恢复被删除的代码
This commit is contained in:
23
soul-api/.air.toml
Normal file
23
soul-api/.air.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
# Air 热重载配置:改 .go 后自动重新编译并重启
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
# Windows 下用 .exe 避免系统弹出「选择应用打开 main」
|
||||
[build]
|
||||
bin = "./tmp/main.exe"
|
||||
cmd = "go build -o ./tmp/main.exe ./cmd/server"
|
||||
delay = 800
|
||||
exclude_dir = ["tmp", "vendor"]
|
||||
exclude_regex = ["_test\\.go$"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
log = "build-errors.log"
|
||||
stop_on_error = true
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
@@ -6,7 +6,7 @@ GIN_MODE=debug
|
||||
APP_VERSION=0.0.0
|
||||
|
||||
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram)
|
||||
DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True
|
||||
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=wxb8bbb2b10dec74aa
|
||||
|
||||
36
soul-api/.env.example
Normal file
36
soul-api/.env.example
Normal file
@@ -0,0 +1,36 @@
|
||||
# 服务(启动端口在 .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
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=wxb8bbb2b10dec74aa
|
||||
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
|
||||
WECHAT_MCH_ID=1318592501
|
||||
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||
WECHAT_NOTIFY_URL=https://soul.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
|
||||
WECHAT_TRANSFER_URL=https://soul.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
|
||||
7
soul-api/.gitignore
vendored
7
soul-api/.gitignore
vendored
@@ -1,7 +1,4 @@
|
||||
.env
|
||||
certs/
|
||||
.htaccess
|
||||
tmp/
|
||||
soul-api
|
||||
server.exe
|
||||
soul-api.exe
|
||||
uploads/
|
||||
*.log
|
||||
|
||||
9
soul-api/Makefile
Normal file
9
soul-api/Makefile
Normal file
@@ -0,0 +1,9 @@
|
||||
# 开发:热重载(需先安装 air: go install github.com/air-verse/air@latest)
|
||||
dev:
|
||||
air
|
||||
|
||||
# 普通运行(无热重载)
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
.PHONY: dev run
|
||||
BIN
soul-api/__pycache__/devlop.cpython-311.pyc
Normal file
BIN
soul-api/__pycache__/devlop.cpython-311.pyc
Normal file
Binary file not shown.
58
soul-api/cmd/server/main.go
Normal file
58
soul-api/cmd/server/main.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/router"
|
||||
"soul-api/internal/wechat"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatal("load config: ", err)
|
||||
}
|
||||
config.SetCurrent(cfg)
|
||||
if err := database.Init(cfg.DBDSN); err != nil {
|
||||
log.Fatal("database: ", err)
|
||||
}
|
||||
if err := wechat.Init(cfg); err != nil {
|
||||
log.Fatal("wechat: ", err)
|
||||
}
|
||||
if err := wechat.InitTransfer(cfg); err != nil {
|
||||
log.Fatal("wechat transfer: ", err)
|
||||
}
|
||||
|
||||
r := router.Setup(cfg)
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("soul-api listen on :%s (mode=%s)", cfg.Port, cfg.Mode)
|
||||
log.Printf(" -> 访问地址: http://localhost:%s (健康检查: http://localhost:%s/health)", cfg.Port, cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal("listen: ", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
log.Println("shutting down...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("server shutdown: ", err)
|
||||
}
|
||||
log.Println("bye")
|
||||
}
|
||||
390
soul-api/dev_dev.py
Normal file
390
soul-api/dev_dev.py
Normal file
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soul-api Go 项目一键部署到宝塔,重启的是宝塔里的 soulDev 项目
|
||||
- 本地交叉编译 Linux 二进制
|
||||
- 上传到 /www/wwwroot/自营/soul-dev
|
||||
- 重启 soulDev:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
|
||||
宝塔 API 重启(可选):在环境变量或 .env 中设置
|
||||
BT_PANEL_URL = https://你的面板地址:9988
|
||||
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
|
||||
BT_GO_PROJECT_NAME = soulDev (与宝塔 Go 项目列表里名称一致)
|
||||
并安装 requests: pip install requests
|
||||
"""
|
||||
|
||||
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/自营/soul-dev"
|
||||
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
|
||||
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),
|
||||
"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"
|
||||
# 必须 shell=False,否则 Windows 下 -ldflags 等参数会被当成包路径导致 "malformed import path"
|
||||
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 = 8081
|
||||
|
||||
|
||||
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 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"))
|
||||
env_src = os.path.join(root, ".env")
|
||||
staging_env = os.path.join(staging, ".env")
|
||||
if include_env and os.path.isfile(env_src):
|
||||
shutil.copy2(env_src, staging_env)
|
||||
print(" [已包含] .env")
|
||||
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 项目(需配置 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")
|
||||
if not url or not key:
|
||||
return False
|
||||
if not requests:
|
||||
print(" [提示] 未安装 requests,无法使用宝塔 API,将用 SSH 重启。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()
|
||||
# 宝塔 Go 项目插件:先停止再启动,接口以实际面板版本为准
|
||||
base = url.rstrip("/")
|
||||
params = {"request_time": req_time, "request_token": req_token}
|
||||
# 常见形式:/plugin?name=go_project,POST 带 action、project_name
|
||||
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", "")))
|
||||
# 再调一次 start 确保启动
|
||||
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()
|
||||
remote_tar = "/tmp/soul_api_deploy.tar.gz"
|
||||
project_path = cfg["project_path"]
|
||||
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"):
|
||||
# SSH:只杀「工作目录为本项目」的 soul-api,避免误杀其他 Go 项目
|
||||
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(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: 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()
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soul-api 一键部署到宝塔,重启 soulDev 项目",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
|
||||
parser.add_argument("--no-env", action="store_true", help="不打包 .env(保留服务器现有 .env)")
|
||||
parser.add_argument("--no-restart", action="store_true", help="上传后不重启服务")
|
||||
parser.add_argument(
|
||||
"--restart-method",
|
||||
choices=("auto", "btapi", "ssh"),
|
||||
default="auto",
|
||||
help="重启方式: auto=先试宝塔API再SSH, btapi=仅宝塔API, ssh=仅SSH (默认 auto)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root = script_dir
|
||||
|
||||
cfg = get_cfg()
|
||||
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 二进制,请先编译或去掉 --no-build")
|
||||
return 1
|
||||
print("[1/4] 跳过编译,使用现有 soul-api")
|
||||
|
||||
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())
|
||||
390
soul-api/devlop.py
Normal file
390
soul-api/devlop.py
Normal file
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
soulApp (soul-api) Go 项目一键部署到宝塔
|
||||
- 本地交叉编译 Linux 二进制
|
||||
- 上传到 /www/wwwroot/自营/soul-api
|
||||
- 重启:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动
|
||||
|
||||
宝塔 API 重启(可选):在环境变量或 .env 中设置
|
||||
BT_PANEL_URL = https://你的面板地址:9988
|
||||
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
|
||||
BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致)
|
||||
并安装 requests: pip install requests
|
||||
"""
|
||||
|
||||
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/自营/soul-api"
|
||||
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
|
||||
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),
|
||||
"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", "soulApi"),
|
||||
}
|
||||
|
||||
|
||||
# ==================== 本地构建 ====================
|
||||
|
||||
|
||||
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"
|
||||
# 必须 shell=False,否则 Windows 下 -ldflags 等参数会被当成包路径导致 "malformed import path"
|
||||
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 = 8080
|
||||
|
||||
|
||||
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 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"))
|
||||
env_src = os.path.join(root, ".env")
|
||||
staging_env = os.path.join(staging, ".env")
|
||||
if include_env and os.path.isfile(env_src):
|
||||
shutil.copy2(env_src, staging_env)
|
||||
print(" [已包含] .env")
|
||||
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, "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):
|
||||
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 项目(需配置 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", "soulApi")
|
||||
if not url or not key:
|
||||
return False
|
||||
if not requests:
|
||||
print(" [提示] 未安装 requests,无法使用宝塔 API,将用 SSH 重启。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()
|
||||
# 宝塔 Go 项目插件:先停止再启动,接口以实际面板版本为准
|
||||
base = url.rstrip("/")
|
||||
params = {"request_time": req_time, "request_token": req_token}
|
||||
# 常见形式:/plugin?name=go_project,POST 带 action、project_name
|
||||
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", "")))
|
||||
# 再调一次 start 确保启动
|
||||
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()
|
||||
remote_tar = "/tmp/soul_api_deploy.tar.gz"
|
||||
project_path = cfg["project_path"]
|
||||
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] 重启 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)
|
||||
if not ok and restart_method in ("auto", "ssh"):
|
||||
# SSH:只杀「工作目录为本项目」的 soul-api,避免误杀其他 Go 项目
|
||||
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(" [成功] soulApp 已通过 SSH 重启")
|
||||
else:
|
||||
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: 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()
|
||||
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="soulApp (soul-api) Go 项目一键部署到宝塔",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
|
||||
parser.add_argument("--no-env", action="store_true", help="不打包 .env(保留服务器现有 .env)")
|
||||
parser.add_argument("--no-restart", action="store_true", help="上传后不重启服务")
|
||||
parser.add_argument(
|
||||
"--restart-method",
|
||||
choices=("auto", "btapi", "ssh"),
|
||||
default="auto",
|
||||
help="重启方式: auto=先试宝塔API再SSH, btapi=仅宝塔API, ssh=仅SSH (默认 auto)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root = script_dir
|
||||
|
||||
cfg = get_cfg()
|
||||
print("=" * 60)
|
||||
print(" soulApp 一键部署到宝塔")
|
||||
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 二进制,请先编译或去掉 --no-build")
|
||||
return 1
|
||||
print("[1/4] 跳过编译,使用现有 soul-api")
|
||||
|
||||
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())
|
||||
59
soul-api/go.mod
Normal file
59
soul-api/go.mod
Normal file
@@ -0,0 +1,59 @@
|
||||
module soul-api
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/unrolled/secure v1.17.0
|
||||
golang.org/x/time v0.8.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.17.3 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
158
soul-api/go.sum
Normal file
158
soul-api/go.sum
Normal file
@@ -0,0 +1,158 @@
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 h1:IInr1YWwkhwOykxDqux1Goym0uFhrYwBjmgLnEwCLqs=
|
||||
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2/go.mod h1:xFGsskCnzAu+6rFEJbGVAlwhrwZPXAny6m7j71S/B5k=
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 h1:yu4A7WhPXfs/RSYFL2UdHFRQYAXbrpiBOT3kJ5hjepU=
|
||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38/go.mod h1:boWl2cwbgXt1AbrYTWMXs9Ebby6ecbJ1CyNVRaNVqUY=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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/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=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
|
||||
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.4.0 h1:LJE4SW3jd4lQTESnlpQZcBhQ3oci0U2MLR5uhicfTHQ=
|
||||
go.opentelemetry.io/otel/sdk v1.4.0/go.mod h1:71GJPNJh4Qju6zJuYl1CrYtXbrgfau/M9UAggqiy1UE=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
68
soul-api/internal/auth/adminjwt.go
Normal file
68
soul-api/internal/auth/adminjwt.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package auth 管理端 JWT:签发与校验,使用 Authorization: Bearer <token>
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const adminJWTExpire = 7 * 24 * time.Hour // 7 天
|
||||
|
||||
// AdminClaims 管理端 JWT 载荷
|
||||
type AdminClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// IssueAdminJWT 签发管理端 JWT,使用 ADMIN_SESSION_SECRET 签名
|
||||
func IssueAdminJWT(secret, username string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := AdminClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(adminJWTExpire)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Subject: "admin",
|
||||
},
|
||||
Username: username,
|
||||
Role: "admin",
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return tok.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// ParseAdminJWT 校验并解析 JWT,返回 claims;无效或过期返回 nil, false
|
||||
func ParseAdminJWT(tokenString, secret string) (*AdminClaims, bool) {
|
||||
if tokenString == "" || secret == "" {
|
||||
return nil, false
|
||||
}
|
||||
tok, err := jwt.ParseWithClaims(tokenString, &AdminClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil || !tok.Valid {
|
||||
return nil, false
|
||||
}
|
||||
claims, ok := tok.Claims.(*AdminClaims)
|
||||
if !ok || claims.Username == "" {
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
// GetAdminJWTFromRequest 从请求中读取 JWT:优先 Authorization: Bearer <token>,其次 Cookie admin_session(兼容旧端)
|
||||
func GetAdminJWTFromRequest(r *http.Request) string {
|
||||
// 1. Authorization: Bearer <token>
|
||||
ah := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(ah, "Bearer ") {
|
||||
return strings.TrimSpace(ah[7:])
|
||||
}
|
||||
// 2. Cookie(兼容:若值为 JWT 格式则可用)
|
||||
c, err := r.Cookie(adminCookieName)
|
||||
if err != nil || c == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.Value)
|
||||
}
|
||||
71
soul-api/internal/auth/adminsession.go
Normal file
71
soul-api/internal/auth/adminsession.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Package auth 管理端 session:与 next-project lib/admin-auth.ts 的 token 格式兼容(exp.signature)
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
adminCookieName = "admin_session"
|
||||
maxAgeSec = 7 * 24 * 3600 // 7 天
|
||||
)
|
||||
|
||||
// CreateAdminToken 生成签名 token,格式与 next 一致:exp.base64url(hmac_sha256(exp))
|
||||
func CreateAdminToken(secret string) string {
|
||||
exp := time.Now().Unix() + maxAgeSec
|
||||
payload := strconv.FormatInt(exp, 10)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(payload))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return payload + "." + sig
|
||||
}
|
||||
|
||||
// VerifyAdminToken 校验 token:解析 exp、验签、验过期
|
||||
func VerifyAdminToken(token, secret string) bool {
|
||||
if token == "" || secret == "" {
|
||||
return false
|
||||
}
|
||||
dot := strings.Index(token, ".")
|
||||
if dot <= 0 {
|
||||
return false
|
||||
}
|
||||
payload := token[:dot]
|
||||
sig := token[dot+1:]
|
||||
exp, err := strconv.ParseInt(payload, 10, 64)
|
||||
if err != nil || exp < time.Now().Unix() {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(payload))
|
||||
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(sig), []byte(expected))
|
||||
}
|
||||
|
||||
// AdminCookieName 返回 Cookie 名
|
||||
func AdminCookieName() string { return adminCookieName }
|
||||
|
||||
// MaxAgeSec 返回 session 有效秒数
|
||||
func MaxAgeSec() int { return maxAgeSec }
|
||||
|
||||
// SetCookieHeaderValue 返回完整的 Set-Cookie 头内容(含 SameSite=None; Secure,供跨站时携带 Cookie)
|
||||
func SetCookieHeaderValue(token string, maxAge int) string {
|
||||
if maxAge <= 0 {
|
||||
return adminCookieName + "=; Path=/; Max-Age=0; HttpOnly; SameSite=None; Secure"
|
||||
}
|
||||
return adminCookieName + "=" + token + "; Path=/; Max-Age=" + strconv.Itoa(maxAge) + "; HttpOnly; SameSite=None; Secure"
|
||||
}
|
||||
|
||||
// GetAdminTokenFromRequest 从请求 Cookie 中读取 admin_session
|
||||
func GetAdminTokenFromRequest(r *http.Request) string {
|
||||
c, err := r.Cookie(adminCookieName)
|
||||
if err != nil || c == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.Value)
|
||||
}
|
||||
185
soul-api/internal/config/config.go
Normal file
185
soul-api/internal/config/config.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config 应用配置(从环境变量读取,启动时加载 .env)
|
||||
type Config struct {
|
||||
Port string
|
||||
Mode string
|
||||
DBDSN string
|
||||
TrustedProxies []string
|
||||
CORSOrigins []string
|
||||
Version string // APP_VERSION,打包/部署前写在 .env,/health 返回
|
||||
|
||||
// 微信小程序配置
|
||||
WechatAppID string
|
||||
WechatAppSecret string
|
||||
WechatMchID string
|
||||
WechatMchKey string
|
||||
WechatNotifyURL string
|
||||
WechatMiniProgramState string // 订阅消息跳转版本:developer/formal,从 .env WECHAT_MINI_PROGRAM_STATE 读取
|
||||
|
||||
// 微信转账配置(API v3)
|
||||
WechatAPIv3Key string
|
||||
WechatCertPath string
|
||||
WechatKeyPath string
|
||||
WechatSerialNo string
|
||||
WechatTransferURL string // 转账回调地址
|
||||
|
||||
// 管理端登录(与 next-project 一致:ADMIN_USERNAME / ADMIN_PASSWORD / ADMIN_SESSION_SECRET)
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
AdminSessionSecret string
|
||||
}
|
||||
|
||||
// 默认 CORS 允许的源(零配置:不设环境变量也能用)
|
||||
var defaultCORSOrigins = []string{
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
"https://soul.quwanzhi.com",
|
||||
"http://soul.quwanzhi.com",
|
||||
"http://souladmin.quwanzhi.com",
|
||||
}
|
||||
|
||||
// current 由 main 在 Load 后设置,供 handler/middleware 读取
|
||||
var current *Config
|
||||
|
||||
// SetCurrent 设置全局配置(main 启动时调用一次)
|
||||
func SetCurrent(cfg *Config) { current = cfg }
|
||||
|
||||
// Get 返回当前配置,未设置时返回 nil
|
||||
func Get() *Config { return current }
|
||||
|
||||
// parseCORSOrigins 从环境变量 CORS_ORIGINS 读取(逗号分隔),未设置则用默认值
|
||||
func parseCORSOrigins() []string {
|
||||
s := os.Getenv("CORS_ORIGINS")
|
||||
if s == "" {
|
||||
return defaultCORSOrigins
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
origins := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if o := strings.TrimSpace(p); o != "" {
|
||||
origins = append(origins, o)
|
||||
}
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
return defaultCORSOrigins
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
// Load 加载配置,端口等从 .env 读取。优先从可执行文件同目录加载 .env,再试当前目录
|
||||
func Load() (*Config, error) {
|
||||
if execPath, err := os.Executable(); err == nil {
|
||||
_ = godotenv.Load(filepath.Join(filepath.Dir(execPath), ".env"))
|
||||
}
|
||||
_ = godotenv.Load(".env")
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
mode := os.Getenv("GIN_MODE")
|
||||
if mode == "" {
|
||||
mode = "debug"
|
||||
}
|
||||
dsn := os.Getenv("DB_DSN")
|
||||
if dsn == "" {
|
||||
dsn = "user:pass@tcp(127.0.0.1:3306)/soul?charset=utf8mb4&parseTime=True"
|
||||
}
|
||||
version := os.Getenv("APP_VERSION")
|
||||
if version == "" {
|
||||
version = "0.0.0"
|
||||
}
|
||||
|
||||
// 微信配置
|
||||
wechatAppID := os.Getenv("WECHAT_APPID")
|
||||
if wechatAppID == "" {
|
||||
wechatAppID = "wxb8bbb2b10dec74aa" // 默认小程序AppID
|
||||
}
|
||||
wechatAppSecret := os.Getenv("WECHAT_APPSECRET")
|
||||
if wechatAppSecret == "" {
|
||||
wechatAppSecret = "3c1fb1f63e6e052222bbcead9d07fe0c" // 默认小程序AppSecret
|
||||
}
|
||||
wechatMchID := os.Getenv("WECHAT_MCH_ID")
|
||||
if wechatMchID == "" {
|
||||
wechatMchID = "1318592501" // 默认商户号
|
||||
}
|
||||
wechatMchKey := os.Getenv("WECHAT_MCH_KEY")
|
||||
if wechatMchKey == "" {
|
||||
wechatMchKey = "wx3e31b068be59ddc131b068be59ddc2" // 默认API密钥(v2)
|
||||
}
|
||||
wechatNotifyURL := os.Getenv("WECHAT_NOTIFY_URL")
|
||||
if wechatNotifyURL == "" {
|
||||
wechatNotifyURL = "https://soul.quwanzhi.com/api/miniprogram/pay/notify" // 默认回调地址
|
||||
}
|
||||
wechatMiniProgramState := strings.TrimSpace(os.Getenv("WECHAT_MINI_PROGRAM_STATE"))
|
||||
if wechatMiniProgramState != "developer" && wechatMiniProgramState != "trial" {
|
||||
wechatMiniProgramState = "formal" // 默认正式版,避免生成开发版码导致「开发版已过期」
|
||||
}
|
||||
|
||||
// 转账配置
|
||||
wechatAPIv3Key := os.Getenv("WECHAT_APIV3_KEY")
|
||||
if wechatAPIv3Key == "" {
|
||||
wechatAPIv3Key = "wx3e31b068be59ddc131b068be59ddc2" // 默认 API v3 密钥
|
||||
}
|
||||
wechatCertPath := os.Getenv("WECHAT_CERT_PATH")
|
||||
if wechatCertPath == "" {
|
||||
wechatCertPath = "certs/apiclient_cert.pem" // 默认证书路径
|
||||
}
|
||||
wechatKeyPath := os.Getenv("WECHAT_KEY_PATH")
|
||||
if wechatKeyPath == "" {
|
||||
wechatKeyPath = "certs/apiclient_key.pem" // 默认私钥路径
|
||||
}
|
||||
wechatSerialNo := os.Getenv("WECHAT_SERIAL_NO")
|
||||
if wechatSerialNo == "" {
|
||||
wechatSerialNo = "4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5" // 默认证书序列号
|
||||
}
|
||||
wechatTransferURL := os.Getenv("WECHAT_TRANSFER_URL")
|
||||
if wechatTransferURL == "" {
|
||||
wechatTransferURL = "https://soul.quwanzhi.com/api/payment/wechat/transfer/notify" // 默认转账回调地址
|
||||
}
|
||||
|
||||
adminUsername := os.Getenv("ADMIN_USERNAME")
|
||||
if adminUsername == "" {
|
||||
adminUsername = "admin"
|
||||
}
|
||||
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminPassword == "" {
|
||||
adminPassword = "admin123"
|
||||
}
|
||||
adminSessionSecret := os.Getenv("ADMIN_SESSION_SECRET")
|
||||
if adminSessionSecret == "" {
|
||||
adminSessionSecret = "soul-admin-secret-change-in-prod"
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
Mode: mode,
|
||||
DBDSN: dsn,
|
||||
TrustedProxies: []string{"127.0.0.1", "::1"},
|
||||
CORSOrigins: parseCORSOrigins(),
|
||||
Version: version,
|
||||
WechatAppID: wechatAppID,
|
||||
WechatAppSecret: wechatAppSecret,
|
||||
WechatMchID: wechatMchID,
|
||||
WechatMchKey: wechatMchKey,
|
||||
WechatNotifyURL: wechatNotifyURL,
|
||||
WechatMiniProgramState: wechatMiniProgramState,
|
||||
WechatAPIv3Key: wechatAPIv3Key,
|
||||
WechatCertPath: wechatCertPath,
|
||||
WechatKeyPath: wechatKeyPath,
|
||||
WechatSerialNo: wechatSerialNo,
|
||||
WechatTransferURL: wechatTransferURL,
|
||||
AdminUsername: adminUsername,
|
||||
AdminPassword: adminPassword,
|
||||
AdminSessionSecret: adminSessionSecret,
|
||||
}, nil
|
||||
}
|
||||
40
soul-api/internal/database/database.go
Normal file
40
soul-api/internal/database/database.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"soul-api/internal/model"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
// Init 使用 DSN 连接 MySQL,供 handler 通过 DB() 使用
|
||||
func Init(dsn string) error {
|
||||
var err error
|
||||
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&model.WechatCallbackLog{}); err != nil {
|
||||
log.Printf("database: wechat_callback_logs migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Withdrawal{}); err != nil {
|
||||
log.Printf("database: withdrawals migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.MatchRecord{}); err != nil {
|
||||
log.Printf("database: match_records migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserAddress{}); err != nil {
|
||||
log.Printf("database: user_addresses migrate warning: %v", err)
|
||||
}
|
||||
log.Println("database: connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DB 返回全局 *gorm.DB,仅在 Init 成功后调用
|
||||
func DB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
92
soul-api/internal/handler/admin.go
Normal file
92
soul-api/internal/handler/admin.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/auth"
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminCheck GET /api/admin 鉴权检查(JWT:Authorization Bearer 或 Cookie),已登录返回 success 或概览占位
|
||||
func AdminCheck(c *gin.Context) {
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
token := auth.GetAdminJWTFromRequest(c.Request)
|
||||
if _, ok := auth.ParseAdminJWT(token, cfg.AdminSessionSecret); !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未授权访问,请先登录"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"content": gin.H{
|
||||
"totalChapters": 0, "totalWords": 0, "publishedChapters": 0, "draftChapters": 0,
|
||||
"lastUpdate": nil,
|
||||
},
|
||||
"payment": gin.H{
|
||||
"totalRevenue": 0, "todayRevenue": 0, "totalOrders": 0, "todayOrders": 0, "averagePrice": 0,
|
||||
},
|
||||
"referral": gin.H{
|
||||
"totalReferrers": 0, "activeReferrers": 0, "totalCommission": 0, "paidCommission": 0, "pendingCommission": 0,
|
||||
},
|
||||
"users": gin.H{
|
||||
"totalUsers": 0, "purchasedUsers": 0, "activeUsers": 0, "todayNewUsers": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminLogin POST /api/admin 登录(校验 ADMIN_USERNAME/PASSWORD,返回 JWT,前端存 token 并带 Authorization: Bearer)
|
||||
func AdminLogin(c *gin.Context) {
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "配置未加载"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
return
|
||||
}
|
||||
username := trimSpace(body.Username)
|
||||
password := body.Password
|
||||
if username != cfg.AdminUsername || password != cfg.AdminPassword {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "签发失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"token": token,
|
||||
"user": gin.H{
|
||||
"id": "admin", "username": cfg.AdminUsername, "role": "admin", "name": "卡若",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminLogout POST /api/admin/logout 服务端无状态,仅返回成功;前端需清除本地 token
|
||||
func AdminLogout(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
start := 0
|
||||
for start < len(s) && (s[start] == ' ' || s[start] == '\t') {
|
||||
start++
|
||||
}
|
||||
end := len(s)
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
101
soul-api/internal/handler/admin_chapters.go
Normal file
101
soul-api/internal/handler/admin_chapters.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminChaptersList GET /api/admin/chapters 从 chapters 表组树:part -> chapters -> sections
|
||||
func AdminChaptersList(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"structure": []interface{}{}, "stats": nil}})
|
||||
return
|
||||
}
|
||||
type section struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree bool `json:"isFree"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
type chapter struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Sections []section `json:"sections"`
|
||||
}
|
||||
type part struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Chapters []chapter `json:"chapters"`
|
||||
}
|
||||
partMap := make(map[string]*part)
|
||||
chapterMap := make(map[string]map[string]*chapter)
|
||||
for _, row := range list {
|
||||
if partMap[row.PartID] == nil {
|
||||
partMap[row.PartID] = &part{ID: row.PartID, Title: row.PartTitle, Type: "part", Chapters: []chapter{}}
|
||||
chapterMap[row.PartID] = make(map[string]*chapter)
|
||||
}
|
||||
p := partMap[row.PartID]
|
||||
if chapterMap[row.PartID][row.ChapterID] == nil {
|
||||
ch := chapter{ID: row.ChapterID, Title: row.ChapterTitle, Sections: []section{}}
|
||||
p.Chapters = append(p.Chapters, ch)
|
||||
chapterMap[row.PartID][row.ChapterID] = &p.Chapters[len(p.Chapters)-1]
|
||||
}
|
||||
ch := chapterMap[row.PartID][row.ChapterID]
|
||||
price := 1.0
|
||||
if row.Price != nil {
|
||||
price = *row.Price
|
||||
}
|
||||
isFree := false
|
||||
if row.IsFree != nil {
|
||||
isFree = *row.IsFree
|
||||
}
|
||||
st := "published"
|
||||
if row.Status != nil {
|
||||
st = *row.Status
|
||||
}
|
||||
ch.Sections = append(ch.Sections, section{ID: row.ID, Title: row.SectionTitle, Price: price, IsFree: isFree, Status: st})
|
||||
}
|
||||
structure := make([]part, 0, len(partMap))
|
||||
for _, p := range partMap {
|
||||
structure = append(structure, *p)
|
||||
}
|
||||
var total int64
|
||||
database.DB().Model(&model.Chapter{}).Count(&total)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"structure": structure, "stats": gin.H{"totalSections": total}},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminChaptersAction POST/PUT/DELETE /api/admin/chapters
|
||||
func AdminChaptersAction(c *gin.Context) {
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
ID string `json:"id"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
if body.Action == "updatePrice" && body.ID != "" && body.Price != nil {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("price", *body.Price)
|
||||
}
|
||||
if body.Action == "toggleFree" && body.ID != "" && body.IsFree != nil {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("is_free", *body.IsFree)
|
||||
}
|
||||
if body.Action == "updateStatus" && body.ID != "" && body.Status != nil {
|
||||
db.Model(&model.Chapter{}).Where("id = ?", body.ID).Update("status", *body.Status)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
99
soul-api/internal/handler/admin_distribution.go
Normal file
99
soul-api/internal/handler/admin_distribution.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminDistributionOverview GET /api/admin/distribution/overview(全部使用 GORM,无 Raw SQL)
|
||||
func AdminDistributionOverview(c *gin.Context) {
|
||||
now := time.Now()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
todayEnd := todayStart.Add(24 * time.Hour)
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
db := database.DB()
|
||||
overview := gin.H{
|
||||
"todayClicks": 0, "todayBindings": 0, "todayConversions": 0, "todayEarnings": 0,
|
||||
"monthClicks": 0, "monthBindings": 0, "monthConversions": 0, "monthEarnings": 0,
|
||||
"totalClicks": 0, "totalBindings": 0, "totalConversions": 0, "totalEarnings": 0,
|
||||
"expiringBindings": 0, "pendingWithdrawals": 0, "pendingWithdrawAmount": 0,
|
||||
"conversionRate": "0.00", "totalDistributors": 0, "activeDistributors": 0,
|
||||
}
|
||||
|
||||
// 订单:仅用 Where + Count / Select(Sum) 参数化
|
||||
var totalOrders int64
|
||||
db.Model(&model.Order{}).Where("status = ?", "paid").Count(&totalOrders)
|
||||
var totalAmount float64
|
||||
db.Model(&model.Order{}).Where("status = ?", "paid").Select("COALESCE(SUM(amount),0)").Scan(&totalAmount)
|
||||
var todayOrders int64
|
||||
db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Count(&todayOrders)
|
||||
var todayAmount float64
|
||||
db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Select("COALESCE(SUM(amount),0)").Scan(&todayAmount)
|
||||
var monthOrders int64
|
||||
db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Count(&monthOrders)
|
||||
var monthAmount float64
|
||||
db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Select("COALESCE(SUM(amount),0)").Scan(&monthAmount)
|
||||
overview["totalEarnings"] = totalAmount
|
||||
overview["todayEarnings"] = todayAmount
|
||||
overview["monthEarnings"] = monthAmount
|
||||
|
||||
// 绑定:全部 GORM Where
|
||||
var totalBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Count(&totalBindings)
|
||||
var converted int64
|
||||
db.Model(&model.ReferralBinding{}).Where("status = ?", "converted").Count(&converted)
|
||||
var todayBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where("binding_date >= ? AND binding_date < ?", todayStart, todayEnd).Count(&todayBindings)
|
||||
var todayConv int64
|
||||
db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ? AND binding_date < ?", "converted", todayStart, todayEnd).Count(&todayConv)
|
||||
var monthBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where("binding_date >= ?", monthStart).Count(&monthBindings)
|
||||
var monthConv int64
|
||||
db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ?", "converted", monthStart).Count(&monthConv)
|
||||
expiringEnd := now.Add(7 * 24 * time.Hour)
|
||||
var expiring int64
|
||||
db.Model(&model.ReferralBinding{}).Where("status = ? AND expiry_date > ? AND expiry_date <= ?", "active", now, expiringEnd).Count(&expiring)
|
||||
overview["totalBindings"] = totalBindings
|
||||
overview["totalConversions"] = converted
|
||||
overview["todayBindings"] = todayBindings
|
||||
overview["todayConversions"] = todayConv
|
||||
overview["monthBindings"] = monthBindings
|
||||
overview["monthConversions"] = monthConv
|
||||
overview["expiringBindings"] = expiring
|
||||
|
||||
// 访问数
|
||||
var visitTotal int64
|
||||
db.Model(&model.ReferralVisit{}).Count(&visitTotal)
|
||||
overview["totalClicks"] = visitTotal
|
||||
if visitTotal > 0 && converted > 0 {
|
||||
overview["conversionRate"] = formatPercent(float64(converted)/float64(visitTotal)*100)
|
||||
}
|
||||
|
||||
// 提现待处理
|
||||
var pendCount int64
|
||||
db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Count(&pendCount)
|
||||
var pendSum float64
|
||||
db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Select("COALESCE(SUM(amount),0)").Scan(&pendSum)
|
||||
overview["pendingWithdrawals"] = pendCount
|
||||
overview["pendingWithdrawAmount"] = pendSum
|
||||
|
||||
// 分销商
|
||||
var distTotal int64
|
||||
db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ?", "").Count(&distTotal)
|
||||
var distActive int64
|
||||
db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ? AND earnings > ?", "", 0).Count(&distActive)
|
||||
overview["totalDistributors"] = distTotal
|
||||
overview["activeDistributors"] = distActive
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "overview": overview})
|
||||
}
|
||||
|
||||
func formatPercent(v float64) string {
|
||||
return fmt.Sprintf("%.2f", v) + "%"
|
||||
}
|
||||
22
soul-api/internal/handler/admin_extra.go
Normal file
22
soul-api/internal/handler/admin_extra.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminContent GET/POST/PUT/DELETE /api/admin/content
|
||||
func AdminContent(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// AdminPayment GET/POST/PUT/DELETE /api/admin/payment
|
||||
func AdminPayment(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// AdminReferral GET/POST/PUT/DELETE /api/admin/referral
|
||||
func AdminReferral(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
383
soul-api/internal/handler/admin_withdrawals.go
Normal file
383
soul-api/internal/handler/admin_withdrawals.go
Normal file
@@ -0,0 +1,383 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminWithdrawalsList GET /api/admin/withdrawals
|
||||
func AdminWithdrawalsList(c *gin.Context) {
|
||||
statusFilter := c.Query("status")
|
||||
var list []model.Withdrawal
|
||||
q := database.DB().Order("created_at DESC").Limit(100)
|
||||
if statusFilter != "" {
|
||||
q = q.Where("status = ?", statusFilter)
|
||||
}
|
||||
if err := q.Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "withdrawals": []interface{}{}, "stats": gin.H{"total": 0}})
|
||||
return
|
||||
}
|
||||
userIds := make([]string, 0, len(list))
|
||||
seen := make(map[string]bool)
|
||||
for _, w := range list {
|
||||
if !seen[w.UserID] {
|
||||
seen[w.UserID] = true
|
||||
userIds = append(userIds, w.UserID)
|
||||
}
|
||||
}
|
||||
var users []model.User
|
||||
if len(userIds) > 0 {
|
||||
database.DB().Where("id IN ?", userIds).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
withdrawals := make([]gin.H, 0, len(list))
|
||||
for _, w := range list {
|
||||
u := userMap[w.UserID]
|
||||
userName := "未知用户"
|
||||
var userAvatar *string
|
||||
account := "未绑定微信号"
|
||||
if w.WechatID != nil && *w.WechatID != "" {
|
||||
account = *w.WechatID
|
||||
}
|
||||
if u != nil {
|
||||
if u.Nickname != nil {
|
||||
userName = *u.Nickname
|
||||
}
|
||||
userAvatar = u.Avatar
|
||||
if u.WechatID != nil && *u.WechatID != "" {
|
||||
account = *u.WechatID
|
||||
}
|
||||
}
|
||||
st := "pending"
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
if st == "success" {
|
||||
st = "completed"
|
||||
} else if st == "failed" {
|
||||
st = "rejected"
|
||||
} else if st == "pending_confirm" {
|
||||
st = "pending_confirm"
|
||||
}
|
||||
}
|
||||
userConfirmedAt := interface{}(nil)
|
||||
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
|
||||
userConfirmedAt = w.UserConfirmedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
withdrawals = append(withdrawals, gin.H{
|
||||
"id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": userAvatar,
|
||||
"amount": w.Amount, "status": st, "createdAt": w.CreatedAt,
|
||||
"method": "wechat", "account": account,
|
||||
"userConfirmedAt": userConfirmedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "withdrawals": withdrawals, "stats": gin.H{"total": len(withdrawals)}})
|
||||
}
|
||||
|
||||
// AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款
|
||||
// approve:先调微信转账接口打款,成功则标为 processing,失败则标为 failed 并返回错误。
|
||||
// 若未初始化微信转账客户端,则仅将状态标为 success(线下打款后批准)。
|
||||
// reject:直接标为 failed。
|
||||
func AdminWithdrawalsAction(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
|
||||
return
|
||||
}
|
||||
reason := body.ErrorMessage
|
||||
if reason == "" {
|
||||
reason = body.Reason
|
||||
}
|
||||
if reason == "" && body.Action == "reject" {
|
||||
reason = "管理员拒绝"
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
now := time.Now()
|
||||
|
||||
switch body.Action {
|
||||
case "reject":
|
||||
err := db.Model(&model.Withdrawal{}).Where("id = ?", body.ID).Updates(map[string]interface{}{
|
||||
"status": "failed",
|
||||
"error_message": reason,
|
||||
"fail_reason": reason,
|
||||
"processed_at": now,
|
||||
}).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已拒绝"})
|
||||
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)
|
||||
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 白名单、参数错误等)
|
||||
})
|
||||
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,
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"})
|
||||
}
|
||||
}
|
||||
|
||||
// AdminWithdrawalsSync POST /api/admin/withdrawals/sync 主动向微信查询转账结果并更新状态(无回调时的备选)
|
||||
// body: { "id": "提现记录id" } 同步单条;不传 id 或 id 为空则同步所有 processing/pending_confirm
|
||||
func AdminWithdrawalsSync(c *gin.Context) {
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
|
||||
db := database.DB()
|
||||
var list []model.Withdrawal
|
||||
if body.ID != "" {
|
||||
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
|
||||
}
|
||||
list = []model.Withdrawal{w}
|
||||
} else {
|
||||
if err := db.Where("status IN ?", []string{"processing", "pending_confirm"}).
|
||||
Find(&list).Error; err != nil || len(list) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "暂无待同步记录", "synced": 0})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
synced := 0
|
||||
for _, w := range list {
|
||||
batchNo := ""
|
||||
detailNo := ""
|
||||
if w.BatchNo != nil {
|
||||
batchNo = *w.BatchNo
|
||||
}
|
||||
if w.DetailNo != nil {
|
||||
detailNo = *w.DetailNo
|
||||
}
|
||||
if detailNo == "" {
|
||||
continue
|
||||
}
|
||||
var status, failReason string
|
||||
// FundApp 单笔:batch_no == detail_no 时用商户单号查询
|
||||
if batchNo == detailNo {
|
||||
state, _, fail, err := wechat.QueryTransferByOutBill(detailNo)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
status = state
|
||||
failReason = fail
|
||||
} else {
|
||||
res, err := wechat.QueryTransfer(batchNo, detailNo)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := res["detail_status"].(string); ok {
|
||||
status = s
|
||||
}
|
||||
if s, ok := res["fail_reason"].(string); ok {
|
||||
failReason = s
|
||||
}
|
||||
}
|
||||
up := map[string]interface{}{"processed_at": now}
|
||||
switch status {
|
||||
case "SUCCESS":
|
||||
up["status"] = "success"
|
||||
case "FAIL":
|
||||
up["status"] = "failed"
|
||||
if failReason != "" {
|
||||
up["fail_reason"] = failReason
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&model.Withdrawal{}).Where("id = ?", w.ID).Updates(up).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
synced++
|
||||
fmt.Printf("[AdminWithdrawals] 同步状态 id=%s -> %s\n", w.ID, up["status"])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "已向微信查询并更新",
|
||||
"synced": synced,
|
||||
"total": len(list),
|
||||
})
|
||||
}
|
||||
17
soul-api/internal/handler/auth.go
Normal file
17
soul-api/internal/handler/auth.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthLogin POST /api/auth/login
|
||||
func AuthLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// AuthResetPassword POST /api/auth/reset-password
|
||||
func AuthResetPassword(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
241
soul-api/internal/handler/book.go
Normal file
241
soul-api/internal/handler/book.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
||||
func BookAllChapters(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookChapterByID GET /api/book/chapter/:id 按业务 id 查询(兼容旧链接)
|
||||
func BookChapterByID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("id = ?", id)
|
||||
})
|
||||
}
|
||||
|
||||
// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐)
|
||||
func BookChapterByMID(c *gin.Context) {
|
||||
midStr := c.Param("mid")
|
||||
if midStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 mid"})
|
||||
return
|
||||
}
|
||||
mid, err := strconv.Atoi(midStr)
|
||||
if err != nil || mid < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "mid 必须为正整数"})
|
||||
return
|
||||
}
|
||||
findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("mid = ?", mid)
|
||||
})
|
||||
}
|
||||
|
||||
// findChapterAndRespond 按条件查章节并返回统一格式
|
||||
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
var ch model.Chapter
|
||||
db := database.DB()
|
||||
if err := whereFn(db).First(&ch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"data": ch,
|
||||
"content": ch.Content,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"partTitle": ch.PartTitle,
|
||||
"id": ch.ID,
|
||||
"mid": ch.MID,
|
||||
"sectionTitle": ch.SectionTitle,
|
||||
}
|
||||
if ch.IsFree != nil {
|
||||
out["isFree"] = *ch.IsFree
|
||||
}
|
||||
if ch.Price != nil {
|
||||
out["price"] = *ch.Price
|
||||
// 价格为 0 元则自动视为免费
|
||||
if *ch.Price == 0 {
|
||||
out["isFree"] = true
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// BookChapters GET/POST/PUT/DELETE /api/book/chapters(与 app/api/book/chapters 一致,用 GORM)
|
||||
func BookChapters(c *gin.Context) {
|
||||
db := database.DB()
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet:
|
||||
partId := c.Query("partId")
|
||||
status := c.Query("status")
|
||||
if status == "" {
|
||||
status = "published"
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "100"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 500 {
|
||||
pageSize = 100
|
||||
}
|
||||
q := db.Model(&model.Chapter{})
|
||||
if partId != "" {
|
||||
q = q.Where("part_id = ?", partId)
|
||||
}
|
||||
if status != "" && status != "all" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var list []model.Chapter
|
||||
q.Order("sort_order ASC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list)
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"list": list, "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||||
},
|
||||
})
|
||||
return
|
||||
case http.MethodPost:
|
||||
var body model.Chapter
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.ID == "" || body.PartID == "" || body.ChapterID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要字段 id/partId/chapterId"})
|
||||
return
|
||||
}
|
||||
if err := db.Create(&body).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
|
||||
return
|
||||
case http.MethodPut:
|
||||
var body model.Chapter
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(map[string]interface{}{
|
||||
"part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle,
|
||||
"content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price,
|
||||
"sort_order": body.SortOrder, "status": body.Status,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
case http.MethodDelete:
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
if err := db.Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
||||
}
|
||||
|
||||
// BookHot GET /api/book/hot
|
||||
func BookHot(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
database.DB().Order("sort_order ASC, id ASC").Limit(10).Find(&list)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookLatestChapters GET /api/book/latest-chapters
|
||||
func BookLatestChapters(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
func escapeLikeBook(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "%", "\\%")
|
||||
s = strings.ReplaceAll(s, "_", "\\_")
|
||||
return s
|
||||
}
|
||||
|
||||
// BookSearch GET /api/book/search?q= 章节搜索(与 /api/search 逻辑一致)
|
||||
func BookSearch(c *gin.Context) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": ""})
|
||||
return
|
||||
}
|
||||
pattern := "%" + escapeLikeBook(q) + "%"
|
||||
var list []model.Chapter
|
||||
err := database.DB().Model(&model.Chapter{}).
|
||||
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Limit(20).
|
||||
Find(&list).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q})
|
||||
return
|
||||
}
|
||||
lowerQ := strings.ToLower(q)
|
||||
results := make([]gin.H, 0, len(list))
|
||||
for _, ch := range list {
|
||||
matchType := "content"
|
||||
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
|
||||
matchType = "title"
|
||||
}
|
||||
results = append(results, gin.H{
|
||||
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle,
|
||||
"isFree": ch.IsFree, "matchType": matchType,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "results": results, "total": len(results), "keyword": q})
|
||||
}
|
||||
|
||||
// BookStats GET /api/book/stats
|
||||
func BookStats(c *gin.Context) {
|
||||
var total int64
|
||||
database.DB().Model(&model.Chapter{}).Count(&total)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
|
||||
}
|
||||
|
||||
// BookSync GET/POST /api/book/sync
|
||||
func BookSync(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步由 DB 维护"})
|
||||
}
|
||||
230
soul-api/internal/handler/ckb.go
Normal file
230
soul-api/internal/handler/ckb.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ckbAPIKey = "fyngh-ecy9h-qkdae-epwd5-rz6kd"
|
||||
const ckbAPIURL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
|
||||
|
||||
var ckbSourceMap = map[string]string{"team": "团队招募", "investor": "资源对接", "mentor": "导师顾问", "partner": "创业合伙"}
|
||||
var ckbTagsMap = map[string]string{"team": "切片团队,团队招募", "investor": "资源对接,资源群", "mentor": "导师顾问,咨询服务", "partner": "创业合伙,创业伙伴"}
|
||||
|
||||
// ckbSign 与 next-project app/api/ckb/join 一致:排除 sign/apiKey/portrait,空值跳过,按键升序拼接值,MD5(拼接串) 再 MD5(结果+apiKey)
|
||||
func ckbSign(params map[string]interface{}, apiKey string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
if k == "sign" || k == "apiKey" || k == "portrait" {
|
||||
continue
|
||||
}
|
||||
v := params[k]
|
||||
if v == nil || v == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var concat string
|
||||
for _, k := range keys {
|
||||
v := params[k]
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
concat += val
|
||||
case float64:
|
||||
concat += strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case int:
|
||||
concat += strconv.Itoa(val)
|
||||
case int64:
|
||||
concat += strconv.FormatInt(val, 10)
|
||||
default:
|
||||
concat += ""
|
||||
}
|
||||
}
|
||||
h := md5.Sum([]byte(concat))
|
||||
first := hex.EncodeToString(h[:])
|
||||
h2 := md5.Sum([]byte(first + apiKey))
|
||||
return hex.EncodeToString(h2[:])
|
||||
}
|
||||
|
||||
// CKBJoin POST /api/ckb/join
|
||||
func CKBJoin(c *gin.Context) {
|
||||
var body struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Phone string `json:"phone"`
|
||||
Wechat string `json:"wechat"`
|
||||
Name string `json:"name"`
|
||||
UserID string `json:"userId"`
|
||||
Remark string `json:"remark"`
|
||||
CanHelp string `json:"canHelp"` // 资源对接:我能帮到你什么
|
||||
NeedHelp string `json:"needHelp"` // 资源对接:我需要什么帮助
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
if body.Phone == "" && body.Wechat == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
if body.Type != "team" && body.Type != "investor" && body.Type != "mentor" && body.Type != "partner" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"})
|
||||
return
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": "创业实验-" + ckbSourceMap[body.Type],
|
||||
"tags": ckbTagsMap[body.Type],
|
||||
"siteTags": "创业实验APP",
|
||||
"remark": body.Remark,
|
||||
}
|
||||
if body.Remark == "" {
|
||||
remark := "用户通过创业实验APP申请" + ckbSourceMap[body.Type]
|
||||
if body.Type == "investor" && (body.CanHelp != "" || body.NeedHelp != "") {
|
||||
remark = fmt.Sprintf("能帮:%s 需要:%s", body.CanHelp, body.NeedHelp)
|
||||
}
|
||||
params["remark"] = remark
|
||||
}
|
||||
if body.Phone != "" {
|
||||
params["phone"] = body.Phone
|
||||
}
|
||||
if body.Wechat != "" {
|
||||
params["wechatId"] = body.Wechat
|
||||
}
|
||||
if body.Name != "" {
|
||||
params["name"] = body.Name
|
||||
}
|
||||
params["apiKey"] = ckbAPIKey
|
||||
params["sign"] = ckbSign(params, ckbAPIKey)
|
||||
sourceData := map[string]interface{}{
|
||||
"joinType": body.Type, "joinLabel": ckbSourceMap[body.Type], "userId": body.UserID,
|
||||
"device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
if body.Type == "investor" {
|
||||
if body.CanHelp != "" {
|
||||
sourceData["canHelp"] = body.CanHelp
|
||||
}
|
||||
if body.NeedHelp != "" {
|
||||
sourceData["needHelp"] = body.NeedHelp
|
||||
}
|
||||
}
|
||||
params["portrait"] = map[string]interface{}{
|
||||
"type": 4, "source": 0,
|
||||
"sourceData": sourceData,
|
||||
"remark": ckbSourceMap[body.Type] + "申请",
|
||||
"uniqueId": "soul_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "服务器错误,请稍后重试"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
msg := "成功加入" + ckbSourceMap[body.Type]
|
||||
if result.Message == "已存在" {
|
||||
msg = "您已加入,我们会尽快联系您"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data})
|
||||
return
|
||||
}
|
||||
errMsg := result.Message
|
||||
if errMsg == "" {
|
||||
errMsg = "加入失败,请稍后重试"
|
||||
}
|
||||
// 打印 CKB 原始响应便于排查
|
||||
fmt.Printf("[CKBJoin] 失败 type=%s wechat=%s code=%d message=%s raw=%s\n",
|
||||
body.Type, body.Wechat, result.Code, result.Message, string(b))
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
|
||||
}
|
||||
|
||||
// CKBMatch POST /api/ckb/match
|
||||
func CKBMatch(c *gin.Context) {
|
||||
var body struct {
|
||||
MatchType string `json:"matchType"`
|
||||
Phone string `json:"phone"`
|
||||
Wechat string `json:"wechat"`
|
||||
UserID string `json:"userId"`
|
||||
Nickname string `json:"nickname"`
|
||||
MatchedUser interface{} `json:"matchedUser"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if body.Phone == "" && body.Wechat == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
label := ckbSourceMap[body.MatchType]
|
||||
if label == "" {
|
||||
label = "创业合伙"
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": "创业实验-找伙伴匹配",
|
||||
"tags": "找伙伴," + label,
|
||||
"siteTags": "创业实验APP,匹配用户",
|
||||
"remark": "用户发起" + label + "匹配",
|
||||
}
|
||||
if body.Phone != "" {
|
||||
params["phone"] = body.Phone
|
||||
}
|
||||
if body.Wechat != "" {
|
||||
params["wechatId"] = body.Wechat
|
||||
}
|
||||
if body.Nickname != "" {
|
||||
params["name"] = body.Nickname
|
||||
}
|
||||
params["apiKey"] = ckbAPIKey
|
||||
params["sign"] = ckbSign(params, ckbAPIKey)
|
||||
params["portrait"] = map[string]interface{}{
|
||||
"type": 4, "source": 0,
|
||||
"sourceData": map[string]interface{}{
|
||||
"action": "match", "matchType": body.MatchType, "matchLabel": label,
|
||||
"userId": body.UserID, "device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
|
||||
},
|
||||
"remark": "找伙伴匹配-" + label,
|
||||
"uniqueId": "soul_match_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配记录已上报", "data": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
|
||||
}
|
||||
|
||||
// CKBSync GET/POST /api/ckb/sync
|
||||
func CKBSync(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
63
soul-api/internal/handler/config.go
Normal file
63
soul-api/internal/handler/config.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetConfig GET /api/config 从 system_config 读取并合并(与 app/api/config 结构一致)
|
||||
func GetConfig(c *gin.Context) {
|
||||
var list []model.SystemConfig
|
||||
if err := database.DB().Order("config_key ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{},
|
||||
"siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{},
|
||||
})
|
||||
return
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{},
|
||||
"siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{},
|
||||
"authorInfo": gin.H{}, "marketing": gin.H{}, "system": gin.H{},
|
||||
}
|
||||
for _, row := range list {
|
||||
var val interface{}
|
||||
_ = json.Unmarshal(row.ConfigValue, &val)
|
||||
switch row.ConfigKey {
|
||||
case "site_config", "siteConfig":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["siteConfig"] = m
|
||||
}
|
||||
case "menu_config", "menuConfig":
|
||||
out["menuConfig"] = val
|
||||
case "page_config", "pageConfig":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["pageConfig"] = m
|
||||
}
|
||||
case "payment_methods", "paymentMethods":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["paymentMethods"] = m
|
||||
}
|
||||
case "live_qr_codes", "liveQRCodes":
|
||||
out["liveQRCodes"] = val
|
||||
case "author_info", "authorInfo":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["authorInfo"] = m
|
||||
}
|
||||
case "marketing":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["marketing"] = m
|
||||
}
|
||||
case "system":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["system"] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
12
soul-api/internal/handler/content.go
Normal file
12
soul-api/internal/handler/content.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ContentGet GET /api/content
|
||||
func ContentGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
75
soul-api/internal/handler/cron.go
Normal file
75
soul-api/internal/handler/cron.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CronSyncOrders GET/POST /api/cron/sync-orders
|
||||
// 对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
|
||||
func CronSyncOrders(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var createdOrders []model.Order
|
||||
// 只处理最近 24 小时内创建的未支付订单
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
if err := db.Where("status = ? AND created_at > ?", "created", cutoff).Find(&createdOrders).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
synced := 0
|
||||
ctx := context.Background()
|
||||
for _, o := range createdOrders {
|
||||
tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN)
|
||||
if err != nil {
|
||||
fmt.Printf("[SyncOrders] 查询订单 %s 失败: %v\n", o.OrderSN, err)
|
||||
continue
|
||||
}
|
||||
if tradeState != "SUCCESS" {
|
||||
continue
|
||||
}
|
||||
// 微信已支付,本地未更新 → 补齐
|
||||
totalAmount := float64(totalFee) / 100
|
||||
now := time.Now()
|
||||
if err := db.Model(&o).Updates(map[string]interface{}{
|
||||
"status": "paid",
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
fmt.Printf("[SyncOrders] 更新订单 %s 失败: %v\n", o.OrderSN, err)
|
||||
continue
|
||||
}
|
||||
synced++
|
||||
fmt.Printf("[SyncOrders] 补齐漏单: %s, amount=%.2f\n", o.OrderSN, totalAmount)
|
||||
|
||||
// 同步后续逻辑(全书、分销等)
|
||||
pt := "fullbook"
|
||||
if o.ProductType != "" {
|
||||
pt = o.ProductType
|
||||
}
|
||||
if pt == "fullbook" {
|
||||
db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true)
|
||||
}
|
||||
processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, &o)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"synced": synced,
|
||||
"total": len(createdOrders),
|
||||
})
|
||||
}
|
||||
|
||||
// CronUnbindExpired GET/POST /api/cron/unbind-expired
|
||||
func CronUnbindExpired(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
868
soul-api/internal/handler/db.go
Normal file
868
soul-api/internal/handler/db.go
Normal file
@@ -0,0 +1,868 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||
// 从 system_config 读取 free_chapters、mp_config、feature_config、chapter_config,合并后返回
|
||||
func GetPublicDBConfig(c *gin.Context) {
|
||||
defaultFree := []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"}
|
||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
|
||||
defaultMp := gin.H{"appId": "wxb8bbb2b10dec74aa", "apiDomain": "https://soul.quwanzhi.com", "buyerDiscount": 5, "referralBindDays": 30, "minWithdraw": 10}
|
||||
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"freeChapters": defaultFree,
|
||||
"prices": defaultPrices,
|
||||
"features": defaultFeatures,
|
||||
"mpConfig": defaultMp,
|
||||
"configs": gin.H{}, // 兼容 miniprogram 备用格式 res.configs.feature_config
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
keys := []string{"chapter_config", "free_chapters", "feature_config", "mp_config"}
|
||||
for _, k := range keys {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
var val interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "chapter_config":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
if v, ok := m["freeChapters"].([]interface{}); ok && len(v) > 0 {
|
||||
arr := make([]string, 0, len(v))
|
||||
for _, x := range v {
|
||||
if s, ok := x.(string); ok {
|
||||
arr = append(arr, s)
|
||||
}
|
||||
}
|
||||
if len(arr) > 0 {
|
||||
out["freeChapters"] = arr
|
||||
}
|
||||
}
|
||||
if v, ok := m["prices"].(map[string]interface{}); ok {
|
||||
out["prices"] = v
|
||||
}
|
||||
if v, ok := m["features"].(map[string]interface{}); ok {
|
||||
out["features"] = v
|
||||
}
|
||||
out["configs"].(gin.H)["chapter_config"] = m
|
||||
}
|
||||
case "free_chapters":
|
||||
if arr, ok := val.([]interface{}); ok && len(arr) > 0 {
|
||||
ss := make([]string, 0, len(arr))
|
||||
for _, x := range arr {
|
||||
if s, ok := x.(string); ok {
|
||||
ss = append(ss, s)
|
||||
}
|
||||
}
|
||||
if len(ss) > 0 {
|
||||
out["freeChapters"] = ss
|
||||
}
|
||||
out["configs"].(gin.H)["free_chapters"] = arr
|
||||
}
|
||||
case "feature_config":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
// 合并到 features,不整体覆盖以保留 chapter_config 里的
|
||||
cur := out["features"].(gin.H)
|
||||
for kk, vv := range m {
|
||||
cur[kk] = vv
|
||||
}
|
||||
out["configs"].(gin.H)["feature_config"] = m
|
||||
}
|
||||
case "mp_config":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["mpConfig"] = m
|
||||
out["configs"].(gin.H)["mp_config"] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
// 好友优惠(用于 read 页展示优惠价)
|
||||
var refRow model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil {
|
||||
var refVal map[string]interface{}
|
||||
if err := json.Unmarshal(refRow.ConfigValue, &refVal); err == nil {
|
||||
if v, ok := refVal["userDiscount"].(float64); ok {
|
||||
out["userDiscount"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, has := out["userDiscount"]; !has {
|
||||
out["userDiscount"] = float64(5)
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// DBConfigGet GET /api/db/config(管理端鉴权后同路径由 db 组处理时用)
|
||||
func DBConfigGet(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
db := database.DB()
|
||||
var list []model.SystemConfig
|
||||
q := db.Table("system_config")
|
||||
if key != "" {
|
||||
q = q.Where("config_key = ?", key)
|
||||
}
|
||||
if err := q.Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if key != "" && len(list) == 1 {
|
||||
var val interface{}
|
||||
_ = json.Unmarshal(list[0].ConfigValue, &val)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
|
||||
return
|
||||
}
|
||||
data := make([]gin.H, 0, len(list))
|
||||
for _, row := range list {
|
||||
var val interface{}
|
||||
_ = json.Unmarshal(row.ConfigValue, &val)
|
||||
data = append(data, gin.H{"configKey": row.ConfigKey, "configValue": val})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
||||
}
|
||||
|
||||
// AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回免费章节、功能开关、站点/作者与价格
|
||||
func AdminSettingsGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"freeChapters": []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"},
|
||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
|
||||
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
|
||||
}
|
||||
keys := []string{"free_chapters", "feature_config", "site_settings"}
|
||||
for _, k := range keys {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
var val interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "free_chapters":
|
||||
if arr, ok := val.([]interface{}); ok && len(arr) > 0 {
|
||||
ss := make([]string, 0, len(arr))
|
||||
for _, x := range arr {
|
||||
if s, ok := x.(string); ok {
|
||||
ss = append(ss, s)
|
||||
}
|
||||
}
|
||||
if len(ss) > 0 {
|
||||
out["freeChapters"] = ss
|
||||
}
|
||||
}
|
||||
case "feature_config":
|
||||
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
|
||||
out["featureConfig"] = m
|
||||
}
|
||||
case "site_settings":
|
||||
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
|
||||
out["siteSettings"] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存免费章节、功能开关、站点/作者与价格(不包含小程序配置,该配置已移除)
|
||||
func AdminSettingsPost(c *gin.Context) {
|
||||
var body struct {
|
||||
FreeChapters []string `json:"freeChapters"`
|
||||
FeatureConfig map[string]interface{} `json:"featureConfig"`
|
||||
SiteSettings map[string]interface{} `json:"siteSettings"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
saveKey := func(key, desc string, value interface{}) error {
|
||||
valBytes, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var row model.SystemConfig
|
||||
err = db.Where("config_key = ?", key).First(&row).Error
|
||||
if err != nil {
|
||||
row = model.SystemConfig{ConfigKey: key, ConfigValue: valBytes, Description: &desc}
|
||||
return db.Create(&row).Error
|
||||
}
|
||||
row.ConfigValue = valBytes
|
||||
if desc != "" {
|
||||
row.Description = &desc
|
||||
}
|
||||
return db.Save(&row).Error
|
||||
}
|
||||
if body.FreeChapters != nil {
|
||||
if err := saveKey("free_chapters", "免费章节ID列表", body.FreeChapters); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存免费章节失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.FeatureConfig != nil {
|
||||
if err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存功能开关失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.SiteSettings != nil {
|
||||
if err := saveKey("site_settings", "站点与作者配置", body.SiteSettings); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存站点设置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"})
|
||||
}
|
||||
|
||||
// AdminReferralSettingsGet GET /api/admin/referral-settings 推广设置页专用:仅返回 referral_config
|
||||
func AdminReferralSettingsGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
defaultConfig := gin.H{
|
||||
"distributorShare": float64(90),
|
||||
"minWithdrawAmount": float64(10),
|
||||
"bindingDays": float64(30),
|
||||
"userDiscount": float64(5),
|
||||
"withdrawFee": float64(5),
|
||||
"enableAutoWithdraw": false,
|
||||
}
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig})
|
||||
return
|
||||
}
|
||||
var val map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil || len(val) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
|
||||
}
|
||||
|
||||
// AdminReferralSettingsPost POST /api/admin/referral-settings 推广设置页专用:仅保存 referral_config(请求体为完整配置对象)
|
||||
func AdminReferralSettingsPost(c *gin.Context) {
|
||||
var body struct {
|
||||
DistributorShare float64 `json:"distributorShare"`
|
||||
MinWithdrawAmount float64 `json:"minWithdrawAmount"`
|
||||
BindingDays float64 `json:"bindingDays"`
|
||||
UserDiscount float64 `json:"userDiscount"`
|
||||
WithdrawFee float64 `json:"withdrawFee"`
|
||||
EnableAutoWithdraw bool `json:"enableAutoWithdraw"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
val := gin.H{
|
||||
"distributorShare": body.DistributorShare,
|
||||
"minWithdrawAmount": body.MinWithdrawAmount,
|
||||
"bindingDays": body.BindingDays,
|
||||
"userDiscount": body.UserDiscount,
|
||||
"withdrawFee": body.WithdrawFee,
|
||||
"enableAutoWithdraw": body.EnableAutoWithdraw,
|
||||
}
|
||||
valBytes, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
desc := "分销 / 推广规则配置"
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil {
|
||||
row = model.SystemConfig{ConfigKey: "referral_config", ConfigValue: valBytes, Description: &desc}
|
||||
err = db.Create(&row).Error
|
||||
} else {
|
||||
row.ConfigValue = valBytes
|
||||
row.Description = &desc
|
||||
err = db.Save(&row).Error
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
|
||||
}
|
||||
|
||||
// DBConfigPost POST /api/db/config
|
||||
func DBConfigPost(c *gin.Context) {
|
||||
var body struct {
|
||||
Key string `json:"key"`
|
||||
Value interface{} `json:"value"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Key == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
|
||||
return
|
||||
}
|
||||
valBytes, err := json.Marshal(body.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
desc := body.Description
|
||||
var row model.SystemConfig
|
||||
err = db.Where("config_key = ?", body.Key).First(&row).Error
|
||||
if err != nil {
|
||||
row = model.SystemConfig{ConfigKey: body.Key, ConfigValue: valBytes, Description: &desc}
|
||||
err = db.Create(&row).Error
|
||||
} else {
|
||||
row.ConfigValue = valBytes
|
||||
if body.Description != "" {
|
||||
row.Description = &desc
|
||||
}
|
||||
err = db.Save(&row).Error
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
|
||||
}
|
||||
|
||||
// DBUsersList GET /api/db/users(支持分页 page、pageSize,可选搜索 search;购买状态、分销收益、绑定人数从订单/绑定表实时计算)
|
||||
func DBUsersList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "15"))
|
||||
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 15
|
||||
}
|
||||
|
||||
q := db.Model(&model.User{})
|
||||
if search != "" {
|
||||
pattern := "%" + search + "%"
|
||||
q = q.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var users []model.User
|
||||
if err := q.Order("created_at DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "users": []interface{}{}})
|
||||
return
|
||||
}
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "users": users,
|
||||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 读取推广配置中的分销比例
|
||||
distributorShare := 0.9
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userIDs := make([]string, 0, len(users))
|
||||
for _, u := range users {
|
||||
userIDs = append(userIDs, u.ID)
|
||||
}
|
||||
|
||||
// 1. 购买状态:全书已购、已付费章节数(从 orders 计算)
|
||||
hasFullBookMap := make(map[string]bool)
|
||||
sectionCountMap := make(map[string]int)
|
||||
var fullbookRows []struct {
|
||||
UserID string
|
||||
}
|
||||
db.Model(&model.Order{}).Select("user_id").Where("product_type = ? AND status = ?", "fullbook", "paid").Find(&fullbookRows)
|
||||
for _, r := range fullbookRows {
|
||||
hasFullBookMap[r.UserID] = true
|
||||
}
|
||||
var sectionRows []struct {
|
||||
UserID string
|
||||
Count int64
|
||||
}
|
||||
db.Model(&model.Order{}).Select("user_id, COUNT(*) as count").
|
||||
Where("product_type = ? AND status = ?", "section", "paid").
|
||||
Group("user_id").Find(§ionRows)
|
||||
for _, r := range sectionRows {
|
||||
sectionCountMap[r.UserID] = int(r.Count)
|
||||
}
|
||||
|
||||
// 2. 分销收益:从 referrer 订单计算佣金;可提现 = 累计佣金 - 已提现 - 待处理提现
|
||||
referrerEarningsMap := make(map[string]float64)
|
||||
var referrerRows []struct {
|
||||
ReferrerID string
|
||||
Total float64
|
||||
}
|
||||
db.Model(&model.Order{}).Select("referrer_id, COALESCE(SUM(amount), 0) as total").
|
||||
Where("referrer_id IS NOT NULL AND referrer_id != '' AND status = ?", "paid").
|
||||
Group("referrer_id").Find(&referrerRows)
|
||||
for _, r := range referrerRows {
|
||||
referrerEarningsMap[r.ReferrerID] = r.Total * distributorShare
|
||||
}
|
||||
withdrawnMap := make(map[string]float64)
|
||||
var withdrawnRows []struct {
|
||||
UserID string
|
||||
Total float64
|
||||
}
|
||||
db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total").
|
||||
Where("status = ?", "success").
|
||||
Group("user_id").Find(&withdrawnRows)
|
||||
for _, r := range withdrawnRows {
|
||||
withdrawnMap[r.UserID] = r.Total
|
||||
}
|
||||
pendingWithdrawMap := make(map[string]float64)
|
||||
var pendingRows []struct {
|
||||
UserID string
|
||||
Total float64
|
||||
}
|
||||
db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total").
|
||||
Where("status IN ?", []string{"pending", "processing", "pending_confirm"}).
|
||||
Group("user_id").Find(&pendingRows)
|
||||
for _, r := range pendingRows {
|
||||
pendingWithdrawMap[r.UserID] = r.Total
|
||||
}
|
||||
|
||||
// 3. 绑定人数:从 referral_bindings 计算
|
||||
referralCountMap := make(map[string]int)
|
||||
var refCountRows []struct {
|
||||
ReferrerID string
|
||||
Count int64
|
||||
}
|
||||
db.Model(&model.ReferralBinding{}).Select("referrer_id, COUNT(*) as count").
|
||||
Group("referrer_id").Find(&refCountRows)
|
||||
for _, r := range refCountRows {
|
||||
referralCountMap[r.ReferrerID] = int(r.Count)
|
||||
}
|
||||
|
||||
// 填充每个用户的实时计算字段
|
||||
for i := range users {
|
||||
uid := users[i].ID
|
||||
// 购买状态
|
||||
users[i].HasFullBook = ptrBool(hasFullBookMap[uid])
|
||||
users[i].PurchasedSectionCount = sectionCountMap[uid]
|
||||
// 分销收益
|
||||
totalE := referrerEarningsMap[uid]
|
||||
withdrawn := withdrawnMap[uid]
|
||||
pendingWd := pendingWithdrawMap[uid]
|
||||
available := totalE - withdrawn - pendingWd
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
users[i].Earnings = ptrFloat64(totalE)
|
||||
users[i].PendingEarnings = ptrFloat64(available)
|
||||
users[i].ReferralCount = ptrInt(referralCountMap[uid])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "users": users,
|
||||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
func ptrFloat64(f float64) *float64 { v := f; return &v }
|
||||
func ptrInt(n int) *int { return &n }
|
||||
|
||||
// DBUsersAction POST /api/db/users(创建)、PUT /api/db/users(更新)
|
||||
func DBUsersAction(c *gin.Context) {
|
||||
db := database.DB()
|
||||
if c.Request.Method == http.MethodPost {
|
||||
var body struct {
|
||||
OpenID *string `json:"openId"`
|
||||
Phone *string `json:"phone"`
|
||||
Nickname *string `json:"nickname"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
userID := "user_" + randomSuffix()
|
||||
code := "SOUL" + randomSuffix()[:4]
|
||||
nick := "用户"
|
||||
if body.Nickname != nil && *body.Nickname != "" {
|
||||
nick = *body.Nickname
|
||||
} else {
|
||||
nick = nick + userID[len(userID)-4:]
|
||||
}
|
||||
u := model.User{
|
||||
ID: userID, Nickname: &nick, ReferralCode: &code,
|
||||
OpenID: body.OpenID, Phone: body.Phone, WechatID: body.WechatID, Avatar: body.Avatar,
|
||||
}
|
||||
if body.IsAdmin != nil {
|
||||
u.IsAdmin = body.IsAdmin
|
||||
}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "user": u, "isNew": true, "message": "用户创建成功"})
|
||||
return
|
||||
}
|
||||
// PUT 更新
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
HasFullBook *bool `json:"hasFullBook"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
PendingEarnings *float64 `json:"pendingEarnings"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if body.Nickname != nil {
|
||||
updates["nickname"] = *body.Nickname
|
||||
}
|
||||
if body.Phone != nil {
|
||||
updates["phone"] = *body.Phone
|
||||
}
|
||||
if body.WechatID != nil {
|
||||
updates["wechat_id"] = *body.WechatID
|
||||
}
|
||||
if body.Avatar != nil {
|
||||
updates["avatar"] = *body.Avatar
|
||||
}
|
||||
if body.HasFullBook != nil {
|
||||
updates["has_full_book"] = *body.HasFullBook
|
||||
}
|
||||
if body.IsAdmin != nil {
|
||||
updates["is_admin"] = *body.IsAdmin
|
||||
}
|
||||
if body.Earnings != nil {
|
||||
updates["earnings"] = *body.Earnings
|
||||
}
|
||||
if body.PendingEarnings != nil {
|
||||
updates["pending_earnings"] = *body.PendingEarnings
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&model.User{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户更新成功"})
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff)
|
||||
}
|
||||
|
||||
// DBUsersDelete DELETE /api/db/users
|
||||
func DBUsersDelete(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户删除成功"})
|
||||
}
|
||||
|
||||
// DBUsersReferrals GET /api/db/users/referrals(绑定关系详情弹窗;收益与「已付费」与小程序口径一致:订单+提现表实时计算)
|
||||
func DBUsersReferrals(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 分销比例(与小程序 /api/miniprogram/earnings、支付回调一致)
|
||||
distributorShare := 0.9
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bindings []model.ReferralBinding
|
||||
if err := db.Where("referrer_id = ?", userId).Order("binding_date DESC").Find(&bindings).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "referrals": []interface{}{}, "stats": gin.H{"total": 0, "purchased": 0, "free": 0, "earnings": 0, "pendingEarnings": 0, "withdrawnEarnings": 0}})
|
||||
return
|
||||
}
|
||||
refereeIds := make([]string, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
refereeIds = append(refereeIds, b.RefereeID)
|
||||
}
|
||||
var users []model.User
|
||||
if len(refereeIds) > 0 {
|
||||
db.Where("id IN ?", refereeIds).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
referrals := make([]gin.H, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
u := userMap[b.RefereeID]
|
||||
nick := "微信用户"
|
||||
var avatar *string
|
||||
var phone *string
|
||||
hasFullBook := false
|
||||
if u != nil {
|
||||
if u.Nickname != nil {
|
||||
nick = *u.Nickname
|
||||
}
|
||||
avatar, phone = u.Avatar, u.Phone
|
||||
if u.HasFullBook != nil {
|
||||
hasFullBook = *u.HasFullBook
|
||||
}
|
||||
}
|
||||
status := "active"
|
||||
if b.Status != nil {
|
||||
status = *b.Status
|
||||
}
|
||||
daysRemaining := 0
|
||||
if b.ExpiryDate.After(time.Now()) {
|
||||
daysRemaining = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
|
||||
}
|
||||
// 已付费:与小程序一致,以绑定记录的 purchase_count > 0 为准(支付回调会更新该字段)
|
||||
hasPaid := b.PurchaseCount != nil && *b.PurchaseCount > 0
|
||||
displayStatus := bindingStatusDisplay(hasPaid, hasFullBook) // vip | paid | free,供前端徽章展示
|
||||
referrals = append(referrals, gin.H{
|
||||
"id": b.RefereeID, "nickname": nick, "avatar": avatar, "phone": phone,
|
||||
"hasFullBook": hasFullBook || status == "converted",
|
||||
"purchasedSections": getBindingPurchaseCount(b),
|
||||
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
|
||||
"status": displayStatus,
|
||||
})
|
||||
}
|
||||
|
||||
// 累计收益、待提现:与小程序 MyEarnings 一致,从订单+提现表实时计算
|
||||
var orderSum struct{ Total float64 }
|
||||
db.Model(&model.Order{}).Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("referrer_id = ? AND status = ?", userId, "paid").
|
||||
Scan(&orderSum)
|
||||
earningsE := orderSum.Total * distributorShare
|
||||
|
||||
var withdrawnSum struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status = ?", userId, "success").
|
||||
Scan(&withdrawnSum)
|
||||
withdrawnE := withdrawnSum.Total
|
||||
|
||||
var pendingWdSum struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
|
||||
Scan(&pendingWdSum)
|
||||
availableE := earningsE - withdrawnE - pendingWdSum.Total
|
||||
if availableE < 0 {
|
||||
availableE = 0
|
||||
}
|
||||
|
||||
// 已付费人数:与小程序一致,绑定中 purchase_count > 0 的条数
|
||||
purchased := 0
|
||||
for _, b := range bindings {
|
||||
if b.PurchaseCount != nil && *b.PurchaseCount > 0 {
|
||||
purchased++
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "referrals": referrals,
|
||||
"stats": gin.H{
|
||||
"total": len(bindings), "purchased": purchased, "free": len(bindings) - purchased,
|
||||
"earnings": roundFloat(earningsE, 2), "pendingEarnings": roundFloat(availableE, 2), "withdrawnEarnings": roundFloat(withdrawnE, 2),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func getBindingPurchaseCount(b model.ReferralBinding) int {
|
||||
if b.PurchaseCount == nil {
|
||||
return 0
|
||||
}
|
||||
return *b.PurchaseCount
|
||||
}
|
||||
|
||||
func bindingStatusDisplay(hasPaid bool, hasFullBook bool) string {
|
||||
if hasFullBook {
|
||||
return "vip"
|
||||
}
|
||||
if hasPaid {
|
||||
return "paid"
|
||||
}
|
||||
return "free"
|
||||
}
|
||||
|
||||
func roundFloat(v float64, prec int) float64 {
|
||||
ratio := 1.0
|
||||
for i := 0; i < prec; i++ {
|
||||
ratio *= 10
|
||||
}
|
||||
return float64(int(v*ratio+0.5)) / ratio
|
||||
}
|
||||
|
||||
// DBInit POST /api/db/init
|
||||
func DBInit(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "初始化接口已就绪(表结构由迁移维护)"}})
|
||||
}
|
||||
|
||||
// DBDistribution GET /api/db/distribution
|
||||
func DBDistribution(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
db := database.DB()
|
||||
var bindings []model.ReferralBinding
|
||||
q := db.Order("binding_date DESC").Limit(500)
|
||||
if userId != "" {
|
||||
q = q.Where("referrer_id = ?", userId)
|
||||
}
|
||||
if err := q.Find(&bindings).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "bindings": []interface{}{}, "total": 0})
|
||||
return
|
||||
}
|
||||
referrerIds := make(map[string]bool)
|
||||
refereeIds := make(map[string]bool)
|
||||
for _, b := range bindings {
|
||||
referrerIds[b.ReferrerID] = true
|
||||
refereeIds[b.RefereeID] = true
|
||||
}
|
||||
allIds := make([]string, 0, len(referrerIds)+len(refereeIds))
|
||||
for id := range referrerIds {
|
||||
allIds = append(allIds, id)
|
||||
}
|
||||
for id := range refereeIds {
|
||||
if !referrerIds[id] {
|
||||
allIds = append(allIds, id)
|
||||
}
|
||||
}
|
||||
var users []model.User
|
||||
if len(allIds) > 0 {
|
||||
db.Where("id IN ?", allIds).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
getStr := func(s *string) string {
|
||||
if s == nil || *s == "" {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
out := make([]gin.H, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
refNick := "微信用户"
|
||||
var refereePhone, refereeAvatar *string
|
||||
if u := userMap[b.RefereeID]; u != nil {
|
||||
if u.Nickname != nil && *u.Nickname != "" {
|
||||
refNick = *u.Nickname
|
||||
} else {
|
||||
refNick = "微信用户"
|
||||
}
|
||||
refereePhone = u.Phone
|
||||
refereeAvatar = u.Avatar
|
||||
}
|
||||
var referrerName, referrerAvatar *string
|
||||
if u := userMap[b.ReferrerID]; u != nil {
|
||||
referrerName = u.Nickname
|
||||
referrerAvatar = u.Avatar
|
||||
}
|
||||
days := 0
|
||||
if b.ExpiryDate.After(time.Now()) {
|
||||
days = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
|
||||
}
|
||||
// 佣金展示用累计佣金 total_commission(支付回调累加),无则用 commission_amount
|
||||
commissionVal := b.TotalCommission
|
||||
if commissionVal == nil {
|
||||
commissionVal = b.CommissionAmount
|
||||
}
|
||||
statusVal := ""
|
||||
if b.Status != nil {
|
||||
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),
|
||||
"boundAt": b.BindingDate, "expiresAt": b.ExpiryDate, "status": statusVal,
|
||||
"daysRemaining": days, "commission": commissionVal, "totalCommission": commissionVal, "source": "miniprogram",
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "bindings": out, "total": len(out)})
|
||||
}
|
||||
|
||||
// DBChapters GET/POST /api/db/chapters
|
||||
func DBChapters(c *gin.Context) {
|
||||
var list []model.Chapter
|
||||
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// DBConfigDelete DELETE /api/db/config
|
||||
func DBConfigDelete(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
if key == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("config_key = ?", key).Delete(&model.SystemConfig{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DBInitGet GET /api/db/init
|
||||
func DBInitGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "ok"}})
|
||||
}
|
||||
|
||||
// DBMigrateGet GET /api/db/migrate
|
||||
func DBMigrateGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移状态查询(由 Prisma/外部维护)"})
|
||||
}
|
||||
|
||||
// DBMigratePost POST /api/db/migrate
|
||||
func DBMigratePost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移由 Prisma/外部执行"})
|
||||
}
|
||||
247
soul-api/internal/handler/db_book.go
Normal file
247
soul-api/internal/handler/db_book.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
|
||||
type sectionListItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree,omitempty"`
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
FilePath *string `json:"filePath,omitempty"`
|
||||
}
|
||||
|
||||
// DBBookAction GET/POST/PUT /api/db/book
|
||||
func DBBookAction(c *gin.Context) {
|
||||
db := database.DB()
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet:
|
||||
action := c.Query("action")
|
||||
id := c.Query("id")
|
||||
switch action {
|
||||
case "list":
|
||||
var rows []model.Chapter
|
||||
if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
||||
return
|
||||
}
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
if r.Price != nil {
|
||||
price = *r.Price
|
||||
}
|
||||
sections = append(sections, sectionListItem{
|
||||
ID: r.ID,
|
||||
Title: r.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: r.IsFree,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
|
||||
return
|
||||
case "read":
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
var ch model.Chapter
|
||||
if err := db.Where("id = ?", id).First(&ch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
price := 1.0
|
||||
if ch.Price != nil {
|
||||
price = *ch.Price
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"section": gin.H{
|
||||
"id": ch.ID,
|
||||
"title": ch.SectionTitle,
|
||||
"price": price,
|
||||
"content": ch.Content,
|
||||
"partId": ch.PartID,
|
||||
"partTitle": ch.PartTitle,
|
||||
"chapterId": ch.ChapterID,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
},
|
||||
})
|
||||
return
|
||||
case "export":
|
||||
var rows []model.Chapter
|
||||
if err := db.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
if r.Price != nil {
|
||||
price = *r.Price
|
||||
}
|
||||
sections = append(sections, sectionListItem{
|
||||
ID: r.ID, Title: r.SectionTitle, Price: price, IsFree: r.IsFree,
|
||||
PartID: r.PartID, PartTitle: r.PartTitle, ChapterID: r.ChapterID, ChapterTitle: r.ChapterTitle,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections})
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
|
||||
return
|
||||
}
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
Data []importItem `json:"data"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
switch body.Action {
|
||||
case "sync":
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成(Gin 无文件源时可从 DB 已存在数据视为已同步)"})
|
||||
return
|
||||
case "import":
|
||||
imported, failed := 0, 0
|
||||
for _, item := range body.Data {
|
||||
price := 1.0
|
||||
if item.Price != nil {
|
||||
price = *item.Price
|
||||
}
|
||||
isFree := false
|
||||
if item.IsFree != nil {
|
||||
isFree = *item.IsFree
|
||||
}
|
||||
wordCount := len(item.Content)
|
||||
status := "published"
|
||||
ch := model.Chapter{
|
||||
ID: item.ID,
|
||||
PartID: strPtr(item.PartID, "part-1"),
|
||||
PartTitle: strPtr(item.PartTitle, "未分类"),
|
||||
ChapterID: strPtr(item.ChapterID, "chapter-1"),
|
||||
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
|
||||
SectionTitle: item.Title,
|
||||
Content: item.Content,
|
||||
WordCount: &wordCount,
|
||||
IsFree: &isFree,
|
||||
Price: &price,
|
||||
Status: &status,
|
||||
}
|
||||
err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
err = db.Create(&ch).Error
|
||||
} else if err == nil {
|
||||
err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||||
"section_title": ch.SectionTitle,
|
||||
"content": ch.Content,
|
||||
"word_count": ch.WordCount,
|
||||
"is_free": ch.IsFree,
|
||||
"price": ch.Price,
|
||||
}).Error
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"})
|
||||
return
|
||||
}
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
|
||||
return
|
||||
}
|
||||
price := 1.0
|
||||
if body.Price != nil {
|
||||
price = *body.Price
|
||||
}
|
||||
isFree := false
|
||||
if body.IsFree != nil {
|
||||
isFree = *body.IsFree
|
||||
}
|
||||
wordCount := len(body.Content)
|
||||
updates := map[string]interface{}{
|
||||
"section_title": body.Title,
|
||||
"content": body.Content,
|
||||
"word_count": wordCount,
|
||||
"price": price,
|
||||
"is_free": isFree,
|
||||
}
|
||||
err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
||||
}
|
||||
|
||||
type importItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
PartID *string `json:"partId"`
|
||||
PartTitle *string `json:"partTitle"`
|
||||
ChapterID *string `json:"chapterId"`
|
||||
ChapterTitle *string `json:"chapterTitle"`
|
||||
}
|
||||
|
||||
func strPtr(s *string, def string) string {
|
||||
if s != nil && *s != "" {
|
||||
return *s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// DBBookDelete DELETE /api/db/book
|
||||
func DBBookDelete(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
22
soul-api/internal/handler/distribution.go
Normal file
22
soul-api/internal/handler/distribution.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DistributionGet POST /api/distribution GET/POST/PUT
|
||||
func DistributionGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DistributionAutoWithdrawConfig GET/POST/DELETE /api/distribution/auto-withdraw-config
|
||||
func DistributionAutoWithdrawConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// DistributionMessages GET/POST /api/distribution/messages
|
||||
func DistributionMessages(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
12
soul-api/internal/handler/documentation.go
Normal file
12
soul-api/internal/handler/documentation.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DocGenerate POST /api/documentation/generate
|
||||
func DocGenerate(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
253
soul-api/internal/handler/match.go
Normal file
253
soul-api/internal/handler/match.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const defaultFreeMatchLimit = 3
|
||||
|
||||
// MatchQuota 匹配次数配额(纯计算:订单 + match_records)
|
||||
type MatchQuota struct {
|
||||
PurchasedTotal int64 `json:"purchasedTotal"`
|
||||
PurchasedUsed int64 `json:"purchasedUsed"`
|
||||
MatchesUsedToday int64 `json:"matchesUsedToday"`
|
||||
FreeRemainToday int64 `json:"freeRemainToday"`
|
||||
PurchasedRemain int64 `json:"purchasedRemain"`
|
||||
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
|
||||
}
|
||||
|
||||
func getFreeMatchLimit(db *gorm.DB) int {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err != nil {
|
||||
return defaultFreeMatchLimit
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err != nil {
|
||||
return defaultFreeMatchLimit
|
||||
}
|
||||
if v, ok := config["freeMatchLimit"].(float64); ok && v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
return defaultFreeMatchLimit
|
||||
}
|
||||
|
||||
// GetMatchQuota 根据订单和 match_records 纯计算用户匹配配额
|
||||
func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
|
||||
if freeLimit <= 0 {
|
||||
freeLimit = defaultFreeMatchLimit
|
||||
}
|
||||
var purchasedTotal int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userID, "match", "paid").Count(&purchasedTotal)
|
||||
var matchesToday int64
|
||||
db.Model(&model.MatchRecord{}).Where("user_id = ? AND created_at >= CURDATE()", userID).Count(&matchesToday)
|
||||
// 历史每日超出免费部分之和 = 已消耗的购买次数
|
||||
var purchasedUsed int64
|
||||
db.Raw(`
|
||||
SELECT COALESCE(SUM(cnt - ?), 0) FROM (
|
||||
SELECT DATE(created_at) AS d, COUNT(*) AS cnt
|
||||
FROM match_records WHERE user_id = ?
|
||||
GROUP BY DATE(created_at)
|
||||
HAVING cnt > ?
|
||||
) t
|
||||
`, freeLimit, userID, freeLimit).Scan(&purchasedUsed)
|
||||
freeUsed := matchesToday
|
||||
if freeUsed > int64(freeLimit) {
|
||||
freeUsed = int64(freeLimit)
|
||||
}
|
||||
freeRemain := int64(freeLimit) - freeUsed
|
||||
if freeRemain < 0 {
|
||||
freeRemain = 0
|
||||
}
|
||||
purchasedRemain := purchasedTotal - purchasedUsed
|
||||
if purchasedRemain < 0 {
|
||||
purchasedRemain = 0
|
||||
}
|
||||
remainToday := freeRemain + purchasedRemain
|
||||
return MatchQuota{
|
||||
PurchasedTotal: purchasedTotal,
|
||||
PurchasedUsed: purchasedUsed,
|
||||
MatchesUsedToday: matchesToday,
|
||||
FreeRemainToday: freeRemain,
|
||||
PurchasedRemain: purchasedRemain,
|
||||
RemainToday: remainToday,
|
||||
}
|
||||
}
|
||||
|
||||
var defaultMatchTypes = []gin.H{
|
||||
gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
|
||||
gin.H{"id": "investor", "label": "资源对接", "matchLabel": "资源对接", "icon": "👥", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||
gin.H{"id": "mentor", "label": "导师顾问", "matchLabel": "商业顾问", "icon": "❤️", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||
gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||
}
|
||||
|
||||
// MatchConfigGet GET /api/match/config
|
||||
func MatchConfigGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"matchTypes": defaultMatchTypes,
|
||||
"freeMatchLimit": 3,
|
||||
"matchPrice": 1,
|
||||
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
|
||||
},
|
||||
"source": "default",
|
||||
})
|
||||
return
|
||||
}
|
||||
var config map[string]interface{}
|
||||
_ = json.Unmarshal(cfg.ConfigValue, &config)
|
||||
matchTypes := defaultMatchTypes
|
||||
if v, ok := config["matchTypes"].([]interface{}); ok && len(v) > 0 {
|
||||
matchTypes = make([]gin.H, 0, len(v))
|
||||
for _, t := range v {
|
||||
if m, ok := t.(map[string]interface{}); ok {
|
||||
enabled := true
|
||||
if e, ok := m["enabled"].(bool); ok && !e {
|
||||
enabled = false
|
||||
}
|
||||
if enabled {
|
||||
matchTypes = append(matchTypes, gin.H(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matchTypes) == 0 {
|
||||
matchTypes = defaultMatchTypes
|
||||
}
|
||||
}
|
||||
freeMatchLimit := 3
|
||||
if v, ok := config["freeMatchLimit"].(float64); ok {
|
||||
freeMatchLimit = int(v)
|
||||
}
|
||||
matchPrice := 1
|
||||
if v, ok := config["matchPrice"].(float64); ok {
|
||||
matchPrice = int(v)
|
||||
}
|
||||
settings := gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10}
|
||||
if s, ok := config["settings"].(map[string]interface{}); ok {
|
||||
for k, v := range s {
|
||||
settings[k] = v
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"matchTypes": matchTypes, "freeMatchLimit": freeMatchLimit, "matchPrice": matchPrice, "settings": settings,
|
||||
}, "source": "database"})
|
||||
}
|
||||
|
||||
// MatchConfigPost POST /api/match/config
|
||||
func MatchConfigPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// MatchUsers POST /api/match/users
|
||||
func MatchUsers(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
MatchType string `json:"matchType"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
// 全书用户无限制,否则校验今日剩余次数
|
||||
var user model.User
|
||||
skipQuota := false
|
||||
if err := db.Where("id = ?", body.UserID).First(&user).Error; err == nil {
|
||||
skipQuota = user.HasFullBook != nil && *user.HasFullBook
|
||||
}
|
||||
if !skipQuota {
|
||||
freeLimit := getFreeMatchLimit(db)
|
||||
quota := GetMatchQuota(db, body.UserID, freeLimit)
|
||||
if quota.RemainToday <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "今日匹配次数已用完,请购买更多次数",
|
||||
"code": "QUOTA_EXCEEDED",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
// 只匹配已绑定微信或手机号的用户
|
||||
var users []model.User
|
||||
q := db.Where("id != ?", body.UserID).
|
||||
Where("((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))")
|
||||
if err := q.Order("created_at DESC").Limit(20).Find(&users).Error; err != nil || len(users) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "暂无匹配用户", "data": nil, "code": "NO_USERS"})
|
||||
return
|
||||
}
|
||||
// 随机选一个
|
||||
idx := 0
|
||||
if len(users) > 1 {
|
||||
idx = int(users[0].CreatedAt.Unix() % int64(len(users)))
|
||||
}
|
||||
r := users[idx]
|
||||
nickname := "微信用户"
|
||||
if r.Nickname != nil {
|
||||
nickname = *r.Nickname
|
||||
}
|
||||
avatar := ""
|
||||
if r.Avatar != nil {
|
||||
avatar = *r.Avatar
|
||||
}
|
||||
wechat := ""
|
||||
if r.WechatID != nil {
|
||||
wechat = *r.WechatID
|
||||
}
|
||||
phone := ""
|
||||
if r.Phone != nil {
|
||||
phone = *r.Phone
|
||||
}
|
||||
intro := "来自Soul创业派对的伙伴"
|
||||
matchLabels := map[string]string{"partner": "找伙伴", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
|
||||
tag := matchLabels[body.MatchType]
|
||||
if tag == "" {
|
||||
tag = "找伙伴"
|
||||
}
|
||||
// 写入匹配记录(含发起者的 phone/wechat_id 便于后续联系)
|
||||
rec := model.MatchRecord{
|
||||
ID: fmt.Sprintf("mr_%d", time.Now().UnixNano()),
|
||||
UserID: body.UserID,
|
||||
MatchedUserID: r.ID,
|
||||
MatchType: body.MatchType,
|
||||
}
|
||||
if body.MatchType == "" {
|
||||
rec.MatchType = "partner"
|
||||
}
|
||||
if body.Phone != "" {
|
||||
rec.Phone = &body.Phone
|
||||
}
|
||||
if body.WechatID != "" {
|
||||
rec.WechatID = &body.WechatID
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
fmt.Printf("[MatchUsers] 写入 match_records 失败: %v\n", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"id": r.ID, "nickname": nickname, "avatar": avatar, "wechat": wechat, "phone": phone,
|
||||
"introduction": intro, "tags": []string{"创业者", tag},
|
||||
"matchScore": 80 + (r.CreatedAt.Unix() % 20),
|
||||
"commonInterests": []gin.H{
|
||||
gin.H{"icon": "📚", "text": "都在读《创业派对》"},
|
||||
gin.H{"icon": "💼", "text": "对创业感兴趣"},
|
||||
gin.H{"icon": "🎯", "text": "相似的发展方向"},
|
||||
},
|
||||
},
|
||||
"totalUsers": len(users),
|
||||
})
|
||||
}
|
||||
12
soul-api/internal/handler/menu.go
Normal file
12
soul-api/internal/handler/menu.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MenuGet GET /api/menu
|
||||
func MenuGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
}
|
||||
740
soul-api/internal/handler/miniprogram.go
Normal file
740
soul-api/internal/handler/miniprogram.go
Normal file
@@ -0,0 +1,740 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MiniprogramLogin POST /api/miniprogram/login
|
||||
func MiniprogramLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少登录code"})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用微信接口获取 openid 和 session_key
|
||||
openID, sessionKey, _, err := wechat.Code2Session(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 查询用户是否存在
|
||||
var user model.User
|
||||
result := db.Where("open_id = ?", openID).First(&user)
|
||||
|
||||
isNewUser := result.Error != nil
|
||||
|
||||
if isNewUser {
|
||||
// 创建新用户
|
||||
userID := openID // 直接使用 openid 作为用户 ID
|
||||
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
|
||||
nickname := "微信用户" + openID[len(openID)-4:]
|
||||
avatar := ""
|
||||
hasFullBook := false
|
||||
earnings := 0.0
|
||||
pendingEarnings := 0.0
|
||||
referralCount := 0
|
||||
purchasedSections := "[]"
|
||||
|
||||
user = model.User{
|
||||
ID: userID,
|
||||
OpenID: &openID,
|
||||
SessionKey: &sessionKey,
|
||||
Nickname: &nickname,
|
||||
Avatar: &avatar,
|
||||
ReferralCode: &referralCode,
|
||||
HasFullBook: &hasFullBook,
|
||||
PurchasedSections: &purchasedSections,
|
||||
Earnings: &earnings,
|
||||
PendingEarnings: &pendingEarnings,
|
||||
ReferralCount: &referralCount,
|
||||
}
|
||||
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 更新 session_key
|
||||
db.Model(&user).Update("session_key", sessionKey)
|
||||
}
|
||||
|
||||
// 从 orders 表查询真实购买记录
|
||||
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": getStringValue(user.OpenID),
|
||||
"nickname": getStringValue(user.Nickname),
|
||||
"avatar": 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,
|
||||
}
|
||||
|
||||
// 生成 token
|
||||
token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"openId": openID,
|
||||
"user": responseUser,
|
||||
"token": token,
|
||||
},
|
||||
"isNewUser": isNewUser,
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
func getStringValue(ptr *string) string {
|
||||
if ptr == nil {
|
||||
return ""
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
func getBoolValue(ptr *bool) bool {
|
||||
if ptr == nil {
|
||||
return false
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
func getFloatValue(ptr *float64) float64 {
|
||||
if ptr == nil {
|
||||
return 0.0
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
func getIntValue(ptr *int) int {
|
||||
if ptr == nil {
|
||||
return 0
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
// MiniprogramPay GET/POST /api/miniprogram/pay
|
||||
func MiniprogramPay(c *gin.Context) {
|
||||
if c.Request.Method == "POST" {
|
||||
miniprogramPayPost(c)
|
||||
} else {
|
||||
miniprogramPayGet(c)
|
||||
}
|
||||
}
|
||||
|
||||
// POST - 创建小程序支付订单
|
||||
func miniprogramPayPost(c *gin.Context) {
|
||||
var req struct {
|
||||
OpenID string `json:"openId" binding:"required"`
|
||||
ProductType string `json:"productType" binding:"required"`
|
||||
ProductID string `json:"productId"`
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
UserID string `json:"userId"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少openId参数,请先登录"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "支付金额无效"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 查询用户的有效推荐人(先查 binding,再查 referralCode)
|
||||
var referrerID *string
|
||||
if req.UserID != "" {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT referrer_id
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error
|
||||
if err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
}
|
||||
}
|
||||
if referrerID == nil && req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
}
|
||||
}
|
||||
|
||||
// 有推荐人时应用好友优惠(无论是 binding 还是 referralCode)
|
||||
finalAmount := req.Amount
|
||||
if referrerID != nil {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
discountRate := userDiscount / 100
|
||||
finalAmount = req.Amount * (1 - discountRate)
|
||||
if finalAmount < 0.01 {
|
||||
finalAmount = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
totalFee := int(finalAmount * 100) // 转为分
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
if req.ProductType == "fullbook" {
|
||||
description = "《一场Soul的创业实验》全书"
|
||||
} else if req.ProductType == "match" {
|
||||
description = "购买匹配次数"
|
||||
} else {
|
||||
description = fmt.Sprintf("章节购买-%s", req.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取客户端 IP
|
||||
clientIP := c.ClientIP()
|
||||
if clientIP == "" {
|
||||
clientIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
// 插入订单到数据库
|
||||
userID := req.UserID
|
||||
if userID == "" {
|
||||
userID = req.OpenID
|
||||
}
|
||||
|
||||
productID := req.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
|
||||
status := "created"
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: userID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: finalAmount,
|
||||
Description: &description,
|
||||
Status: &status,
|
||||
ReferrerID: referrerID,
|
||||
ReferralCode: &req.ReferralCode,
|
||||
}
|
||||
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
// 订单创建失败,但不中断支付流程
|
||||
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
|
||||
}
|
||||
|
||||
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID)
|
||||
ctx := c.Request.Context()
|
||||
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, description, attach)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("微信支付请求失败: %v", err)})
|
||||
return
|
||||
}
|
||||
payParams, err := wechat.GetJSAPIPayParams(prepayID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("生成支付参数失败: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"orderSn": orderSn,
|
||||
"prepayId": prepayID,
|
||||
"payParams": payParams,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GET - 查询订单状态(并主动同步:若微信已支付但本地未标记,则更新本地订单,便于配额即时生效)
|
||||
func miniprogramPayGet(c *gin.Context) {
|
||||
orderSn := c.Query("orderSn")
|
||||
if orderSn == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少订单号"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, orderSn)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"status": "unknown",
|
||||
"orderSn": orderSn,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status := "paying"
|
||||
switch tradeState {
|
||||
case "SUCCESS":
|
||||
status = "paid"
|
||||
// 若微信已支付,主动同步到本地 orders(不等 PayNotify),便于购买次数即时生效
|
||||
db := database.DB()
|
||||
var order model.Order
|
||||
if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err == nil && order.Status != nil && *order.Status != "paid" {
|
||||
now := time.Now()
|
||||
db.Model(&order).Updates(map[string]interface{}{
|
||||
"status": "paid",
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
})
|
||||
fmt.Printf("[PayGet] 主动同步订单已支付: %s\n", orderSn)
|
||||
}
|
||||
case "CLOSED", "REVOKED", "PAYERROR":
|
||||
status = "failed"
|
||||
case "REFUND":
|
||||
status = "refunded"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"status": status,
|
||||
"orderSn": orderSn,
|
||||
"transactionId": transactionID,
|
||||
"totalFee": totalFee,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramPayNotify POST /api/miniprogram/pay/notify(v3 支付回调,PowerWeChat 验签解密)
|
||||
func MiniprogramPayNotify(c *gin.Context) {
|
||||
resp, err := wechat.HandlePayNotify(c.Request, func(orderSn, transactionID string, totalFee int, attachStr, openID string) error {
|
||||
totalAmount := float64(totalFee) / 100
|
||||
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
|
||||
|
||||
var attach struct {
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if attachStr != "" {
|
||||
_ = json.Unmarshal([]byte(attachStr), &attach)
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
buyerUserID := attach.UserID
|
||||
if openID != "" {
|
||||
var user model.User
|
||||
if err := db.Where("open_id = ?", openID).First(&user).Error; err == nil {
|
||||
if attach.UserID != "" && user.ID != attach.UserID {
|
||||
fmt.Printf("[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准\n")
|
||||
}
|
||||
buyerUserID = user.ID
|
||||
}
|
||||
}
|
||||
if buyerUserID == "" && attach.UserID != "" {
|
||||
buyerUserID = attach.UserID
|
||||
}
|
||||
|
||||
var order model.Order
|
||||
result := db.Where("order_sn = ?", orderSn).First(&order)
|
||||
if result.Error != nil {
|
||||
fmt.Printf("[PayNotify] 订单不存在,补记订单: %s\n", orderSn)
|
||||
productID := attach.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
productType := attach.ProductType
|
||||
if productType == "" {
|
||||
productType = "unknown"
|
||||
}
|
||||
desc := "支付回调补记订单"
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
order = model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: buyerUserID,
|
||||
OpenID: openID,
|
||||
ProductType: productType,
|
||||
ProductID: &productID,
|
||||
Amount: totalAmount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
TransactionID: &transactionID,
|
||||
PayTime: &now,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 补记订单失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("create order: %w", err)
|
||||
}
|
||||
} else if *order.Status != "paid" {
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
if err := db.Model(&order).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
}).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("update order: %w", err)
|
||||
}
|
||||
fmt.Printf("[PayNotify] 订单状态已更新为已支付: %s\n", orderSn)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
|
||||
}
|
||||
|
||||
if buyerUserID != "" && attach.ProductType != "" {
|
||||
if attach.ProductType == "fullbook" {
|
||||
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
|
||||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
|
||||
} else if attach.ProductType == "match" {
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", buyerUserID, orderSn)
|
||||
} else if attach.ProductType == "section" && attach.ProductID != "" {
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductID, orderSn,
|
||||
).Count(&count)
|
||||
if count == 0 {
|
||||
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
}
|
||||
}
|
||||
productID := attach.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
db.Where(
|
||||
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductType, productID, orderSn,
|
||||
).Delete(&model.Order{})
|
||||
processReferralCommission(db, buyerUserID, totalAmount, orderSn, &order)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[PayNotify] 处理回调失败: %v\n", err)
|
||||
c.String(http.StatusOK, failResponse())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
c.Header(k, v[0])
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
|
||||
// 处理分销佣金
|
||||
// amount 为实付金额(若有好友优惠则已打折);order 用于判断是否有推荐人从而反推原价
|
||||
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string, order *model.Order) {
|
||||
// 获取分成配置,默认 90%;好友优惠用于反推原价(佣金按原价计算)
|
||||
distributorShare := 0.9
|
||||
userDiscount := 0.0
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
if disc, ok := config["userDiscount"].(float64); ok {
|
||||
userDiscount = disc / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
// 佣金按原价计算:若有推荐人则实付已打折,反推原价 = amount / (1 - userDiscount)
|
||||
commissionBase := amount
|
||||
if order != nil && userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || order.ReferralCode != nil && *order.ReferralCode != "") {
|
||||
if (1 - userDiscount) > 0 {
|
||||
commissionBase = amount / (1 - userDiscount)
|
||||
}
|
||||
}
|
||||
|
||||
// 查找有效推广绑定
|
||||
type Binding struct {
|
||||
ID int `gorm:"column:id"`
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
ExpiryDate time.Time `gorm:"column:expiry_date"`
|
||||
PurchaseCount int `gorm:"column:purchase_count"`
|
||||
TotalCommission float64 `gorm:"column:total_commission"`
|
||||
}
|
||||
|
||||
var binding Binding
|
||||
err := db.Raw(`
|
||||
SELECT id, referrer_id, expiry_date, purchase_count, total_commission
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active'
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, buyerUserID).Scan(&binding).Error
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[PayNotify] 用户无有效推广绑定,跳过分佣: %s\n", buyerUserID)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if time.Now().After(binding.ExpiryDate) {
|
||||
fmt.Printf("[PayNotify] 绑定已过期,跳过分佣: %s\n", buyerUserID)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算佣金(按原价)
|
||||
commission := commissionBase * distributorShare
|
||||
newPurchaseCount := binding.PurchaseCount + 1
|
||||
newTotalCommission := binding.TotalCommission + commission
|
||||
|
||||
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f, shareRate=%.0f%%\n",
|
||||
binding.ReferrerID, amount, commission, distributorShare*100)
|
||||
|
||||
// 更新推广者的待结算收益
|
||||
db.Model(&model.User{}).Where("id = ?", binding.ReferrerID).
|
||||
Update("pending_earnings", db.Raw("pending_earnings + ?", commission))
|
||||
|
||||
// 更新绑定记录(COALESCE 避免 total_commission 为 NULL 时 NULL+?=NULL)
|
||||
db.Exec(`
|
||||
UPDATE referral_bindings
|
||||
SET last_purchase_date = NOW(),
|
||||
purchase_count = COALESCE(purchase_count, 0) + 1,
|
||||
total_commission = COALESCE(total_commission, 0) + ?
|
||||
WHERE id = ?
|
||||
`, commission, binding.ID)
|
||||
|
||||
fmt.Printf("[PayNotify] 分佣完成: 推广者 %s 获得 %.2f 元(第 %d 次购买,累计 %.2f 元)\n",
|
||||
binding.ReferrerID, commission, newPurchaseCount, newTotalCommission)
|
||||
}
|
||||
|
||||
// 微信支付回调响应
|
||||
func successResponse() string {
|
||||
return `<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`
|
||||
}
|
||||
|
||||
func failResponse() string {
|
||||
return `<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[ERROR]]></return_msg></xml>`
|
||||
}
|
||||
|
||||
// MiniprogramPhone POST /api/miniprogram/phone
|
||||
func MiniprogramPhone(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少code参数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取手机号
|
||||
phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "获取手机号失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 如果提供了 userId,更新到数据库
|
||||
if req.UserID != "" {
|
||||
db := database.DB()
|
||||
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
|
||||
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"phoneNumber": phoneNumber,
|
||||
"countryCode": countryCode,
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramQrcode POST /api/miniprogram/qrcode
|
||||
func MiniprogramQrcode(c *gin.Context) {
|
||||
var req struct {
|
||||
Scene string `json:"scene"`
|
||||
Page string `json:"page"`
|
||||
Width int `json:"width"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建 scene 参数
|
||||
scene := req.Scene
|
||||
if scene == "" {
|
||||
var parts []string
|
||||
if req.UserID != "" {
|
||||
userId := req.UserID
|
||||
if len(userId) > 15 {
|
||||
userId = userId[:15]
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("ref=%s", userId))
|
||||
}
|
||||
if req.ChapterID != "" {
|
||||
parts = append(parts, fmt.Sprintf("ch=%s", req.ChapterID))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
scene = "soul"
|
||||
} else {
|
||||
scene = strings.Join(parts, "&")
|
||||
}
|
||||
}
|
||||
|
||||
page := req.Page
|
||||
if page == "" {
|
||||
page = "pages/index/index"
|
||||
}
|
||||
|
||||
width := req.Width
|
||||
if width == 0 {
|
||||
width = 280
|
||||
}
|
||||
|
||||
fmt.Printf("[MiniprogramQrcode] 生成小程序码, scene=%s\n", scene)
|
||||
|
||||
// 生成小程序码
|
||||
imageData, err := wechat.GenerateMiniProgramCode(scene, page, width)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("生成小程序码失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 base64
|
||||
base64Image := fmt.Sprintf("data:image/png;base64,%s", base64Encode(imageData))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"image": base64Image,
|
||||
"scene": scene,
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramQrcodeImage GET /api/miniprogram/qrcode/image?scene=xxx&page=xxx&width=280
|
||||
// 直接返回 image/png,供小程序 wx.downloadFile 使用,便于开发工具与真机统一用 tempFilePath 绘制
|
||||
func MiniprogramQrcodeImage(c *gin.Context) {
|
||||
scene := c.Query("scene")
|
||||
if scene == "" {
|
||||
scene = "soul"
|
||||
}
|
||||
page := c.DefaultQuery("page", "pages/read/read")
|
||||
width, _ := strconv.Atoi(c.DefaultQuery("width", "280"))
|
||||
if width <= 0 {
|
||||
width = 280
|
||||
}
|
||||
imageData, err := wechat.GenerateMiniProgramCode(scene, page, width)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("生成小程序码失败: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Data(http.StatusOK, "image/png", imageData)
|
||||
}
|
||||
|
||||
// base64 编码
|
||||
func base64Encode(data []byte) string {
|
||||
const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
var result strings.Builder
|
||||
|
||||
for i := 0; i < len(data); i += 3 {
|
||||
b1, b2, b3 := data[i], byte(0), byte(0)
|
||||
if i+1 < len(data) {
|
||||
b2 = data[i+1]
|
||||
}
|
||||
if i+2 < len(data) {
|
||||
b3 = data[i+2]
|
||||
}
|
||||
|
||||
result.WriteByte(base64Table[b1>>2])
|
||||
result.WriteByte(base64Table[((b1&0x03)<<4)|(b2>>4)])
|
||||
|
||||
if i+1 < len(data) {
|
||||
result.WriteByte(base64Table[((b2&0x0F)<<2)|(b3>>6)])
|
||||
} else {
|
||||
result.WriteByte('=')
|
||||
}
|
||||
|
||||
if i+2 < len(data) {
|
||||
result.WriteByte(base64Table[b3&0x3F])
|
||||
} else {
|
||||
result.WriteByte('=')
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
101
soul-api/internal/handler/orders.go
Normal file
101
soul-api/internal/handler/orders.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OrdersList GET /api/orders(带用户昵称/头像/手机号,分销佣金按配置比例计算)
|
||||
func OrdersList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var orders []model.Order
|
||||
if err := db.Order("created_at DESC").Find(&orders).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "orders": []interface{}{}})
|
||||
return
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "orders": []interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
// 分销比例(与支付回调一致)
|
||||
distributorShare := 0.9
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收集订单中的 user_id、referrer_id,查用户信息
|
||||
userIDs := make(map[string]bool)
|
||||
for _, o := range orders {
|
||||
if o.UserID != "" {
|
||||
userIDs[o.UserID] = true
|
||||
}
|
||||
if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
userIDs[*o.ReferrerID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
if len(ids) > 0 {
|
||||
db.Where("id IN ?", ids).Find(&users)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
|
||||
getStr := func(s *string) string {
|
||||
if s == nil || *s == "" {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(orders))
|
||||
for _, o := range orders {
|
||||
// 序列化订单为基础字段
|
||||
b, _ := json.Marshal(o)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
// 用户信息
|
||||
if u := userMap[o.UserID]; u != nil {
|
||||
m["userNickname"] = getStr(u.Nickname)
|
||||
m["userPhone"] = getStr(u.Phone)
|
||||
m["userAvatar"] = getStr(u.Avatar)
|
||||
} else {
|
||||
m["userNickname"] = ""
|
||||
m["userPhone"] = ""
|
||||
m["userAvatar"] = ""
|
||||
}
|
||||
// 推荐人信息
|
||||
if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
if u := userMap[*o.ReferrerID]; u != nil {
|
||||
m["referrerNickname"] = getStr(u.Nickname)
|
||||
m["referrerCode"] = getStr(u.ReferralCode)
|
||||
}
|
||||
}
|
||||
// 分销佣金:仅对已支付且存在推荐人的订单,按配置比例计算(与支付回调口径一致)
|
||||
status := getStr(o.Status)
|
||||
if status == "paid" && o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
m["referrerEarnings"] = o.Amount * distributorShare
|
||||
} else {
|
||||
m["referrerEarnings"] = nil
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "orders": out})
|
||||
}
|
||||
141
soul-api/internal/handler/payment.go
Normal file
141
soul-api/internal/handler/payment.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PaymentAlipayNotify POST /api/payment/alipay/notify
|
||||
func PaymentAlipayNotify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentCallback POST /api/payment/callback
|
||||
func PaymentCallback(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentCreateOrder POST /api/payment/create-order
|
||||
func PaymentCreateOrder(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentMethods GET /api/payment/methods
|
||||
func PaymentMethods(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
}
|
||||
|
||||
// PaymentQuery GET /api/payment/query
|
||||
func PaymentQuery(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentStatusOrderSn GET /api/payment/status/:orderSn
|
||||
func PaymentStatusOrderSn(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentVerify POST /api/payment/verify
|
||||
func PaymentVerify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PaymentWechatNotify POST /api/payment/wechat/notify
|
||||
func PaymentWechatNotify(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// logWechatTransferCallback 写入微信转账回调日志到 wechat_callback_logs
|
||||
func logWechatTransferCallback(db *gorm.DB, outBillNo, transferBillNo, state, failReason, outBatchNo, handlerResult, handlerError string) {
|
||||
entry := model.WechatCallbackLog{
|
||||
CallbackType: "transfer",
|
||||
OutDetailNo: outBillNo,
|
||||
TransferBillNo: transferBillNo,
|
||||
State: state,
|
||||
FailReason: failReason,
|
||||
OutBatchNo: outBatchNo,
|
||||
HandlerResult: handlerResult,
|
||||
HandlerError: handlerError,
|
||||
}
|
||||
if err := db.Create(&entry).Error; err != nil {
|
||||
fmt.Printf("[TransferNotify] 写回调日志失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PaymentWechatTransferNotify POST /api/payment/wechat/transfer/notify
|
||||
// 使用 PowerWeChat 验签、解密密文后更新提现状态,并返回微信要求的应答;同时写入 wechat_callback_logs
|
||||
// GET 同一路径时仅返回 200 与说明(便于探活或浏览器访问,不写库)
|
||||
func PaymentWechatTransferNotify(c *gin.Context) {
|
||||
if c.Request.Method == "GET" {
|
||||
c.String(http.StatusOK, "转账结果通知请使用 POST")
|
||||
return
|
||||
}
|
||||
fmt.Printf("[TransferNotify] 收到微信转账回调请求 method=%s path=%s\n", c.Request.Method, c.Request.URL.Path)
|
||||
resp, err := wechat.HandleTransferNotify(c.Request, func(outBillNo, transferBillNo, state, failReason string) error {
|
||||
fmt.Printf("[TransferNotify] 解密成功: out_bill_no=%s, transfer_bill_no=%s, state=%s\n", outBillNo, transferBillNo, state)
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("detail_no = ?", outBillNo).First(&w).Error; err != nil {
|
||||
fmt.Printf("[TransferNotify] 未找到 detail_no=%s 的提现记录: %v\n", outBillNo, err)
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, "", "success", "未找到提现记录")
|
||||
return nil
|
||||
}
|
||||
outBatchNo := ""
|
||||
if w.BatchNo != nil {
|
||||
outBatchNo = *w.BatchNo
|
||||
}
|
||||
cur := ""
|
||||
if w.Status != nil {
|
||||
cur = *w.Status
|
||||
}
|
||||
if cur != "processing" && cur != "pending_confirm" {
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "success", "状态已变更跳过")
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
up := map[string]interface{}{"processed_at": now}
|
||||
switch state {
|
||||
case "SUCCESS":
|
||||
up["status"] = "success"
|
||||
case "FAIL", "CANCELLED":
|
||||
up["status"] = "failed"
|
||||
if failReason != "" {
|
||||
up["fail_reason"] = failReason
|
||||
}
|
||||
default:
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "success", "")
|
||||
return nil
|
||||
}
|
||||
if err := db.Model(&w).Updates(up).Error; err != nil {
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "fail", err.Error())
|
||||
return fmt.Errorf("更新提现状态失败: %w", err)
|
||||
}
|
||||
fmt.Printf("[TransferNotify] 已更新提现 id=%s -> status=%s\n", w.ID, up["status"])
|
||||
logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "success", "")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[TransferNotify] 验签/解密/处理失败: %v\n", err)
|
||||
db := database.DB()
|
||||
logWechatTransferCallback(db, "", "", "", "", "", "fail", err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
c.Header(k, v[0])
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
545
soul-api/internal/handler/referral.go
Normal file
545
soul-api/internal/handler/referral.go
Normal file
@@ -0,0 +1,545 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const defaultBindingDays = 30
|
||||
|
||||
// ReferralBind POST /api/referral/bind 推荐码绑定(新绑定/续期/切换)
|
||||
func ReferralBind(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
ReferralCode string `json:"referralCode" binding:"required"`
|
||||
OpenID string `json:"openId"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"})
|
||||
return
|
||||
}
|
||||
effectiveUserID := req.UserID
|
||||
if effectiveUserID == "" && req.OpenID != "" {
|
||||
effectiveUserID = "user_" + req.OpenID[len(req.OpenID)-8:]
|
||||
}
|
||||
if effectiveUserID == "" || req.ReferralCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
bindingDays := defaultBindingDays
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["bindingDays"] != nil {
|
||||
if v, ok := config["bindingDays"].(float64); ok {
|
||||
bindingDays = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var referrer model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
|
||||
return
|
||||
}
|
||||
if referrer.ID == effectiveUserID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能使用自己的推荐码"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", effectiveUserID).First(&user).Error; err != nil {
|
||||
if req.OpenID != "" {
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
expiryDate := time.Now().AddDate(0, 0, bindingDays)
|
||||
var existing model.ReferralBinding
|
||||
err := db.Where("referee_id = ? AND status = ?", user.ID, "active").Order("binding_date DESC").First(&existing).Error
|
||||
action := "new"
|
||||
var oldReferrerID interface{}
|
||||
|
||||
if err == nil {
|
||||
if existing.ReferrerID == referrer.ID {
|
||||
action = "renew"
|
||||
db.Model(&existing).Updates(map[string]interface{}{
|
||||
"expiry_date": expiryDate,
|
||||
"binding_date": time.Now(),
|
||||
})
|
||||
} else {
|
||||
action = "switch"
|
||||
oldReferrerID = existing.ReferrerID
|
||||
db.Model(&existing).Update("status", "cancelled")
|
||||
bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6))
|
||||
db.Create(&model.ReferralBinding{
|
||||
ID: bindID,
|
||||
ReferrerID: referrer.ID,
|
||||
RefereeID: user.ID,
|
||||
ReferralCode: req.ReferralCode,
|
||||
Status: refString("active"),
|
||||
ExpiryDate: expiryDate,
|
||||
BindingDate: time.Now(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6))
|
||||
db.Create(&model.ReferralBinding{
|
||||
ID: bindID,
|
||||
ReferrerID: referrer.ID,
|
||||
RefereeID: user.ID,
|
||||
ReferralCode: req.ReferralCode,
|
||||
Status: refString("active"),
|
||||
ExpiryDate: expiryDate,
|
||||
BindingDate: time.Now(),
|
||||
})
|
||||
db.Model(&model.User{}).Where("id = ?", referrer.ID).UpdateColumn("referral_count", gorm.Expr("COALESCE(referral_count, 0) + 1"))
|
||||
}
|
||||
|
||||
msg := "绑定成功"
|
||||
if action == "renew" {
|
||||
msg = "绑定已续期"
|
||||
} else if action == "switch" {
|
||||
msg = "推荐人已切换"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": msg,
|
||||
"data": gin.H{
|
||||
"action": action,
|
||||
"referrer": gin.H{"id": referrer.ID, "nickname": getStringValue(referrer.Nickname)},
|
||||
"expiryDate": expiryDate,
|
||||
"bindingDays": bindingDays,
|
||||
"oldReferrerId": oldReferrerID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func refString(s string) *string { return &s }
|
||||
func randomStr(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[time.Now().UnixNano()%int64(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ReferralData GET /api/referral/data 获取分销数据统计
|
||||
func ReferralData(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 获取分销配置(与 soul-admin 推广设置一致)
|
||||
distributorShare := 0.9
|
||||
minWithdrawAmount := 10.0
|
||||
bindingDays := defaultBindingDays
|
||||
userDiscount := 5
|
||||
withdrawFee := 5.0
|
||||
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
if minAmount, ok := config["minWithdrawAmount"].(float64); ok {
|
||||
minWithdrawAmount = minAmount
|
||||
}
|
||||
if days, ok := config["bindingDays"].(float64); ok && days > 0 {
|
||||
bindingDays = int(days)
|
||||
}
|
||||
if discount, ok := config["userDiscount"].(float64); ok {
|
||||
userDiscount = int(discount)
|
||||
}
|
||||
if fee, ok := config["withdrawFee"].(float64); ok {
|
||||
withdrawFee = fee
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 查询用户基本信息
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 绑定统计
|
||||
var totalBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where("referrer_id = ?", userId).Count(&totalBindings)
|
||||
|
||||
var activeBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where(
|
||||
"referrer_id = ? AND status = 'active' AND expiry_date > ?",
|
||||
userId, time.Now(),
|
||||
).Count(&activeBindings)
|
||||
|
||||
var convertedBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where(
|
||||
"referrer_id = ? AND status = 'active' AND purchase_count > 0",
|
||||
userId,
|
||||
).Count(&convertedBindings)
|
||||
|
||||
var expiredBindings int64
|
||||
db.Model(&model.ReferralBinding{}).Where(
|
||||
"referrer_id = ? AND (status IN ('expired', 'cancelled') OR (status = 'active' AND expiry_date <= ?))",
|
||||
userId, time.Now(),
|
||||
).Count(&expiredBindings)
|
||||
|
||||
// 3. 付款统计
|
||||
var paidOrders []struct {
|
||||
Amount float64
|
||||
UserID string
|
||||
}
|
||||
db.Model(&model.Order{}).
|
||||
Select("amount, user_id").
|
||||
Where("referrer_id = ? AND status = 'paid'", userId).
|
||||
Find(&paidOrders)
|
||||
|
||||
totalAmount := 0.0
|
||||
uniqueUsers := make(map[string]bool)
|
||||
for _, order := range paidOrders {
|
||||
totalAmount += order.Amount
|
||||
uniqueUsers[order.UserID] = true
|
||||
}
|
||||
uniquePaidCount := len(uniqueUsers)
|
||||
|
||||
// 4. 访问统计
|
||||
totalVisits := int(totalBindings)
|
||||
var visitCount int64
|
||||
if err := db.Model(&model.ReferralVisit{}).
|
||||
Select("COUNT(DISTINCT visitor_id) as count").
|
||||
Where("referrer_id = ?", userId).
|
||||
Count(&visitCount).Error; err == nil {
|
||||
totalVisits = int(visitCount)
|
||||
}
|
||||
|
||||
// 5. 提现统计(与小程序可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核)
|
||||
// 待审核 = pending + processing + pending_confirm,与 /api/withdraw/pending-confirm 口径一致
|
||||
var pendingWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
|
||||
Scan(&pendingWithdraw)
|
||||
|
||||
var successWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status = ?", userId, "success").
|
||||
Scan(&successWithdraw)
|
||||
|
||||
pendingWithdrawAmount := pendingWithdraw.Total
|
||||
withdrawnFromTable := successWithdraw.Total
|
||||
|
||||
// 6. 获取活跃绑定用户列表
|
||||
var activeBindingsList []model.ReferralBinding
|
||||
db.Where("referrer_id = ? AND status = 'active' AND expiry_date > ?", userId, time.Now()).
|
||||
Order("binding_date DESC").
|
||||
Limit(20).
|
||||
Find(&activeBindingsList)
|
||||
|
||||
activeUsers := []gin.H{}
|
||||
for _, b := range activeBindingsList {
|
||||
var referee model.User
|
||||
db.Where("id = ?", b.RefereeID).First(&referee)
|
||||
|
||||
daysRemaining := int(time.Until(b.ExpiryDate).Hours() / 24)
|
||||
if daysRemaining < 0 {
|
||||
daysRemaining = 0
|
||||
}
|
||||
|
||||
activeUsers = append(activeUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"daysRemaining": daysRemaining,
|
||||
"hasFullBook": getBoolValue(referee.HasFullBook),
|
||||
"bindingDate": b.BindingDate,
|
||||
"status": "active",
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 获取已转化用户列表
|
||||
var convertedBindingsList []model.ReferralBinding
|
||||
db.Where("referrer_id = ? AND status = 'active' AND purchase_count > 0", userId).
|
||||
Order("last_purchase_date DESC").
|
||||
Limit(20).
|
||||
Find(&convertedBindingsList)
|
||||
|
||||
convertedUsers := []gin.H{}
|
||||
for _, b := range convertedBindingsList {
|
||||
var referee model.User
|
||||
db.Where("id = ?", b.RefereeID).First(&referee)
|
||||
|
||||
commission := 0.0
|
||||
if b.TotalCommission != nil {
|
||||
commission = *b.TotalCommission
|
||||
}
|
||||
orderAmount := commission / distributorShare
|
||||
|
||||
convertedUsers = append(convertedUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"commission": commission,
|
||||
"orderAmount": orderAmount,
|
||||
"purchaseCount": getIntValue(b.PurchaseCount),
|
||||
"conversionDate": b.LastPurchaseDate,
|
||||
"status": "converted",
|
||||
})
|
||||
}
|
||||
|
||||
// 8. 获取已过期用户列表
|
||||
var expiredBindingsList []model.ReferralBinding
|
||||
db.Where(
|
||||
"referrer_id = ? AND (status = 'expired' OR (status = 'active' AND expiry_date <= ?))",
|
||||
userId, time.Now(),
|
||||
).Order("expiry_date DESC").Limit(20).Find(&expiredBindingsList)
|
||||
|
||||
expiredUsers := []gin.H{}
|
||||
for _, b := range expiredBindingsList {
|
||||
var referee model.User
|
||||
db.Where("id = ?", b.RefereeID).First(&referee)
|
||||
|
||||
expiredUsers = append(expiredUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"bindingDate": b.BindingDate,
|
||||
"expiryDate": b.ExpiryDate,
|
||||
"status": "expired",
|
||||
})
|
||||
}
|
||||
|
||||
// 9. 获取收益明细
|
||||
var earningsDetailsList []model.Order
|
||||
db.Where("referrer_id = ? AND status = 'paid'", userId).
|
||||
Order("pay_time DESC").
|
||||
Limit(20).
|
||||
Find(&earningsDetailsList)
|
||||
|
||||
earningsDetails := []gin.H{}
|
||||
for _, e := range earningsDetailsList {
|
||||
var buyer model.User
|
||||
db.Where("id = ?", e.UserID).First(&buyer)
|
||||
|
||||
commission := e.Amount * distributorShare
|
||||
earningsDetails = append(earningsDetails, gin.H{
|
||||
"id": e.ID,
|
||||
"orderSn": e.OrderSN,
|
||||
"amount": e.Amount,
|
||||
"commission": commission,
|
||||
"productType": e.ProductType,
|
||||
"productId": getStringValue(e.ProductID),
|
||||
"description": getStringValue(e.Description),
|
||||
"buyerNickname": getStringValue(buyer.Nickname),
|
||||
"buyerAvatar": getStringValue(buyer.Avatar),
|
||||
"payTime": e.PayTime,
|
||||
})
|
||||
}
|
||||
|
||||
// 计算收益
|
||||
totalCommission := totalAmount * distributorShare
|
||||
estimatedEarnings := totalAmount * distributorShare
|
||||
availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount
|
||||
if availableEarnings < 0 {
|
||||
availableEarnings = 0
|
||||
}
|
||||
|
||||
// 计算即将过期用户数(7天内)
|
||||
sevenDaysLater := time.Now().Add(7 * 24 * time.Hour)
|
||||
expiringCount := 0
|
||||
for _, b := range activeBindingsList {
|
||||
if b.ExpiryDate.After(time.Now()) && b.ExpiryDate.Before(sevenDaysLater) {
|
||||
expiringCount++
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
// 核心可见数据
|
||||
"bindingCount": activeBindings,
|
||||
"visitCount": totalVisits,
|
||||
"paidCount": uniquePaidCount,
|
||||
"expiredCount": expiredBindings,
|
||||
|
||||
// 收益数据
|
||||
"totalCommission": round(totalCommission, 2),
|
||||
"availableEarnings": round(availableEarnings, 2),
|
||||
"pendingWithdrawAmount": round(pendingWithdrawAmount, 2),
|
||||
"withdrawnEarnings": withdrawnFromTable,
|
||||
"earnings": getFloatValue(user.Earnings),
|
||||
"pendingEarnings": getFloatValue(user.PendingEarnings),
|
||||
"estimatedEarnings": round(estimatedEarnings, 2),
|
||||
"shareRate": int(distributorShare * 100),
|
||||
"minWithdrawAmount": minWithdrawAmount,
|
||||
"bindingDays": bindingDays,
|
||||
"userDiscount": userDiscount,
|
||||
"withdrawFee": withdrawFee,
|
||||
|
||||
// 推荐码
|
||||
"referralCode": getStringValue(user.ReferralCode),
|
||||
"referralCount": getIntValue(user.ReferralCount),
|
||||
|
||||
// 详细统计
|
||||
"stats": gin.H{
|
||||
"totalBindings": totalBindings,
|
||||
"activeBindings": activeBindings,
|
||||
"convertedBindings": convertedBindings,
|
||||
"expiredBindings": expiredBindings,
|
||||
"expiringCount": expiringCount,
|
||||
"totalPaymentAmount": totalAmount,
|
||||
},
|
||||
|
||||
// 用户列表
|
||||
"activeUsers": activeUsers,
|
||||
"convertedUsers": convertedUsers,
|
||||
"expiredUsers": expiredUsers,
|
||||
|
||||
// 收益明细
|
||||
"earningsDetails": earningsDetails,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// round 四舍五入保留小数
|
||||
func round(val float64, precision int) float64 {
|
||||
ratio := math.Pow(10, float64(precision))
|
||||
return math.Round(val*ratio) / ratio
|
||||
}
|
||||
|
||||
// MyEarnings GET /api/miniprogram/earnings 仅返回「我的收益」卡片所需数据(累计、可提现、推荐人数),用于我的页展示与刷新
|
||||
func MyEarnings(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
distributorShare := 0.9
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
var paidOrders []struct {
|
||||
Amount float64
|
||||
}
|
||||
db.Model(&model.Order{}).
|
||||
Select("amount").
|
||||
Where("referrer_id = ? AND status = 'paid'", userId).
|
||||
Find(&paidOrders)
|
||||
totalAmount := 0.0
|
||||
for _, o := range paidOrders {
|
||||
totalAmount += o.Amount
|
||||
}
|
||||
var pendingWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
|
||||
Scan(&pendingWithdraw)
|
||||
var successWithdraw struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).
|
||||
Select("COALESCE(SUM(amount), 0) as total").
|
||||
Where("user_id = ? AND status = ?", userId, "success").
|
||||
Scan(&successWithdraw)
|
||||
totalCommission := totalAmount * distributorShare
|
||||
pendingWithdrawAmount := pendingWithdraw.Total
|
||||
withdrawnFromTable := successWithdraw.Total
|
||||
availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount
|
||||
if availableEarnings < 0 {
|
||||
availableEarnings = 0
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"totalCommission": round(totalCommission, 2),
|
||||
"availableEarnings": round(availableEarnings, 2),
|
||||
"referralCount": getIntValue(user.ReferralCount),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ReferralVisit POST /api/referral/visit 记录推荐访问(不需登录)
|
||||
func ReferralVisit(c *gin.Context) {
|
||||
var req struct {
|
||||
ReferralCode string `json:"referralCode" binding:"required"`
|
||||
VisitorOpenID string `json:"visitorOpenId"`
|
||||
VisitorID string `json:"visitorId"`
|
||||
Source string `json:"source"`
|
||||
Page string `json:"page"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var referrer model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
|
||||
return
|
||||
}
|
||||
source := req.Source
|
||||
if source == "" {
|
||||
source = "miniprogram"
|
||||
}
|
||||
visitorID := req.VisitorID
|
||||
if visitorID == "" {
|
||||
visitorID = ""
|
||||
}
|
||||
vOpenID := req.VisitorOpenID
|
||||
vPage := req.Page
|
||||
err := db.Create(&model.ReferralVisit{
|
||||
ReferrerID: referrer.ID,
|
||||
VisitorID: strPtrOrNil(visitorID),
|
||||
VisitorOpenID: strPtrOrNil(vOpenID),
|
||||
Source: strPtrOrNil(source),
|
||||
Page: strPtrOrNil(vPage),
|
||||
}).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已处理"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "访问已记录"})
|
||||
}
|
||||
func strPtrOrNil(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
81
soul-api/internal/handler/search.go
Normal file
81
soul-api/internal/handler/search.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// escapeLike 转义 LIKE 中的 % _ \,防止注入与通配符滥用
|
||||
func escapeLike(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "%", "\\%")
|
||||
s = strings.ReplaceAll(s, "_", "\\_")
|
||||
return s
|
||||
}
|
||||
|
||||
// SearchGet GET /api/search?q= 从 chapters 表搜索(GORM,参数化)
|
||||
func SearchGet(c *gin.Context) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
if q == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入搜索关键词"})
|
||||
return
|
||||
}
|
||||
pattern := "%" + escapeLike(q) + "%"
|
||||
var list []model.Chapter
|
||||
err := database.DB().Model(&model.Chapter{}).
|
||||
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Limit(50).
|
||||
Find(&list).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
|
||||
return
|
||||
}
|
||||
lowerQ := strings.ToLower(q)
|
||||
results := make([]gin.H, 0, len(list))
|
||||
for _, ch := range list {
|
||||
matchType := "content"
|
||||
score := 5
|
||||
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
|
||||
matchType = "title"
|
||||
score = 10
|
||||
}
|
||||
snippet := ""
|
||||
pos := strings.Index(strings.ToLower(ch.Content), lowerQ)
|
||||
if pos >= 0 && len(ch.Content) > 0 {
|
||||
start := pos - 50
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := pos + utf8.RuneCountInString(q) + 50
|
||||
if end > len(ch.Content) {
|
||||
end = len(ch.Content)
|
||||
}
|
||||
snippet = ch.Content[start:end]
|
||||
if start > 0 {
|
||||
snippet = "..." + snippet
|
||||
}
|
||||
if end < len(ch.Content) {
|
||||
snippet = snippet + "..."
|
||||
}
|
||||
}
|
||||
price := 1.0
|
||||
if ch.Price != nil {
|
||||
price = *ch.Price
|
||||
}
|
||||
results = append(results, gin.H{
|
||||
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
|
||||
"price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"keyword": q, "total": len(results), "results": results},
|
||||
})
|
||||
}
|
||||
22
soul-api/internal/handler/sync.go
Normal file
22
soul-api/internal/handler/sync.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SyncGet GET /api/sync
|
||||
func SyncGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// SyncPost POST /api/sync
|
||||
func SyncPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// SyncPut PUT /api/sync
|
||||
func SyncPut(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
81
soul-api/internal/handler/upload.go
Normal file
81
soul-api/internal/handler/upload.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const uploadDir = "uploads"
|
||||
const maxUploadBytes = 5 * 1024 * 1024 // 5MB
|
||||
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
|
||||
|
||||
// UploadPost POST /api/upload 上传图片(表单 file)
|
||||
func UploadPost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的文件"})
|
||||
return
|
||||
}
|
||||
if file.Size > maxUploadBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "文件大小不能超过5MB"})
|
||||
return
|
||||
}
|
||||
ct := file.Header.Get("Content-Type")
|
||||
if !allowedTypes[ct] && !strings.HasPrefix(ct, "image/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持图片格式"})
|
||||
return
|
||||
}
|
||||
ext := filepath.Ext(file.Filename)
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
folder := c.PostForm("folder")
|
||||
if folder == "" {
|
||||
folder = "avatars"
|
||||
}
|
||||
dir := filepath.Join(uploadDir, folder)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||||
return
|
||||
}
|
||||
url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
|
||||
}
|
||||
|
||||
func randomStrUpload(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// UploadDelete DELETE /api/upload
|
||||
func UploadDelete(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请指定 path"})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(path, "/uploads/") && !strings.HasPrefix(path, "uploads/") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
|
||||
return
|
||||
}
|
||||
fullPath := strings.TrimPrefix(path, "/")
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "文件不存在或删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
|
||||
}
|
||||
526
soul-api/internal/handler/user.go
Normal file
526
soul-api/internal/handler/user.go
Normal file
@@ -0,0 +1,526 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UserAddressesGet GET /api/user/addresses?userId=
|
||||
func UserAddressesGet(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
var list []model.UserAddress
|
||||
if err := database.DB().Where("user_id = ?", userId).Order("is_default DESC, updated_at DESC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": []interface{}{}})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
full := r.Province + r.City + r.District + r.Detail
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
|
||||
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
|
||||
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
}
|
||||
|
||||
// UserAddressesPost POST /api/user/addresses
|
||||
func UserAddressesPost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
Detail string `json:"detail" binding:"required"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少必填项:userId, name, phone, detail"})
|
||||
return
|
||||
}
|
||||
id := fmt.Sprintf("addr_%d", time.Now().UnixNano()%100000000000)
|
||||
db := database.DB()
|
||||
if body.IsDefault {
|
||||
db.Model(&model.UserAddress{}).Where("user_id = ?", body.UserID).Update("is_default", false)
|
||||
}
|
||||
addr := model.UserAddress{
|
||||
ID: id, UserID: body.UserID, Name: body.Name, Phone: body.Phone,
|
||||
Province: body.Province, City: body.City, District: body.District, Detail: body.Detail,
|
||||
IsDefault: body.IsDefault,
|
||||
}
|
||||
if err := db.Create(&addr).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "添加地址失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "id": id, "message": "添加成功"})
|
||||
}
|
||||
|
||||
// UserAddressesByID GET/PUT/DELETE /api/user/addresses/:id
|
||||
func UserAddressesByID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少地址 id"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
switch c.Request.Method {
|
||||
case "GET":
|
||||
var r model.UserAddress
|
||||
if err := db.Where("id = ?", id).First(&r).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"})
|
||||
return
|
||||
}
|
||||
full := r.Province + r.City + r.District + r.Detail
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
|
||||
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
|
||||
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
|
||||
}})
|
||||
case "PUT":
|
||||
var r model.UserAddress
|
||||
if err := db.Where("id = ?", id).First(&r).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Phone *string `json:"phone"`
|
||||
Province *string `json:"province"`
|
||||
City *string `json:"city"`
|
||||
District *string `json:"district"`
|
||||
Detail *string `json:"detail"`
|
||||
IsDefault *bool `json:"isDefault"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
updates := make(map[string]interface{})
|
||||
if body.Name != nil { updates["name"] = *body.Name }
|
||||
if body.Phone != nil { updates["phone"] = *body.Phone }
|
||||
if body.Province != nil { updates["province"] = *body.Province }
|
||||
if body.City != nil { updates["city"] = *body.City }
|
||||
if body.District != nil { updates["district"] = *body.District }
|
||||
if body.Detail != nil { updates["detail"] = *body.Detail }
|
||||
if body.IsDefault != nil {
|
||||
updates["is_default"] = *body.IsDefault
|
||||
if *body.IsDefault {
|
||||
db.Model(&model.UserAddress{}).Where("user_id = ?", r.UserID).Update("is_default", false)
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
updates["updated_at"] = time.Now()
|
||||
db.Model(&r).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&r)
|
||||
full := r.Province + r.City + r.District + r.Detail
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "item": gin.H{
|
||||
"id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone,
|
||||
"province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail,
|
||||
"isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt,
|
||||
}, "message": "更新成功"})
|
||||
case "DELETE":
|
||||
if err := db.Where("id = ?", id).Delete(&model.UserAddress{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
|
||||
}
|
||||
}
|
||||
|
||||
// UserCheckPurchased GET /api/user/check-purchased?userId=&type=section|fullbook&productId=
|
||||
func UserCheckPurchased(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
type_ := c.Query("type")
|
||||
productId := c.Query("productId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
|
||||
if hasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
return
|
||||
}
|
||||
if type_ == "fullbook" {
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userId, "fullbook", "paid").Count(&count)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "fullbook_order_exists"}[count > 0]}})
|
||||
return
|
||||
}
|
||||
if type_ == "section" && productId != "" {
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status = ?", userId, "section", productId, "paid").Count(&count)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "section_order_exists"}[count > 0]}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": false, "reason": nil}})
|
||||
}
|
||||
|
||||
// UserProfileGet GET /api/user/profile?userId= 或 openId=
|
||||
func UserProfileGet(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
openId := c.Query("openId")
|
||||
if userId == "" && openId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if userId != "" {
|
||||
db = db.Where("id = ?", userId)
|
||||
} else {
|
||||
db = db.Where("open_id = ?", openId)
|
||||
}
|
||||
if err := db.First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
profileComplete := (user.Phone != nil && *user.Phone != "") || (user.WechatID != nil && *user.WechatID != "")
|
||||
hasAvatar := user.Avatar != nil && *user.Avatar != "" && len(*user.Avatar) > 0
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"id": user.ID, "openId": user.OpenID, "nickname": user.Nickname, "avatar": user.Avatar,
|
||||
"phone": user.Phone, "wechatId": user.WechatID, "referralCode": user.ReferralCode,
|
||||
"hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings,
|
||||
"referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar,
|
||||
"createdAt": user.CreatedAt,
|
||||
}})
|
||||
}
|
||||
|
||||
// UserProfilePost POST /api/user/profile 更新用户资料
|
||||
func UserProfilePost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
OpenID string `json:"openId"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
|
||||
return
|
||||
}
|
||||
identifier := body.UserID
|
||||
byID := true
|
||||
if identifier == "" {
|
||||
identifier = body.OpenID
|
||||
byID = false
|
||||
}
|
||||
if identifier == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if byID {
|
||||
db = db.Where("id = ?", identifier)
|
||||
} else {
|
||||
db = db.Where("open_id = ?", identifier)
|
||||
}
|
||||
if err := db.First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
updates := make(map[string]interface{})
|
||||
if body.Nickname != nil { updates["nickname"] = *body.Nickname }
|
||||
if body.Avatar != nil { updates["avatar"] = *body.Avatar }
|
||||
if body.Phone != nil { updates["phone"] = *body.Phone }
|
||||
if body.WechatID != nil { updates["wechat_id"] = *body.WechatID }
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "没有需要更新的字段"})
|
||||
return
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
db.Model(&user).Updates(updates)
|
||||
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,
|
||||
}})
|
||||
}
|
||||
|
||||
// UserPurchaseStatus GET /api/user/purchase-status?userId=
|
||||
func UserPurchaseStatus(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
var orderRows []struct {
|
||||
ProductID string
|
||||
MID int
|
||||
}
|
||||
db.Raw(`SELECT DISTINCT o.product_id, c.mid FROM orders o
|
||||
LEFT JOIN chapters c ON c.id = o.product_id
|
||||
WHERE o.user_id = ? AND o.status = ? AND o.product_type = ?`, userId, "paid", "section").Scan(&orderRows)
|
||||
purchasedSections := make([]string, 0, len(orderRows))
|
||||
sectionMidMap := make(map[string]int)
|
||||
for _, r := range orderRows {
|
||||
if r.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, r.ProductID)
|
||||
if r.MID > 0 {
|
||||
sectionMidMap[r.ProductID] = r.MID
|
||||
}
|
||||
}
|
||||
}
|
||||
// 是否有推荐人(被推荐绑定,可享好友优惠)
|
||||
var refCount int64
|
||||
db.Model(&model.ReferralBinding{}).Where("referee_id = ? AND status = ?", userId, "active").
|
||||
Where("expiry_date > ?", time.Now()).Count(&refCount)
|
||||
hasReferrer := refCount > 0
|
||||
|
||||
// 匹配次数配额:纯计算(订单 + match_records)
|
||||
freeLimit := getFreeMatchLimit(db)
|
||||
matchQuota := GetMatchQuota(db, userId, freeLimit)
|
||||
earnings := 0.0
|
||||
if user.Earnings != nil {
|
||||
earnings = *user.Earnings
|
||||
}
|
||||
pendingEarnings := 0.0
|
||||
if user.PendingEarnings != nil {
|
||||
pendingEarnings = *user.PendingEarnings
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"hasFullBook": user.HasFullBook != nil && *user.HasFullBook,
|
||||
"purchasedSections": purchasedSections,
|
||||
"sectionMidMap": sectionMidMap,
|
||||
"purchasedCount": len(purchasedSections),
|
||||
"hasReferrer": hasReferrer,
|
||||
"matchCount": matchQuota.PurchasedTotal,
|
||||
"matchQuota": gin.H{
|
||||
"purchasedTotal": matchQuota.PurchasedTotal,
|
||||
"purchasedUsed": matchQuota.PurchasedUsed,
|
||||
"matchesUsedToday": matchQuota.MatchesUsedToday,
|
||||
"freeRemainToday": matchQuota.FreeRemainToday,
|
||||
"purchasedRemain": matchQuota.PurchasedRemain,
|
||||
"remainToday": matchQuota.RemainToday,
|
||||
},
|
||||
"earnings": earnings,
|
||||
"pendingEarnings": pendingEarnings,
|
||||
}})
|
||||
}
|
||||
|
||||
// UserReadingProgressGet GET /api/user/reading-progress?userId=
|
||||
func UserReadingProgressGet(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
|
||||
return
|
||||
}
|
||||
var list []model.ReadingProgress
|
||||
if err := database.DB().Where("user_id = ?", userId).Order("last_open_at DESC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
out = append(out, gin.H{
|
||||
"section_id": r.SectionID, "progress": r.Progress, "duration": r.Duration, "status": r.Status,
|
||||
"completed_at": r.CompletedAt, "first_open_at": r.FirstOpenAt, "last_open_at": r.LastOpenAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// UserReadingProgressPost POST /api/user/reading-progress
|
||||
func UserReadingProgressPost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
Progress int `json:"progress"`
|
||||
Duration int `json:"duration"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
now := time.Now()
|
||||
var existing model.ReadingProgress
|
||||
err := db.Where("user_id = ? AND section_id = ?", body.UserID, body.SectionID).First(&existing).Error
|
||||
if err == nil {
|
||||
newProgress := existing.Progress
|
||||
if body.Progress > newProgress {
|
||||
newProgress = body.Progress
|
||||
}
|
||||
newDuration := existing.Duration + body.Duration
|
||||
newStatus := body.Status
|
||||
if newStatus == "" {
|
||||
newStatus = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
} else if existing.CompletedAt != nil {
|
||||
completedAt = existing.CompletedAt
|
||||
}
|
||||
db.Model(&existing).Updates(map[string]interface{}{
|
||||
"progress": newProgress, "duration": newDuration, "status": newStatus,
|
||||
"completed_at": completedAt, "last_open_at": now, "updated_at": now,
|
||||
})
|
||||
} else {
|
||||
status := body.Status
|
||||
if status == "" {
|
||||
status = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
}
|
||||
db.Create(&model.ReadingProgress{
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: body.Duration,
|
||||
Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "进度已保存"})
|
||||
}
|
||||
|
||||
// UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查(GORM)
|
||||
func UserTrackGet(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
phone := c.Query("phone")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
if userId == "" && phone == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
if userId == "" && phone != "" {
|
||||
var u model.User
|
||||
if err := db.Where("phone = ?", phone).First(&u).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
userId = u.ID
|
||||
}
|
||||
var tracks []model.UserTrack
|
||||
if err := db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": []interface{}{}, "stats": gin.H{}, "total": 0})
|
||||
return
|
||||
}
|
||||
stats := make(map[string]int)
|
||||
formatted := make([]gin.H, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
stats[t.Action]++
|
||||
target := ""
|
||||
if t.Target != nil {
|
||||
target = *t.Target
|
||||
}
|
||||
if t.ChapterID != nil && target == "" {
|
||||
target = *t.ChapterID
|
||||
}
|
||||
formatted = append(formatted, gin.H{
|
||||
"id": t.ID, "action": t.Action, "target": target, "chapterTitle": t.ChapterID,
|
||||
"createdAt": t.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": formatted, "stats": stats, "total": len(formatted)})
|
||||
}
|
||||
|
||||
// UserTrackPost POST /api/user/track 记录行为(GORM)
|
||||
func UserTrackPost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
ExtraData interface{} `json:"extraData"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.UserID == "" && body.Phone == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"})
|
||||
return
|
||||
}
|
||||
if body.Action == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
userId := body.UserID
|
||||
if userId == "" {
|
||||
var u model.User
|
||||
if err := db.Where("phone = ?", body.Phone).First(&u).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
userId = u.ID
|
||||
}
|
||||
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
chID := body.Target
|
||||
if body.Action == "view_chapter" {
|
||||
chID = body.Target
|
||||
}
|
||||
t := model.UserTrack{
|
||||
ID: trackID, UserID: userId, Action: body.Action, Target: &body.Target,
|
||||
}
|
||||
if body.Target != "" {
|
||||
t.ChapterID = &chID
|
||||
}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"})
|
||||
}
|
||||
|
||||
// UserUpdate POST /api/user/update 更新昵称、头像、手机、微信号等
|
||||
func UserUpdate(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Phone *string `json:"phone"`
|
||||
Wechat *string `json:"wechat"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"})
|
||||
return
|
||||
}
|
||||
updates := make(map[string]interface{})
|
||||
if body.Nickname != nil { updates["nickname"] = *body.Nickname }
|
||||
if body.Avatar != nil { updates["avatar"] = *body.Avatar }
|
||||
if body.Phone != nil { updates["phone"] = *body.Phone }
|
||||
if body.Wechat != nil { updates["wechat_id"] = *body.Wechat }
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "没有需要更新的字段"})
|
||||
return
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
if err := database.DB().Model(&model.User{}).Where("id = ?", body.UserID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"})
|
||||
}
|
||||
160
soul-api/internal/handler/wechat.go
Normal file
160
soul-api/internal/handler/wechat.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// WechatLogin POST /api/wechat/login
|
||||
func WechatLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// WechatPhoneLoginReq 手机号登录请求:code 为 wx.login() 的 code,phoneCode 为 getPhoneNumber 返回的 code
|
||||
type WechatPhoneLoginReq struct {
|
||||
Code string `json:"code"` // wx.login() 得到,用于 code2session 拿 openId
|
||||
PhoneCode string `json:"phoneCode"` // getPhoneNumber 得到,用于换手机号
|
||||
}
|
||||
|
||||
// WechatPhoneLogin POST /api/wechat/phone-login
|
||||
// 请求体:code(必填)+ phoneCode(必填)。先 code2session 得到 openId,再 getPhoneNumber 得到手机号,创建/更新用户并返回与 /api/miniprogram/login 一致的数据结构。
|
||||
func WechatPhoneLogin(c *gin.Context) {
|
||||
var req WechatPhoneLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code 或 phoneCode"})
|
||||
return
|
||||
}
|
||||
if req.Code == "" || req.PhoneCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供 code 与 phoneCode"})
|
||||
return
|
||||
}
|
||||
|
||||
openID, sessionKey, _, err := wechat.Code2Session(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)})
|
||||
return
|
||||
}
|
||||
phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.PhoneCode)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("获取手机号失败: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
result := db.Where("open_id = ?", openID).First(&user)
|
||||
isNewUser := result.Error != nil
|
||||
|
||||
if isNewUser {
|
||||
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
|
||||
nickname := "微信用户" + openID[len(openID)-4:]
|
||||
avatar := ""
|
||||
hasFullBook := false
|
||||
earnings := 0.0
|
||||
pendingEarnings := 0.0
|
||||
referralCount := 0
|
||||
purchasedSections := "[]"
|
||||
phone := phoneNumber
|
||||
if countryCode != "" && countryCode != "86" {
|
||||
phone = "+" + countryCode + " " + phoneNumber
|
||||
}
|
||||
user = model.User{
|
||||
ID: openID,
|
||||
OpenID: &openID,
|
||||
SessionKey: &sessionKey,
|
||||
Nickname: &nickname,
|
||||
Avatar: &avatar,
|
||||
Phone: &phone,
|
||||
ReferralCode: &referralCode,
|
||||
HasFullBook: &hasFullBook,
|
||||
PurchasedSections: &purchasedSections,
|
||||
Earnings: &earnings,
|
||||
PendingEarnings: &pendingEarnings,
|
||||
ReferralCount: &referralCount,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
phone := phoneNumber
|
||||
if countryCode != "" && countryCode != "86" {
|
||||
phone = "+" + countryCode + " " + phoneNumber
|
||||
}
|
||||
db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone})
|
||||
user.Phone = &phone
|
||||
}
|
||||
|
||||
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)
|
||||
purchasedSections := []string{}
|
||||
for _, row := range orderRows {
|
||||
if row.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, row.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
responseUser := map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"openId": strVal(user.OpenID),
|
||||
"nickname": strVal(user.Nickname),
|
||||
"avatar": strVal(user.Avatar),
|
||||
"phone": strVal(user.Phone),
|
||||
"wechatId": strVal(user.WechatID),
|
||||
"referralCode": strVal(user.ReferralCode),
|
||||
"hasFullBook": boolVal(user.HasFullBook),
|
||||
"purchasedSections": purchasedSections,
|
||||
"earnings": floatVal(user.Earnings),
|
||||
"pendingEarnings": floatVal(user.PendingEarnings),
|
||||
"referralCount": intVal(user.ReferralCount),
|
||||
"createdAt": user.CreatedAt,
|
||||
}
|
||||
token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"openId": openID,
|
||||
"user": responseUser,
|
||||
"token": token,
|
||||
},
|
||||
"isNewUser": isNewUser,
|
||||
})
|
||||
}
|
||||
|
||||
func strVal(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
func boolVal(p *bool) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return *p
|
||||
}
|
||||
func floatVal(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
func intVal(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
366
soul-api/internal/handler/withdraw.go
Normal file
366
soul-api/internal/handler/withdraw.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// computeAvailableWithdraw 与小程序 / referral 页可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核
|
||||
// 用于 referral/data 展示与 withdraw 接口二次查库校验(不信任前端传参)
|
||||
func computeAvailableWithdraw(db *gorm.DB, userID string) (available, totalCommission, withdrawn, pending float64, minAmount float64) {
|
||||
distributorShare := 0.9
|
||||
minAmount = 10
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if _ = json.Unmarshal(cfg.ConfigValue, &config); config != nil {
|
||||
if share, ok := config["distributorShare"].(float64); ok {
|
||||
distributorShare = share / 100
|
||||
}
|
||||
if m, ok := config["minWithdrawAmount"].(float64); ok {
|
||||
minAmount = m
|
||||
}
|
||||
}
|
||||
}
|
||||
var sumOrder struct{ Total float64 }
|
||||
db.Model(&model.Order{}).Where("referrer_id = ? AND status = ?", userID, "paid").
|
||||
Select("COALESCE(SUM(amount), 0) as total").Scan(&sumOrder)
|
||||
totalCommission = sumOrder.Total * distributorShare
|
||||
var w struct{ Total float64 }
|
||||
db.Model(&model.Withdrawal{}).Where("user_id = ? AND status = ?", userID, "success").
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&w)
|
||||
withdrawn = w.Total
|
||||
db.Model(&model.Withdrawal{}).Where("user_id = ? AND status IN ?", userID, []string{"pending", "processing", "pending_confirm"}).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&w)
|
||||
pending = w.Total
|
||||
available = math.Max(0, totalCommission-withdrawn-pending)
|
||||
return available, totalCommission, withdrawn, pending, minAmount
|
||||
}
|
||||
|
||||
// generateWithdrawID 生成提现单号(不依赖 wechat 包)
|
||||
func generateWithdrawID() string {
|
||||
return fmt.Sprintf("WD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000)
|
||||
}
|
||||
|
||||
// WithdrawPost POST /api/withdraw 创建提现申请(仅落库待审核,不调用微信打款接口)
|
||||
// 可提现逻辑与小程序 referral 页一致;二次查库校验防止超额。打款由管理端审核后手动/后续接入官方接口再处理。
|
||||
func WithdrawPost(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
UserName string `json:"userName"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现金额必须大于0"})
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if req.Amount < minWithdrawAmount {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("最低提现金额为%.0f元", minWithdrawAmount),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", req.UserID).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if user.OpenID == nil || *user.OpenID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定微信"})
|
||||
return
|
||||
}
|
||||
|
||||
withdrawID := generateWithdrawID()
|
||||
status := "pending"
|
||||
// 根据 user_id 已查到的用户信息,填充提现表所需字段;仅写入表中存在的列,避免 remark 等列不存在报错
|
||||
wechatID := user.WechatID
|
||||
if (wechatID == nil || *wechatID == "") && user.OpenID != nil && *user.OpenID != "" {
|
||||
wechatID = user.OpenID
|
||||
}
|
||||
withdrawal := model.Withdrawal{
|
||||
ID: withdrawID,
|
||||
UserID: req.UserID,
|
||||
Amount: req.Amount,
|
||||
Status: &status,
|
||||
WechatOpenid: user.OpenID,
|
||||
WechatID: wechatID,
|
||||
}
|
||||
if err := db.Select("ID", "UserID", "Amount", "Status", "WechatOpenid", "WechatID").Create(&withdrawal).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "创建提现记录失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "提现申请已提交,审核通过后将打款至您的微信零钱",
|
||||
"data": map[string]interface{}{
|
||||
"id": withdrawal.ID,
|
||||
"amount": req.Amount,
|
||||
"status": "pending",
|
||||
"created_at": withdrawal.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminWithdrawTest GET/POST /api/admin/withdraw-test 提现测试接口,供 curl 等调试用
|
||||
// 参数:userId(默认 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg)、amount(默认 1)
|
||||
// 测试时忽略最低提现额限制,仅校验可提现余额与用户存在
|
||||
func AdminWithdrawTest(c *gin.Context) {
|
||||
userID := c.DefaultQuery("userId", "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg")
|
||||
amountStr := c.DefaultQuery("amount", "1")
|
||||
var amount float64
|
||||
if _, err := fmt.Sscanf(amountStr, "%f", &amount); err != nil || amount <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "amount 须为正数"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
available, _, _, _, _ := computeAvailableWithdraw(db, userID)
|
||||
if amount > available {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("可提现金额不足(当前可提现:%.2f元)", available),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if user.OpenID == nil || *user.OpenID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定微信"})
|
||||
return
|
||||
}
|
||||
|
||||
withdrawID := generateWithdrawID()
|
||||
status := "pending"
|
||||
wechatID := user.WechatID
|
||||
if (wechatID == nil || *wechatID == "") && user.OpenID != nil && *user.OpenID != "" {
|
||||
wechatID = user.OpenID
|
||||
}
|
||||
withdrawal := model.Withdrawal{
|
||||
ID: withdrawID,
|
||||
UserID: userID,
|
||||
Amount: amount,
|
||||
Status: &status,
|
||||
WechatOpenid: user.OpenID,
|
||||
WechatID: wechatID,
|
||||
}
|
||||
if err := db.Select("ID", "UserID", "Amount", "Status", "WechatOpenid", "WechatID").Create(&withdrawal).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": "创建提现记录失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "提现测试已提交",
|
||||
"data": map[string]interface{}{
|
||||
"id": withdrawal.ID,
|
||||
"userId": userID,
|
||||
"amount": amount,
|
||||
"status": "pending",
|
||||
"created_at": withdrawal.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawRecords GET /api/withdraw/records?userId= 当前用户提现记录(GORM)
|
||||
func WithdrawRecords(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
var list []model.Withdrawal
|
||||
if err := database.DB().Where("user_id = ?", userId).Order("created_at DESC").Limit(100).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": []interface{}{}}})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, w := range list {
|
||||
st := ""
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
}
|
||||
canReceive := st == "processing" || st == "pending_confirm"
|
||||
out = append(out, gin.H{
|
||||
"id": w.ID, "amount": w.Amount, "status": st,
|
||||
"createdAt": w.CreatedAt, "processedAt": w.ProcessedAt,
|
||||
"canReceive": canReceive,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out}})
|
||||
}
|
||||
|
||||
// WithdrawConfirmInfo GET /api/miniprogram/withdraw/confirm-info?id= 获取某条提现的领取零钱参数(mchId/appId/package),供 wx.requestMerchantTransfer 使用
|
||||
func WithdrawConfirmInfo(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 id"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ?", id).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "提现记录不存在"})
|
||||
return
|
||||
}
|
||||
st := ""
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
}
|
||||
if st != "processing" && st != "pending_confirm" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前状态不可领取"})
|
||||
return
|
||||
}
|
||||
mchId := os.Getenv("WECHAT_MCH_ID")
|
||||
if mchId == "" {
|
||||
mchId = "1318592501"
|
||||
}
|
||||
appId := os.Getenv("WECHAT_APPID")
|
||||
if appId == "" {
|
||||
appId = "wxb8bbb2b10dec74aa"
|
||||
}
|
||||
packageInfo := ""
|
||||
if w.PackageInfo != nil && *w.PackageInfo != "" {
|
||||
packageInfo = *w.PackageInfo
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"mchId": mchId,
|
||||
"appId": appId,
|
||||
"package": packageInfo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawPendingConfirm GET /api/withdraw/pending-confirm?userId= 待确认收款列表(仅审核通过后)
|
||||
// 只返回 processing、pending_confirm,供「我的」页「待确认收款」展示;pending 为待审核,不在此列表
|
||||
func WithdrawPendingConfirm(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var list []model.Withdrawal
|
||||
// 仅审核已通过、等待用户确认收款的:processing(微信处理中)、pending_confirm(待用户点确认收款)
|
||||
if err := db.Where("user_id = ? AND status IN ?", userId, []string{"processing", "pending_confirm"}).
|
||||
Order("created_at DESC").
|
||||
Find(&list).Error; err != nil {
|
||||
list = nil
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, w := range list {
|
||||
item := gin.H{
|
||||
"id": w.ID,
|
||||
"amount": w.Amount,
|
||||
"createdAt": w.CreatedAt,
|
||||
}
|
||||
if w.PackageInfo != nil && *w.PackageInfo != "" {
|
||||
item["package"] = *w.PackageInfo
|
||||
} else {
|
||||
item["package"] = ""
|
||||
}
|
||||
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
|
||||
item["userConfirmedAt"] = w.UserConfirmedAt.Format("2006-01-02 15:04:05")
|
||||
} else {
|
||||
item["userConfirmedAt"] = nil
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
mchId := os.Getenv("WECHAT_MCH_ID")
|
||||
if mchId == "" {
|
||||
mchId = "1318592501"
|
||||
}
|
||||
appId := os.Getenv("WECHAT_APPID")
|
||||
if appId == "" {
|
||||
appId = "wxb8bbb2b10dec74aa"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"list": out,
|
||||
"mchId": mchId,
|
||||
"appId": appId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawConfirmReceived POST /api/miniprogram/withdraw/confirm-received 用户确认收款(记录已点击确认)
|
||||
// body: { "withdrawalId": "xxx", "userId": "xxx" },仅本人可操作;更新 user_confirmed_at 并将状态置为 success,该条不再出现在待确认收款列表
|
||||
func WithdrawConfirmReceived(c *gin.Context) {
|
||||
var req struct {
|
||||
WithdrawalID string `json:"withdrawalId" binding:"required"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawalId 或 userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ? AND user_id = ?", req.WithdrawalID, req.UserID).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "提现记录不存在或无权操作"})
|
||||
return
|
||||
}
|
||||
st := ""
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
}
|
||||
// 仅处理中或待确认的可标记「用户已确认收款」
|
||||
if st != "processing" && st != "pending_confirm" && st != "success" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前状态不可确认收款"})
|
||||
return
|
||||
}
|
||||
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已确认过"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
// 更新为已确认收款,并将状态置为 success,待确认列表只含 processing/pending_confirm,故该条会从列表中移除
|
||||
up := map[string]interface{}{"user_confirmed_at": now, "status": "success"}
|
||||
if err := db.Model(&w).Updates(up).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已记录确认收款"})
|
||||
}
|
||||
341
soul-api/internal/handler/withdraw_v3.go
Normal file
341
soul-api/internal/handler/withdraw_v3.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat/transferv3"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getTransferV3Client 从 config 创建文档 V3 转账 Client(独立于 PowerWeChat)
|
||||
func getTransferV3Client() (*transferv3.Client, error) {
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config not loaded")
|
||||
}
|
||||
key, err := transferv3.LoadPrivateKeyFromPath(cfg.WechatKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load private key: %w", err)
|
||||
}
|
||||
return transferv3.NewClient(cfg.WechatMchID, cfg.WechatAppID, cfg.WechatSerialNo, key), nil
|
||||
}
|
||||
|
||||
// WithdrawV3Initiate POST /api/v3/withdraw/initiate 根据文档发起商家转账到零钱(V3 独立实现)
|
||||
// body: { "withdrawal_id": "xxx" },需先存在 pending 的提现记录
|
||||
func WithdrawV3Initiate(c *gin.Context) {
|
||||
var req struct {
|
||||
WithdrawalID string `json:"withdrawal_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawal_id"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ?", req.WithdrawalID).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现记录不存在"})
|
||||
return
|
||||
}
|
||||
st := ""
|
||||
if w.Status != nil {
|
||||
st = *w.Status
|
||||
}
|
||||
if st != "pending" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "仅支持 pending 状态发起"})
|
||||
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.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定 openid"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "配置未加载"})
|
||||
return
|
||||
}
|
||||
|
||||
outBatchNo := fmt.Sprintf("WD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000)
|
||||
outDetailNo := fmt.Sprintf("WDD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000)
|
||||
amountFen := int(w.Amount * 100)
|
||||
if amountFen < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "金额异常"})
|
||||
return
|
||||
}
|
||||
|
||||
batchRemark := fmt.Sprintf("提现 %.2f 元", w.Amount)
|
||||
if len([]rune(batchRemark)) > 32 {
|
||||
batchRemark = "用户提现"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"appid": cfg.WechatAppID,
|
||||
"out_batch_no": outBatchNo,
|
||||
"batch_name": "用户提现",
|
||||
"batch_remark": batchRemark,
|
||||
"total_amount": amountFen,
|
||||
"total_num": 1,
|
||||
"transfer_scene_id": "1005",
|
||||
"transfer_detail_list": []map[string]interface{}{
|
||||
{
|
||||
"out_detail_no": outDetailNo,
|
||||
"transfer_amount": amountFen,
|
||||
"transfer_remark": "提现",
|
||||
"openid": openID,
|
||||
},
|
||||
},
|
||||
}
|
||||
if cfg.WechatTransferURL != "" {
|
||||
body["notify_url"] = cfg.WechatTransferURL
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
|
||||
client, err := getTransferV3Client()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respBody, statusCode, err := client.PostBatches(bodyBytes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if statusCode < 200 || statusCode >= 300 {
|
||||
var errResp struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(respBody, &errResp)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": errResp.Message,
|
||||
"code": errResp.Code,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var respData struct {
|
||||
OutBatchNo string `json:"out_batch_no"`
|
||||
BatchID string `json:"batch_id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
BatchStatus string `json:"batch_status"`
|
||||
}
|
||||
_ = json.Unmarshal(respBody, &respData)
|
||||
|
||||
now := time.Now()
|
||||
processingStatus := "processing"
|
||||
_ = db.Model(&w).Updates(map[string]interface{}{
|
||||
"status": processingStatus,
|
||||
"batch_no": outBatchNo,
|
||||
"detail_no": outDetailNo,
|
||||
"batch_id": respData.BatchID,
|
||||
"processed_at": now,
|
||||
}).Error
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "已发起打款,微信处理中",
|
||||
"data": gin.H{
|
||||
"out_batch_no": outBatchNo,
|
||||
"batch_id": respData.BatchID,
|
||||
"batch_status": respData.BatchStatus,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawV3Notify POST /api/v3/withdraw/notify 文档 V3 转账结果回调(验签可选,解密后更新状态)
|
||||
func WithdrawV3Notify(c *gin.Context) {
|
||||
rawBody, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "body read error"})
|
||||
return
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(rawBody, &envelope); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "invalid json"})
|
||||
return
|
||||
}
|
||||
|
||||
resource, _ := envelope["resource"].(map[string]interface{})
|
||||
if resource == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "no resource"})
|
||||
return
|
||||
}
|
||||
|
||||
ciphertext, _ := resource["ciphertext"].(string)
|
||||
nonceStr, _ := resource["nonce"].(string)
|
||||
assoc, _ := resource["associated_data"].(string)
|
||||
if ciphertext == "" || nonceStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "missing ciphertext/nonce"})
|
||||
return
|
||||
}
|
||||
if assoc == "" {
|
||||
assoc = "mch_payment"
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
if cfg == nil || len(cfg.WechatAPIv3Key) != 32 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "config or apiv3 key invalid"})
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := transferv3.DecryptResourceJSON(ciphertext, nonceStr, assoc, []byte(cfg.WechatAPIv3Key))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "decrypt failed"})
|
||||
return
|
||||
}
|
||||
|
||||
outBillNo, _ := decrypted["out_bill_no"].(string)
|
||||
state, _ := decrypted["state"].(string)
|
||||
failReason, _ := decrypted["fail_reason"].(string)
|
||||
if outBillNo == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("detail_no = ?", outBillNo).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
return
|
||||
}
|
||||
cur := ""
|
||||
if w.Status != nil {
|
||||
cur = *w.Status
|
||||
}
|
||||
if cur != "processing" && cur != "pending_confirm" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
up := map[string]interface{}{"processed_at": now}
|
||||
switch state {
|
||||
case "SUCCESS":
|
||||
up["status"] = "success"
|
||||
case "FAIL", "CANCELLED":
|
||||
up["status"] = "failed"
|
||||
if failReason != "" {
|
||||
up["fail_reason"] = failReason
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&w).Updates(up).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "update failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
|
||||
}
|
||||
|
||||
// WithdrawV3Query POST /api/v3/withdraw/query 主动查询转账结果并更新(文档:按商户批次/明细单号查询)
|
||||
// body: { "withdrawal_id": "xxx" }
|
||||
func WithdrawV3Query(c *gin.Context) {
|
||||
var req struct {
|
||||
WithdrawalID string `json:"withdrawal_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawal_id"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var w model.Withdrawal
|
||||
if err := db.Where("id = ?", req.WithdrawalID).First(&w).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现记录不存在"})
|
||||
return
|
||||
}
|
||||
batchNo := ""
|
||||
detailNo := ""
|
||||
if w.BatchNo != nil {
|
||||
batchNo = *w.BatchNo
|
||||
}
|
||||
if w.DetailNo != nil {
|
||||
detailNo = *w.DetailNo
|
||||
}
|
||||
if batchNo == "" || detailNo == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未发起过微信转账"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := getTransferV3Client()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
respBody, statusCode, err := client.GetTransferDetail(batchNo, detailNo)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if statusCode != 200 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": string(respBody),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var detail struct {
|
||||
DetailStatus string `json:"detail_status"`
|
||||
FailReason string `json:"fail_reason"`
|
||||
}
|
||||
_ = json.Unmarshal(respBody, &detail)
|
||||
|
||||
now := time.Now()
|
||||
up := map[string]interface{}{"processed_at": now}
|
||||
switch strings.ToUpper(detail.DetailStatus) {
|
||||
case "SUCCESS":
|
||||
up["status"] = "success"
|
||||
case "FAIL":
|
||||
up["status"] = "failed"
|
||||
if detail.FailReason != "" {
|
||||
up["fail_reason"] = detail.FailReason
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "查询成功,状态未终态",
|
||||
"detail_status": detail.DetailStatus,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&w).Updates(up).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "已同步状态",
|
||||
"detail_status": detail.DetailStatus,
|
||||
})
|
||||
}
|
||||
27
soul-api/internal/middleware/admin_auth.go
Normal file
27
soul-api/internal/middleware/admin_auth.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/auth"
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminAuth 管理端鉴权:校验 JWT(Authorization: Bearer 或 Cookie admin_session),未登录返回 401
|
||||
func AdminAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cfg := config.Get()
|
||||
if cfg == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
token := auth.GetAdminJWTFromRequest(c.Request)
|
||||
if _, ok := auth.ParseAdminJWT(token, cfg.AdminSessionSecret); !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未授权访问,请先登录"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
65
soul-api/internal/middleware/ratelimit.go
Normal file
65
soul-api/internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RateLimiter 按 IP 的限流器
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
clients map[string]*rate.Limiter
|
||||
r rate.Limit
|
||||
b int
|
||||
}
|
||||
|
||||
// NewRateLimiter 创建限流中间件,r 每秒请求数,b 突发容量
|
||||
func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
clients: make(map[string]*rate.Limiter),
|
||||
r: r,
|
||||
b: b,
|
||||
}
|
||||
}
|
||||
|
||||
// getLimiter 获取或创建该 key 的 limiter
|
||||
func (rl *RateLimiter) getLimiter(key string) *rate.Limiter {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
if lim, ok := rl.clients[key]; ok {
|
||||
return lim
|
||||
}
|
||||
lim := rate.NewLimiter(rl.r, rl.b)
|
||||
rl.clients[key] = lim
|
||||
return lim
|
||||
}
|
||||
|
||||
// Middleware 返回 Gin 限流中间件(按客户端 IP)
|
||||
func (rl *RateLimiter) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.ClientIP()
|
||||
lim := rl.getLimiter(key)
|
||||
if !lim.Allow() {
|
||||
c.AbortWithStatus(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup 定期清理过期 limiter(可选,避免 map 无限增长)
|
||||
func (rl *RateLimiter) Cleanup(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
rl.mu.Lock()
|
||||
rl.clients = make(map[string]*rate.Limiter)
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
25
soul-api/internal/middleware/secure.go
Normal file
25
soul-api/internal/middleware/secure.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/unrolled/secure"
|
||||
)
|
||||
|
||||
// Secure 安全响应头中间件
|
||||
func Secure() gin.HandlerFunc {
|
||||
s := secure.New(secure.Options{
|
||||
FrameDeny: true,
|
||||
ContentTypeNosniff: true,
|
||||
BrowserXssFilter: true,
|
||||
ContentSecurityPolicy: "frame-ancestors 'none'",
|
||||
ReferrerPolicy: "no-referrer",
|
||||
})
|
||||
return func(c *gin.Context) {
|
||||
err := s.Process(c.Writer, c.Request)
|
||||
if err != nil {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
1
soul-api/internal/model/README.txt
Normal file
1
soul-api/internal/model/README.txt
Normal file
@@ -0,0 +1 @@
|
||||
在此目录放置 GORM 模型与请求/响应结构体,例如 User、Order、Withdrawal、Config 等。
|
||||
24
soul-api/internal/model/chapter.go
Normal file
24
soul-api/internal/model/chapter.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Chapter 对应表 chapters(mid 为自增主键,id 保留业务标识如 1.1、preface)
|
||||
type Chapter struct {
|
||||
MID int `gorm:"column:mid;primaryKey;autoIncrement" json:"mid"`
|
||||
ID string `gorm:"column:id;size:20;uniqueIndex" json:"id"`
|
||||
PartID string `gorm:"column:part_id;size:20" json:"partId"`
|
||||
PartTitle string `gorm:"column:part_title;size:100" json:"partTitle"`
|
||||
ChapterID string `gorm:"column:chapter_id;size:20" json:"chapterId"`
|
||||
ChapterTitle string `gorm:"column:chapter_title;size:200" json:"chapterTitle"`
|
||||
SectionTitle string `gorm:"column:section_title;size:200" json:"sectionTitle"`
|
||||
Content string `gorm:"column:content;type:longtext" json:"content,omitempty"`
|
||||
WordCount *int `gorm:"column:word_count" json:"wordCount,omitempty"`
|
||||
IsFree *bool `gorm:"column:is_free" json:"isFree,omitempty"`
|
||||
Price *float64 `gorm:"column:price;type:decimal(10,2)" json:"price,omitempty"`
|
||||
SortOrder *int `gorm:"column:sort_order" json:"sortOrder,omitempty"`
|
||||
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Chapter) TableName() string { return "chapters" }
|
||||
18
soul-api/internal/model/match_record.go
Normal file
18
soul-api/internal/model/match_record.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// MatchRecord 匹配记录,每次用户成功匹配时写入
|
||||
type MatchRecord struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
UserID string `gorm:"column:user_id;index;size:50;not null" json:"userId"`
|
||||
MatchType string `gorm:"column:match_type;index;size:50" json:"matchType"`
|
||||
Phone *string `gorm:"column:phone;size:20" json:"phone"`
|
||||
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId"`
|
||||
MatchedUserID string `gorm:"column:matched_user_id;index;size:50" json:"matchedUserId"`
|
||||
MatchScore *int `gorm:"column:match_score" json:"matchScore"`
|
||||
Status *string `gorm:"column:status;size:20" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (MatchRecord) TableName() string { return "match_records" }
|
||||
24
soul-api/internal/model/order.go
Normal file
24
soul-api/internal/model/order.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Order 对应表 orders,JSON 输出与现网接口 1:1(小写驼峰)
|
||||
type Order struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
OrderSN string `gorm:"column:order_sn;uniqueIndex;size:50" json:"orderSn"`
|
||||
UserID string `gorm:"column:user_id;size:50" json:"userId"`
|
||||
OpenID string `gorm:"column:open_id;size:100" json:"openId"`
|
||||
ProductType string `gorm:"column:product_type;size:50" json:"productType"`
|
||||
ProductID *string `gorm:"column:product_id;size:50" json:"productId,omitempty"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
|
||||
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
|
||||
TransactionID *string `gorm:"column:transaction_id;size:100" json:"transactionId,omitempty"`
|
||||
PayTime *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`
|
||||
ReferralCode *string `gorm:"column:referral_code;size:255" json:"referralCode,omitempty"`
|
||||
ReferrerID *string `gorm:"column:referrer_id;size:255" json:"referrerId,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Order) TableName() string { return "orders" }
|
||||
20
soul-api/internal/model/reading_progress.go
Normal file
20
soul-api/internal/model/reading_progress.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ReadingProgress 对应表 reading_progress
|
||||
type ReadingProgress struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
UserID string `gorm:"column:user_id;size:50"`
|
||||
SectionID string `gorm:"column:section_id;size:50"`
|
||||
Progress int `gorm:"column:progress"`
|
||||
Duration int `gorm:"column:duration"`
|
||||
Status string `gorm:"column:status;size:20"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at"`
|
||||
FirstOpenAt *time.Time `gorm:"column:first_open_at"`
|
||||
LastOpenAt *time.Time `gorm:"column:last_open_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (ReadingProgress) TableName() string { return "reading_progress" }
|
||||
22
soul-api/internal/model/referral_binding.go
Normal file
22
soul-api/internal/model/referral_binding.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ReferralBinding 对应表 referral_bindings
|
||||
type ReferralBinding struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50"`
|
||||
ReferrerID string `gorm:"column:referrer_id;size:50"`
|
||||
RefereeID string `gorm:"column:referee_id;size:50"`
|
||||
ReferralCode string `gorm:"column:referral_code;size:20"`
|
||||
Status *string `gorm:"column:status;size:20"`
|
||||
BindingDate time.Time `gorm:"column:binding_date"`
|
||||
ExpiryDate time.Time `gorm:"column:expiry_date"`
|
||||
CommissionAmount *float64 `gorm:"column:commission_amount;type:decimal(10,2)"`
|
||||
PurchaseCount *int `gorm:"column:purchase_count"` // 购买次数
|
||||
TotalCommission *float64 `gorm:"column:total_commission;type:decimal(10,2)"` // 累计佣金
|
||||
LastPurchaseDate *time.Time `gorm:"column:last_purchase_date"` // 最后购买日期
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (ReferralBinding) TableName() string { return "referral_bindings" }
|
||||
16
soul-api/internal/model/referral_visit.go
Normal file
16
soul-api/internal/model/referral_visit.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ReferralVisit 对应表 referral_visits
|
||||
type ReferralVisit struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
ReferrerID string `gorm:"column:referrer_id;size:50"`
|
||||
VisitorID *string `gorm:"column:visitor_id;size:50"`
|
||||
VisitorOpenID *string `gorm:"column:visitor_openid;size:100"`
|
||||
Source *string `gorm:"column:source;size:50"`
|
||||
Page *string `gorm:"column:page;size:200"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (ReferralVisit) TableName() string { return "referral_visits" }
|
||||
35
soul-api/internal/model/system_config.go
Normal file
35
soul-api/internal/model/system_config.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigValue 存 system_config.config_value(JSON 列,可为 object 或 array)
|
||||
type ConfigValue []byte
|
||||
|
||||
func (c ConfigValue) Value() (driver.Value, error) { return []byte(c), nil }
|
||||
func (c *ConfigValue) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*c = nil
|
||||
return nil
|
||||
}
|
||||
b, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
*c = append((*c)[0:0], b...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SystemConfig 对应表 system_config,JSON 输出与现网 1:1(小写驼峰)
|
||||
type SystemConfig struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
ConfigKey string `gorm:"column:config_key;uniqueIndex;size:100" json:"configKey"`
|
||||
ConfigValue ConfigValue `gorm:"column:config_value;type:json" json:"configValue"`
|
||||
Description *string `gorm:"column:description;size:200" json:"description,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SystemConfig) TableName() string { return "system_config" }
|
||||
30
soul-api/internal/model/user.go
Normal file
30
soul-api/internal/model/user.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User 对应表 users,JSON 输出与现网接口 1:1(小写驼峰)
|
||||
type User struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
|
||||
SessionKey *string `gorm:"column:session_key;size:200" json:"-"` // 微信 session_key,不输出到 JSON
|
||||
Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
|
||||
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
|
||||
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
|
||||
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
|
||||
ReferralCode *string `gorm:"column:referral_code;size:20" json:"referralCode,omitempty"`
|
||||
HasFullBook *bool `gorm:"column:has_full_book" json:"hasFullBook,omitempty"`
|
||||
PurchasedSections *string `gorm:"column:purchased_sections;type:json" json:"-"` // 内部字段,实际数据从 orders 表查
|
||||
Earnings *float64 `gorm:"column:earnings;type:decimal(10,2)" json:"earnings,omitempty"`
|
||||
PendingEarnings *float64 `gorm:"column:pending_earnings;type:decimal(10,2)" json:"pendingEarnings,omitempty"`
|
||||
ReferralCount *int `gorm:"column:referral_count" json:"referralCount,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
IsAdmin *bool `gorm:"column:is_admin" json:"isAdmin,omitempty"`
|
||||
WithdrawnEarnings *float64 `gorm:"column:withdrawn_earnings;type:decimal(10,2)" json:"withdrawnEarnings,omitempty"`
|
||||
Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
|
||||
|
||||
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
|
||||
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
20
soul-api/internal/model/user_address.go
Normal file
20
soul-api/internal/model/user_address.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserAddress 对应表 user_addresses
|
||||
type UserAddress struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50"`
|
||||
UserID string `gorm:"column:user_id;size:50"`
|
||||
Name string `gorm:"column:name;size:50"`
|
||||
Phone string `gorm:"column:phone;size:20"`
|
||||
Province string `gorm:"column:province;size:50"`
|
||||
City string `gorm:"column:city;size:50"`
|
||||
District string `gorm:"column:district;size:50"`
|
||||
Detail string `gorm:"column:detail;size:200"`
|
||||
IsDefault bool `gorm:"column:is_default"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (UserAddress) TableName() string { return "user_addresses" }
|
||||
16
soul-api/internal/model/user_track.go
Normal file
16
soul-api/internal/model/user_track.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserTrack 对应表 user_tracks
|
||||
type UserTrack struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50"`
|
||||
UserID string `gorm:"column:user_id;size:100"`
|
||||
Action string `gorm:"column:action;size:50"`
|
||||
ChapterID *string `gorm:"column:chapter_id;size:100"`
|
||||
Target *string `gorm:"column:target;size:200"`
|
||||
ExtraData []byte `gorm:"column:extra_data;type:json"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func (UserTrack) TableName() string { return "user_tracks" }
|
||||
21
soul-api/internal/model/wechat_callback_log.go
Normal file
21
soul-api/internal/model/wechat_callback_log.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// WechatCallbackLog 微信回调日志(转账结果通知、支付通知等)
|
||||
// 表名 wechat_callback_logs
|
||||
type WechatCallbackLog struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
CallbackType string `gorm:"column:callback_type;size:32;index" json:"callbackType"` // transfer | pay
|
||||
OutBatchNo string `gorm:"column:out_batch_no;size:64;index" json:"outBatchNo"` // 商家批次单号
|
||||
OutDetailNo string `gorm:"column:out_detail_no;size:64;index" json:"outDetailNo"` // 商家明细单号(转账即 out_bill_no)
|
||||
TransferBillNo string `gorm:"column:transfer_bill_no;size:64" json:"transferBillNo"` // 微信转账单号
|
||||
State string `gorm:"column:state;size:32" json:"state"` // SUCCESS | FAIL | CANCELLED 等
|
||||
FailReason string `gorm:"column:fail_reason;size:500" json:"failReason"`
|
||||
HandlerResult string `gorm:"column:handler_result;size:20" json:"handlerResult"` // success | fail
|
||||
HandlerError string `gorm:"column:handler_error;size:1000" json:"handlerError"` // 业务处理错误信息
|
||||
RequestBody string `gorm:"column:request_body;type:text" json:"-"` // 原始/解密后 body(可选,不输出 JSON)
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (WechatCallbackLog) TableName() string { return "wechat_callback_logs" }
|
||||
24
soul-api/internal/model/withdrawal.go
Normal file
24
soul-api/internal/model/withdrawal.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Withdrawal 对应表 withdrawals
|
||||
type Withdrawal struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
UserID string `gorm:"column:user_id;size:50" json:"userId"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
Status *string `gorm:"column:status;size:20" json:"status"`
|
||||
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId"`
|
||||
WechatOpenid *string `gorm:"column:wechat_openid;size:100" json:"wechatOpenid"`
|
||||
BatchNo *string `gorm:"column:batch_no;size:100" json:"batchNo,omitempty"` // 商家批次单号
|
||||
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"` // 失败原因
|
||||
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"`
|
||||
}
|
||||
|
||||
func (Withdrawal) TableName() string { return "withdrawals" }
|
||||
1
soul-api/internal/repository/README.txt
Normal file
1
soul-api/internal/repository/README.txt
Normal file
@@ -0,0 +1 @@
|
||||
在此目录放置数据库访问层,供 service 调用,例如 UserRepo、OrderRepo、ConfigRepo 等。
|
||||
283
soul-api/internal/router/router.go
Normal file
283
soul-api/internal/router/router.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/handler"
|
||||
"soul-api/internal/middleware"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Setup 创建并配置 Gin 引擎,路径与 app/api 一致
|
||||
func Setup(cfg *config.Config) *gin.Engine {
|
||||
gin.SetMode(cfg.Mode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.Logger())
|
||||
_ = r.SetTrustedProxies(cfg.TrustedProxies)
|
||||
|
||||
r.Use(middleware.Secure())
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: cfg.CORSOrigins,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 86400,
|
||||
}))
|
||||
rateLimiter := middleware.NewRateLimiter(100, 200)
|
||||
r.Use(rateLimiter.Middleware())
|
||||
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// ----- 管理端 -----
|
||||
api.GET("/admin", handler.AdminCheck)
|
||||
api.POST("/admin", handler.AdminLogin)
|
||||
api.POST("/admin/logout", handler.AdminLogout)
|
||||
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.AdminAuth())
|
||||
{
|
||||
admin.GET("/chapters", handler.AdminChaptersList)
|
||||
admin.POST("/chapters", handler.AdminChaptersAction)
|
||||
admin.PUT("/chapters", handler.AdminChaptersAction)
|
||||
admin.DELETE("/chapters", handler.AdminChaptersAction)
|
||||
admin.GET("/content", handler.AdminContent)
|
||||
admin.POST("/content", handler.AdminContent)
|
||||
admin.PUT("/content", handler.AdminContent)
|
||||
admin.DELETE("/content", handler.AdminContent)
|
||||
admin.GET("/distribution/overview", handler.AdminDistributionOverview)
|
||||
admin.GET("/payment", handler.AdminPayment)
|
||||
admin.POST("/payment", handler.AdminPayment)
|
||||
admin.PUT("/payment", handler.AdminPayment)
|
||||
admin.DELETE("/payment", handler.AdminPayment)
|
||||
admin.GET("/referral", handler.AdminReferral)
|
||||
admin.POST("/referral", handler.AdminReferral)
|
||||
admin.PUT("/referral", handler.AdminReferral)
|
||||
admin.DELETE("/referral", handler.AdminReferral)
|
||||
admin.GET("/withdrawals", handler.AdminWithdrawalsList)
|
||||
admin.PUT("/withdrawals", handler.AdminWithdrawalsAction)
|
||||
admin.POST("/withdrawals/sync", handler.AdminWithdrawalsSync)
|
||||
admin.GET("/withdraw-test", handler.AdminWithdrawTest)
|
||||
admin.POST("/withdraw-test", handler.AdminWithdrawTest)
|
||||
admin.GET("/settings", handler.AdminSettingsGet)
|
||||
admin.POST("/settings", handler.AdminSettingsPost)
|
||||
admin.GET("/referral-settings", handler.AdminReferralSettingsGet)
|
||||
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
|
||||
}
|
||||
|
||||
// ----- 鉴权 -----
|
||||
api.POST("/auth/login", handler.AuthLogin)
|
||||
api.POST("/auth/reset-password", handler.AuthResetPassword)
|
||||
|
||||
// ----- 书籍/章节 -----
|
||||
api.GET("/book/all-chapters", handler.BookAllChapters)
|
||||
api.GET("/book/chapter/:id", handler.BookChapterByID)
|
||||
api.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
|
||||
api.GET("/book/chapters", handler.BookChapters)
|
||||
api.POST("/book/chapters", handler.BookChapters)
|
||||
api.PUT("/book/chapters", handler.BookChapters)
|
||||
api.DELETE("/book/chapters", handler.BookChapters)
|
||||
api.GET("/book/hot", handler.BookHot)
|
||||
api.GET("/book/latest-chapters", handler.BookLatestChapters)
|
||||
api.GET("/book/search", handler.BookSearch)
|
||||
api.GET("/book/stats", handler.BookStats)
|
||||
api.GET("/book/sync", handler.BookSync)
|
||||
api.POST("/book/sync", handler.BookSync)
|
||||
|
||||
// ----- CKB -----
|
||||
api.POST("/ckb/join", handler.CKBJoin)
|
||||
api.POST("/ckb/match", handler.CKBMatch)
|
||||
api.GET("/ckb/sync", handler.CKBSync)
|
||||
api.POST("/ckb/sync", handler.CKBSync)
|
||||
|
||||
// ----- 配置 -----
|
||||
api.GET("/config", handler.GetConfig)
|
||||
// 小程序用:GET /api/db/config 返回 freeChapters、prices(不鉴权,先于 db 组匹配)
|
||||
api.GET("/db/config", handler.GetPublicDBConfig)
|
||||
|
||||
// ----- 内容 -----
|
||||
api.GET("/content", handler.ContentGet)
|
||||
|
||||
// ----- 定时任务 -----
|
||||
api.GET("/cron/sync-orders", handler.CronSyncOrders)
|
||||
api.POST("/cron/sync-orders", handler.CronSyncOrders)
|
||||
api.GET("/cron/unbind-expired", handler.CronUnbindExpired)
|
||||
api.POST("/cron/unbind-expired", handler.CronUnbindExpired)
|
||||
|
||||
// ----- 数据库(管理端) -----
|
||||
db := api.Group("/db")
|
||||
db.Use(middleware.AdminAuth())
|
||||
{
|
||||
db.GET("/book", handler.DBBookAction)
|
||||
db.POST("/book", handler.DBBookAction)
|
||||
db.PUT("/book", handler.DBBookAction)
|
||||
db.DELETE("/book", handler.DBBookDelete)
|
||||
db.GET("/chapters", handler.DBChapters)
|
||||
db.POST("/chapters", handler.DBChapters)
|
||||
db.GET("/config/full", handler.DBConfigGet) // 管理端拉全量配置;GET /api/db/config 已用于公开接口 GetPublicDBConfig
|
||||
db.POST("/config", handler.DBConfigPost)
|
||||
db.DELETE("/config", handler.DBConfigDelete)
|
||||
db.GET("/distribution", handler.DBDistribution)
|
||||
db.GET("/init", handler.DBInitGet)
|
||||
db.POST("/init", handler.DBInit)
|
||||
db.GET("/migrate", handler.DBMigrateGet)
|
||||
db.POST("/migrate", handler.DBMigratePost)
|
||||
db.GET("/users", handler.DBUsersList)
|
||||
db.POST("/users", handler.DBUsersAction)
|
||||
db.PUT("/users", handler.DBUsersAction)
|
||||
db.DELETE("/users", handler.DBUsersDelete)
|
||||
db.GET("/users/referrals", handler.DBUsersReferrals)
|
||||
}
|
||||
|
||||
// ----- 分销 -----
|
||||
api.GET("/distribution", handler.DistributionGet)
|
||||
api.POST("/distribution", handler.DistributionGet)
|
||||
api.PUT("/distribution", handler.DistributionGet)
|
||||
api.GET("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
|
||||
api.POST("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
|
||||
api.DELETE("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig)
|
||||
api.GET("/distribution/messages", handler.DistributionMessages)
|
||||
api.POST("/distribution/messages", handler.DistributionMessages)
|
||||
|
||||
// ----- 文档生成 -----
|
||||
api.POST("/documentation/generate", handler.DocGenerate)
|
||||
|
||||
// ----- 找伙伴 -----
|
||||
api.GET("/match/config", handler.MatchConfigGet)
|
||||
api.POST("/match/config", handler.MatchConfigPost)
|
||||
api.POST("/match/users", handler.MatchUsers)
|
||||
|
||||
// ----- 菜单 -----
|
||||
api.GET("/menu", handler.MenuGet)
|
||||
|
||||
// ----- 订单 -----
|
||||
api.GET("/orders", handler.OrdersList)
|
||||
|
||||
// ----- 支付 -----
|
||||
api.POST("/payment/alipay/notify", handler.PaymentAlipayNotify)
|
||||
api.POST("/payment/callback", handler.PaymentCallback)
|
||||
api.POST("/payment/create-order", handler.PaymentCreateOrder)
|
||||
api.GET("/payment/methods", handler.PaymentMethods)
|
||||
api.GET("/payment/query", handler.PaymentQuery)
|
||||
api.GET("/payment/status/:orderSn", handler.PaymentStatusOrderSn)
|
||||
api.POST("/payment/verify", handler.PaymentVerify)
|
||||
api.POST("/payment/wechat/notify", handler.PaymentWechatNotify)
|
||||
api.GET("/payment/wechat/transfer/notify", handler.PaymentWechatTransferNotify)
|
||||
api.POST("/payment/wechat/transfer/notify", handler.PaymentWechatTransferNotify)
|
||||
|
||||
// ----- 推荐 -----
|
||||
api.POST("/referral/bind", handler.ReferralBind)
|
||||
api.GET("/referral/data", handler.ReferralData)
|
||||
api.POST("/referral/visit", handler.ReferralVisit)
|
||||
|
||||
// ----- 搜索 -----
|
||||
api.GET("/search", handler.SearchGet)
|
||||
|
||||
// ----- 同步 -----
|
||||
api.GET("/sync", handler.SyncGet)
|
||||
api.POST("/sync", handler.SyncPost)
|
||||
api.PUT("/sync", handler.SyncPut)
|
||||
|
||||
// ----- 上传 -----
|
||||
api.POST("/upload", handler.UploadPost)
|
||||
api.DELETE("/upload", handler.UploadDelete)
|
||||
|
||||
// ----- 用户 -----
|
||||
api.GET("/user/addresses", handler.UserAddressesGet)
|
||||
api.POST("/user/addresses", handler.UserAddressesPost)
|
||||
api.GET("/user/addresses/:id", handler.UserAddressesByID)
|
||||
api.PUT("/user/addresses/:id", handler.UserAddressesByID)
|
||||
api.DELETE("/user/addresses/:id", handler.UserAddressesByID)
|
||||
api.GET("/user/check-purchased", handler.UserCheckPurchased)
|
||||
api.GET("/user/profile", handler.UserProfileGet)
|
||||
api.POST("/user/profile", handler.UserProfilePost)
|
||||
api.GET("/user/purchase-status", handler.UserPurchaseStatus)
|
||||
api.GET("/user/reading-progress", handler.UserReadingProgressGet)
|
||||
api.POST("/user/reading-progress", handler.UserReadingProgressPost)
|
||||
api.GET("/user/track", handler.UserTrackGet)
|
||||
api.POST("/user/track", handler.UserTrackPost)
|
||||
api.POST("/user/update", handler.UserUpdate)
|
||||
|
||||
// ----- 微信登录 -----
|
||||
api.POST("/wechat/login", handler.WechatLogin)
|
||||
api.POST("/wechat/phone-login", handler.WechatPhoneLogin)
|
||||
|
||||
// ----- 小程序组(所有小程序端接口统一在 /api/miniprogram 下) -----
|
||||
miniprogram := api.Group("/miniprogram")
|
||||
{
|
||||
miniprogram.GET("/config", handler.GetPublicDBConfig)
|
||||
miniprogram.POST("/login", handler.MiniprogramLogin)
|
||||
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
|
||||
miniprogram.POST("/phone", handler.MiniprogramPhone)
|
||||
miniprogram.GET("/pay", handler.MiniprogramPay)
|
||||
miniprogram.POST("/pay", handler.MiniprogramPay)
|
||||
miniprogram.POST("/pay/notify", handler.MiniprogramPayNotify) // 微信支付回调,URL 需在商户平台配置
|
||||
miniprogram.POST("/qrcode", handler.MiniprogramQrcode)
|
||||
miniprogram.GET("/qrcode/image", handler.MiniprogramQrcodeImage)
|
||||
miniprogram.GET("/book/all-chapters", handler.BookAllChapters)
|
||||
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
|
||||
miniprogram.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
|
||||
miniprogram.GET("/book/hot", handler.BookHot)
|
||||
miniprogram.GET("/book/search", handler.BookSearch)
|
||||
miniprogram.GET("/book/stats", handler.BookStats)
|
||||
miniprogram.POST("/referral/visit", handler.ReferralVisit)
|
||||
miniprogram.POST("/referral/bind", handler.ReferralBind)
|
||||
miniprogram.GET("/referral/data", handler.ReferralData)
|
||||
miniprogram.GET("/earnings", handler.MyEarnings)
|
||||
miniprogram.GET("/match/config", handler.MatchConfigGet)
|
||||
miniprogram.POST("/match/users", handler.MatchUsers)
|
||||
miniprogram.POST("/ckb/join", handler.CKBJoin)
|
||||
miniprogram.POST("/ckb/match", handler.CKBMatch)
|
||||
miniprogram.POST("/upload", handler.UploadPost)
|
||||
miniprogram.DELETE("/upload", handler.UploadDelete)
|
||||
miniprogram.GET("/user/addresses", handler.UserAddressesGet)
|
||||
miniprogram.POST("/user/addresses", handler.UserAddressesPost)
|
||||
miniprogram.GET("/user/addresses/:id", handler.UserAddressesByID)
|
||||
miniprogram.PUT("/user/addresses/:id", handler.UserAddressesByID)
|
||||
miniprogram.DELETE("/user/addresses/:id", handler.UserAddressesByID)
|
||||
miniprogram.GET("/user/check-purchased", handler.UserCheckPurchased)
|
||||
miniprogram.GET("/user/profile", handler.UserProfileGet)
|
||||
miniprogram.POST("/user/profile", handler.UserProfilePost)
|
||||
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)
|
||||
miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet)
|
||||
miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost)
|
||||
miniprogram.POST("/user/update", handler.UserUpdate)
|
||||
miniprogram.POST("/withdraw", handler.WithdrawPost)
|
||||
miniprogram.GET("/withdraw/records", handler.WithdrawRecords)
|
||||
miniprogram.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
|
||||
miniprogram.POST("/withdraw/confirm-received", handler.WithdrawConfirmReceived)
|
||||
miniprogram.GET("/withdraw/confirm-info", handler.WithdrawConfirmInfo)
|
||||
}
|
||||
|
||||
// ----- 提现 -----
|
||||
api.POST("/withdraw", handler.WithdrawPost)
|
||||
api.GET("/withdraw/records", handler.WithdrawRecords)
|
||||
api.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm)
|
||||
// 提现测试(固定用户 1 元,无需 admin 鉴权,仅用于脚本/本地调试)
|
||||
api.GET("/withdraw-test", handler.AdminWithdrawTest)
|
||||
api.POST("/withdraw-test", handler.AdminWithdrawTest)
|
||||
|
||||
// ----- 提现 V3(独立实现,依文档 提现功能完整技术文档.md) -----
|
||||
api.POST("/v3/withdraw/initiate", handler.WithdrawV3Initiate)
|
||||
api.POST("/v3/withdraw/notify", handler.WithdrawV3Notify)
|
||||
api.POST("/v3/withdraw/query", handler.WithdrawV3Query)
|
||||
}
|
||||
|
||||
// 根路径不返回任何页面(仅 204)
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.Status(204)
|
||||
})
|
||||
|
||||
// 健康检查:返回状态与版本号(版本号从 .env 的 APP_VERSION 读取,打包/上传前写入)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"version": cfg.Version,
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
1
soul-api/internal/service/README.txt
Normal file
1
soul-api/internal/service/README.txt
Normal file
@@ -0,0 +1 @@
|
||||
在此目录放置业务逻辑,供 handler 调用,例如 AdminService、UserService、PaymentService 等。
|
||||
446
soul-api/internal/wechat/miniprogram.go
Normal file
446
soul-api/internal/wechat/miniprogram.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/ArtisanCloud/PowerLibs/v3/object"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/power"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
|
||||
notifyrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/notify/request"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request"
|
||||
subrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/basicService/subscribeMessage/request"
|
||||
)
|
||||
|
||||
var (
|
||||
miniProgramApp *miniProgram.MiniProgram
|
||||
paymentApp *payment.Payment
|
||||
cfg *config.Config
|
||||
)
|
||||
|
||||
// resolveCertPaths 若证书/私钥路径为 URL 则下载到临时文件并返回本地路径
|
||||
func resolveCertPaths(c *config.Config) (certPath, keyPath string, err error) {
|
||||
certPath = c.WechatCertPath
|
||||
keyPath = c.WechatKeyPath
|
||||
if certPath == "" || keyPath == "" {
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
if strings.HasPrefix(keyPath, "http://") || strings.HasPrefix(keyPath, "https://") {
|
||||
dir, e := os.MkdirTemp("", "wechat_cert_*")
|
||||
if e != nil {
|
||||
return "", "", fmt.Errorf("创建临时目录失败: %w", e)
|
||||
}
|
||||
keyPath, e = downloadToFile(keyPath, filepath.Join(dir, "apiclient_key.pem"))
|
||||
if e != nil {
|
||||
return "", "", e
|
||||
}
|
||||
if strings.HasPrefix(certPath, "http://") || strings.HasPrefix(certPath, "https://") {
|
||||
certPath, e = downloadToFile(certPath, filepath.Join(dir, "apiclient_cert.pem"))
|
||||
if e != nil {
|
||||
return "", "", e
|
||||
}
|
||||
} else {
|
||||
// cert 是本地路径,只下载了 key
|
||||
certPath = c.WechatCertPath
|
||||
}
|
||||
}
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
|
||||
func downloadToFile(url, filePath string) (string, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("下载文件失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("下载返回状态: %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取内容失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, data, 0600); err != nil {
|
||||
return "", fmt.Errorf("写入临时文件失败: %w", err)
|
||||
}
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// Init 初始化微信客户端(小程序 + 支付 v3 + 转账均使用 PowerWeChat)
|
||||
func Init(c *config.Config) error {
|
||||
cfg = c
|
||||
|
||||
var err error
|
||||
miniProgramApp, err = miniProgram.NewMiniProgram(&miniProgram.UserConfig{
|
||||
AppID: cfg.WechatAppID,
|
||||
Secret: cfg.WechatAppSecret,
|
||||
HttpDebug: cfg.Mode == "debug",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化小程序失败: %w", err)
|
||||
}
|
||||
|
||||
certPath, keyPath, err := resolveCertPaths(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析证书路径: %w", err)
|
||||
}
|
||||
paymentConfig := &payment.UserConfig{
|
||||
AppID: cfg.WechatAppID,
|
||||
MchID: cfg.WechatMchID,
|
||||
MchApiV3Key: cfg.WechatAPIv3Key,
|
||||
Key: cfg.WechatMchKey,
|
||||
CertPath: certPath,
|
||||
KeyPath: keyPath,
|
||||
SerialNo: cfg.WechatSerialNo,
|
||||
NotifyURL: cfg.WechatNotifyURL,
|
||||
HttpDebug: cfg.Mode == "debug",
|
||||
}
|
||||
paymentApp, err = payment.NewPayment(paymentConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化支付(v3)失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Code2Session 小程序登录
|
||||
func Code2Session(code string) (openID, sessionKey, unionID string, err error) {
|
||||
ctx := context.Background()
|
||||
response, err := miniProgramApp.Auth.Session(ctx, code)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("code2Session失败: %w", err)
|
||||
}
|
||||
|
||||
// PowerWeChat v3 返回的是 *object.HashMap
|
||||
if response.ErrCode != 0 {
|
||||
return "", "", "", fmt.Errorf("微信返回错误: %d - %s", response.ErrCode, response.ErrMsg)
|
||||
}
|
||||
|
||||
openID = response.OpenID
|
||||
sessionKey = response.SessionKey
|
||||
unionID = response.UnionID
|
||||
|
||||
return openID, sessionKey, unionID, nil
|
||||
}
|
||||
|
||||
// GetAccessToken 获取小程序 access_token(用于手机号解密、小程序码生成)
|
||||
func GetAccessToken() (string, error) {
|
||||
ctx := context.Background()
|
||||
tokenResp, err := miniProgramApp.AccessToken.GetToken(ctx, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取access_token失败: %w", err)
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// GetPhoneNumber 获取用户手机号
|
||||
func GetPhoneNumber(code string) (phoneNumber, countryCode string, err error) {
|
||||
token, err := GetAccessToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s", token)
|
||||
|
||||
reqBody := map[string]string{"code": code}
|
||||
jsonData, _ := json.Marshal(reqBody)
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
PhoneInfo struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
PurePhoneNumber string `json:"purePhoneNumber"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
} `json:"phone_info"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", "", fmt.Errorf("解析微信返回失败: %w", err)
|
||||
}
|
||||
|
||||
if result.ErrCode != 0 {
|
||||
return "", "", fmt.Errorf("微信返回错误: %d - %s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
|
||||
phoneNumber = result.PhoneInfo.PhoneNumber
|
||||
if phoneNumber == "" {
|
||||
phoneNumber = result.PhoneInfo.PurePhoneNumber
|
||||
}
|
||||
countryCode = result.PhoneInfo.CountryCode
|
||||
if countryCode == "" {
|
||||
countryCode = "86"
|
||||
}
|
||||
|
||||
return phoneNumber, countryCode, nil
|
||||
}
|
||||
|
||||
// GenerateMiniProgramCode 生成小程序码
|
||||
func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
|
||||
token, err := GetAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=%s", token)
|
||||
|
||||
if width <= 0 || width > 430 {
|
||||
width = 280
|
||||
}
|
||||
if page == "" {
|
||||
page = "pages/index/index"
|
||||
}
|
||||
// 微信建议 scene 仅含英文字母、数字;& 和 = 可能导致异常,将 & 转为 _ 再传给微信
|
||||
scene = strings.ReplaceAll(scene, "&", "_")
|
||||
if len(scene) > 32 {
|
||||
scene = scene[:32]
|
||||
}
|
||||
|
||||
// 仅 developer/trial 生成对应版本码,其余一律正式版,避免扫码提示「开发版已过期」
|
||||
envVersion := "release"
|
||||
if cfg != nil {
|
||||
state := strings.TrimSpace(cfg.WechatMiniProgramState)
|
||||
switch state {
|
||||
case "developer":
|
||||
envVersion = "develop"
|
||||
case "trial":
|
||||
envVersion = "trial"
|
||||
default:
|
||||
envVersion = "release"
|
||||
}
|
||||
}
|
||||
if cfg != nil {
|
||||
fmt.Printf("[GenerateMiniProgramCode] env_version=%s (WechatMiniProgramState=%q)\n", envVersion, cfg.WechatMiniProgramState)
|
||||
}
|
||||
reqBody := map[string]interface{}{
|
||||
"scene": scene,
|
||||
"page": page,
|
||||
"width": width,
|
||||
"auto_color": false,
|
||||
"line_color": map[string]int{"r": 0, "g": 206, "b": 209},
|
||||
"is_hyaline": false,
|
||||
"env_version": envVersion,
|
||||
}
|
||||
jsonData, _ := json.Marshal(reqBody)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// 无论 Content-Type,先尝试按 JSON 解析:微信错误时返回小体积 JSON,否则会误报「图片数据异常(太小)」
|
||||
var errResult struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if json.Unmarshal(body, &errResult) == nil && errResult.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("生成小程序码失败: %d - %s", errResult.ErrCode, errResult.ErrMsg)
|
||||
}
|
||||
if len(body) < 1000 {
|
||||
return nil, fmt.Errorf("返回的图片数据异常(太小),可能未发布对应版本或参数错误")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// GetPayNotifyURL 返回支付回调地址(与商户平台配置一致)
|
||||
func GetPayNotifyURL() string {
|
||||
if cfg != nil && cfg.WechatNotifyURL != "" {
|
||||
return cfg.WechatNotifyURL
|
||||
}
|
||||
return "https://soul.quwanzhi.com/api/miniprogram/pay/notify"
|
||||
}
|
||||
|
||||
// PayJSAPIOrder 微信支付 v3 小程序 JSAPI 统一下单,返回 prepay_id
|
||||
func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int, description, attach string) (prepayID string, err error) {
|
||||
if paymentApp == nil {
|
||||
return "", fmt.Errorf("支付未初始化")
|
||||
}
|
||||
req := &request.RequestJSAPIPrepay{
|
||||
PrepayBase: request.PrepayBase{
|
||||
AppID: cfg.WechatAppID,
|
||||
MchID: cfg.WechatMchID,
|
||||
NotifyUrl: GetPayNotifyURL(),
|
||||
},
|
||||
Description: description,
|
||||
OutTradeNo: orderSn,
|
||||
Amount: &request.JSAPIAmount{
|
||||
Total: amountCents,
|
||||
Currency: "CNY",
|
||||
},
|
||||
Payer: &request.JSAPIPayer{OpenID: openID},
|
||||
Attach: attach,
|
||||
}
|
||||
res, err := paymentApp.Order.JSAPITransaction(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res == nil || res.PrepayID == "" {
|
||||
return "", fmt.Errorf("微信返回 prepay_id 为空")
|
||||
}
|
||||
return res.PrepayID, nil
|
||||
}
|
||||
|
||||
// GetJSAPIPayParams 根据 prepay_id 生成小程序 wx.requestPayment 所需参数(v3 签名)
|
||||
func GetJSAPIPayParams(prepayID string) (map[string]string, error) {
|
||||
if paymentApp == nil {
|
||||
return nil, fmt.Errorf("支付未初始化")
|
||||
}
|
||||
cfgMap, err := paymentApp.JSSDK.BridgeConfig(prepayID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string)
|
||||
if m, ok := cfgMap.(*object.StringMap); ok && m != nil {
|
||||
for k, v := range *m {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && cfgMap != nil {
|
||||
if ms, ok := cfgMap.(map[string]interface{}); ok {
|
||||
for k, v := range ms {
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QueryOrderByOutTradeNo 根据商户订单号查询订单状态(v3)
|
||||
func QueryOrderByOutTradeNo(ctx context.Context, outTradeNo string) (tradeState, transactionID string, totalFee int, err error) {
|
||||
if paymentApp == nil {
|
||||
return "", "", 0, fmt.Errorf("支付未初始化")
|
||||
}
|
||||
res, err := paymentApp.Order.QueryByOutTradeNumber(ctx, outTradeNo)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
if res == nil {
|
||||
return "", "", 0, nil
|
||||
}
|
||||
tradeState = res.TradeState
|
||||
transactionID = res.TransactionID
|
||||
if res.Amount != nil {
|
||||
totalFee = int(res.Amount.Total)
|
||||
}
|
||||
return tradeState, transactionID, totalFee, nil
|
||||
}
|
||||
|
||||
// HandlePayNotify 处理 v3 支付回调:验签并解密后调用 handler,返回应写回微信的 HTTP 响应
|
||||
// handler 参数:orderSn, transactionID, totalFee(分), attach(JSON), openID
|
||||
func HandlePayNotify(req *http.Request, handler func(orderSn, transactionID string, totalFee int, attach, openID string) error) (*http.Response, error) {
|
||||
if paymentApp == nil {
|
||||
return nil, fmt.Errorf("支付未初始化")
|
||||
}
|
||||
return paymentApp.HandlePaidNotify(req, func(_ *notifyrequest.RequestNotify, transaction *models.Transaction, fail func(string)) interface{} {
|
||||
if transaction == nil {
|
||||
fail("transaction is nil")
|
||||
return nil
|
||||
}
|
||||
orderSn := transaction.OutTradeNo
|
||||
transactionID := transaction.TransactionID
|
||||
totalFee := 0
|
||||
if transaction.Amount != nil {
|
||||
totalFee = int(transaction.Amount.Total)
|
||||
}
|
||||
attach := transaction.Attach
|
||||
openID := ""
|
||||
if transaction.Payer != nil {
|
||||
openID = transaction.Payer.OpenID
|
||||
}
|
||||
if err := handler(orderSn, transactionID, totalFee, attach, openID); err != nil {
|
||||
fail(err.Error())
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// HandleTransferNotify 处理商家转账结果回调:验签并解密后调用 handler,返回应写回微信的 HTTP 响应
|
||||
// handler 参数:outBillNo(商户单号/即我们存的 detail_no)、transferBillNo、state(SUCCESS/FAIL/CANCELLED)、failReason
|
||||
func HandleTransferNotify(req *http.Request, handler func(outBillNo, transferBillNo, state, failReason string) error) (*http.Response, error) {
|
||||
if paymentApp == nil {
|
||||
return nil, fmt.Errorf("支付/转账未初始化")
|
||||
}
|
||||
return paymentApp.HandleTransferBillsNotify(req, func(_ *notifyrequest.RequestNotify, bill *models.TransferBills, fail func(string)) interface{} {
|
||||
if bill == nil {
|
||||
fail("bill is nil")
|
||||
return nil
|
||||
}
|
||||
outBillNo := bill.OutBillNo
|
||||
transferBillNo := bill.TransferBillNo
|
||||
state := bill.State
|
||||
failReason := bill.FailReason
|
||||
if err := handler(outBillNo, transferBillNo, state, failReason); err != nil {
|
||||
fail(err.Error())
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateOrderSn 生成订单号
|
||||
func GenerateOrderSn() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
random := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("MP%s%06d", timestamp, random)
|
||||
}
|
||||
|
||||
// WithdrawSubscribeTemplateID 提现结果订阅消息模板 ID(与小程序 app.js withdrawSubscribeTmplId 一致)
|
||||
const WithdrawSubscribeTemplateID = "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE"
|
||||
|
||||
// SendWithdrawSubscribeMessage 发起转账成功后发订阅消息(提现成功/待确认收款)
|
||||
// openID 为接收人 openid,amount 为提现金额(元),success 为 true 表示打款已受理
|
||||
func SendWithdrawSubscribeMessage(ctx context.Context, openID string, amount float64, success bool) error {
|
||||
if miniProgramApp == nil {
|
||||
return fmt.Errorf("小程序未初始化")
|
||||
}
|
||||
phrase := "提现成功"
|
||||
thing8 := "微信打款成功,请点击查收"
|
||||
if !success {
|
||||
phrase = "提现失败"
|
||||
thing8 = "请联系官方客服"
|
||||
}
|
||||
amountStr := fmt.Sprintf("¥%.2f", amount)
|
||||
data := &power.HashMap{
|
||||
"phrase4": object.HashMap{"value": phrase},
|
||||
"amount5": object.HashMap{"value": amountStr},
|
||||
"thing8": object.HashMap{"value": thing8},
|
||||
}
|
||||
state := "formal"
|
||||
if cfg != nil && cfg.WechatMiniProgramState != "" {
|
||||
state = cfg.WechatMiniProgramState
|
||||
}
|
||||
_, err := miniProgramApp.SubscribeMessage.Send(ctx, &subrequest.RequestSubscribeMessageSend{
|
||||
ToUser: openID,
|
||||
TemplateID: WithdrawSubscribeTemplateID,
|
||||
Page: "/pages/my/my",
|
||||
MiniProgramState: state,
|
||||
Lang: "zh_CN",
|
||||
Data: data,
|
||||
})
|
||||
return err
|
||||
}
|
||||
230
soul-api/internal/wechat/transfer.go
Normal file
230
soul-api/internal/wechat/transfer.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/ArtisanCloud/PowerLibs/v3/object"
|
||||
fundAppRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/fundApp/request"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment/transfer/request"
|
||||
)
|
||||
|
||||
// TransferParams 转账参数
|
||||
type TransferParams struct {
|
||||
OutBatchNo string // 商家批次单号(唯一)
|
||||
OutDetailNo string // 商家明细单号(唯一)
|
||||
OpenID string // 收款用户 openid
|
||||
Amount int // 转账金额(分)
|
||||
UserName string // 收款用户姓名(可选,用于实名校验)
|
||||
Remark string // 转账备注
|
||||
BatchName string // 批次名称(如"提现")
|
||||
BatchRemark string // 批次备注
|
||||
}
|
||||
|
||||
// TransferResult 转账结果
|
||||
type TransferResult struct {
|
||||
BatchID string // 微信批次单号
|
||||
OutBatchNo string // 商家批次单号
|
||||
CreateTime time.Time // 批次创建时间
|
||||
BatchStatus string // 批次状态:ACCEPTED-已受理 等
|
||||
}
|
||||
|
||||
// InitTransfer 保留兼容:转账已由 Init() 中 PowerWeChat Payment 统一初始化,调用无副作用
|
||||
func InitTransfer(_ *config.Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitiateTransfer 发起商家转账到零钱(PowerWeChat TransferBatch)
|
||||
func InitiateTransfer(params TransferParams) (*TransferResult, error) {
|
||||
if paymentApp == nil {
|
||||
return nil, fmt.Errorf("支付/转账未初始化,请先调用 wechat.Init")
|
||||
}
|
||||
|
||||
detail := &request.TransferDetail{
|
||||
OutDetailNO: params.OutDetailNo,
|
||||
TransferAmount: params.Amount,
|
||||
TransferRemark: params.Remark,
|
||||
OpenID: params.OpenID,
|
||||
}
|
||||
if params.UserName != "" {
|
||||
detail.UserName = object.NewNullString(params.UserName, true)
|
||||
}
|
||||
req := &request.RequestTransferBatch{
|
||||
AppID: cfg.WechatAppID,
|
||||
OutBatchNO: params.OutBatchNo,
|
||||
BatchName: params.BatchName,
|
||||
BatchRemark: params.BatchRemark,
|
||||
TotalAmount: params.Amount,
|
||||
TotalNum: 1,
|
||||
TransferDetailList: []*request.TransferDetail{detail},
|
||||
}
|
||||
if cfg.WechatTransferURL != "" {
|
||||
req.SetNotifyUrl(cfg.WechatTransferURL)
|
||||
}
|
||||
|
||||
resp, err := paymentApp.TransferBatch.Batch(context.Background(), req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发起转账失败: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("转账返回为空")
|
||||
}
|
||||
|
||||
result := &TransferResult{
|
||||
OutBatchNo: resp.OutBatchNo,
|
||||
BatchStatus: "ACCEPTED",
|
||||
}
|
||||
if resp.BatchId != "" {
|
||||
result.BatchID = resp.BatchId
|
||||
}
|
||||
if !resp.CreateTime.IsZero() {
|
||||
result.CreateTime = resp.CreateTime
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// QueryTransfer 查询转账结果(可选,转账状态也可通过回调获取)
|
||||
func QueryTransfer(outBatchNo, outDetailNo string) (map[string]interface{}, error) {
|
||||
if paymentApp == nil {
|
||||
return map[string]interface{}{
|
||||
"out_batch_no": outBatchNo,
|
||||
"out_detail_no": outDetailNo,
|
||||
"status": "unknown",
|
||||
"message": "转账未初始化",
|
||||
}, nil
|
||||
}
|
||||
detail, err := paymentApp.TransferBatch.QueryOutBatchNoDetail(context.Background(), outBatchNo, outDetailNo)
|
||||
if err != nil {
|
||||
return map[string]interface{}{
|
||||
"out_batch_no": outBatchNo,
|
||||
"out_detail_no": outDetailNo,
|
||||
"status": "processing",
|
||||
"message": err.Error(),
|
||||
}, nil
|
||||
}
|
||||
if detail == nil {
|
||||
return map[string]interface{}{
|
||||
"out_batch_no": outBatchNo,
|
||||
"out_detail_no": outDetailNo,
|
||||
"status": "processing",
|
||||
"message": "转账处理中",
|
||||
}, nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"out_batch_no": outBatchNo,
|
||||
"out_detail_no": outDetailNo,
|
||||
"detail_status": detail.DetailStatus,
|
||||
"fail_reason": detail.FailReason,
|
||||
"transfer_amount": detail.TransferAmount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateTransferBatchNo 生成转账批次单号
|
||||
func GenerateTransferBatchNo() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
random := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("WD%s%06d", timestamp, random)
|
||||
}
|
||||
|
||||
// GenerateTransferDetailNo 生成转账明细单号
|
||||
func GenerateTransferDetailNo() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
random := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("WDD%s%06d", timestamp, random)
|
||||
}
|
||||
|
||||
// FundAppTransferParams 单笔转账(FundApp 发起转账)参数
|
||||
type FundAppTransferParams struct {
|
||||
OutBillNo string // 商户单号(唯一,回调时 out_bill_no 即此值,建议存到 withdrawal.detail_no)
|
||||
OpenID string
|
||||
UserName string // 可选
|
||||
Amount int // 分
|
||||
Remark string
|
||||
NotifyURL string
|
||||
TransferSceneId string // 可选,如 "1005"
|
||||
}
|
||||
|
||||
// FundAppTransferResult 单笔转账结果(微信同步返回,无需等回调即可落库)
|
||||
type FundAppTransferResult struct {
|
||||
OutBillNo string // 商户单号
|
||||
TransferBillNo string // 微信转账单号
|
||||
State string // 如 WAIT_USER_CONFIRM 表示待用户确认收款
|
||||
PackageInfo string // 供小程序 wx.requestMerchantTransfer 使用
|
||||
CreateTime string // 微信返回的 create_time
|
||||
}
|
||||
|
||||
// InitiateTransferByFundApp 发起商家转账到零钱(PowerWeChat FundApp.TransferBills 单笔接口)
|
||||
// 与 TransferBatch 不同,此为 /v3/fund-app/mch-transfer/transfer-bills 单笔发起,回调仍为 MCHTRANSFER.BILL.FINISHED,解密后 out_bill_no 即本接口传入的 OutBillNo
|
||||
func InitiateTransferByFundApp(params FundAppTransferParams) (*FundAppTransferResult, error) {
|
||||
if paymentApp == nil || paymentApp.FundApp == nil {
|
||||
return nil, fmt.Errorf("支付/转账未初始化,请先调用 wechat.Init")
|
||||
}
|
||||
req := &fundAppRequest.RequestTransferBills{
|
||||
Appid: cfg.WechatAppID,
|
||||
OutBillNo: params.OutBillNo,
|
||||
TransferSceneId: params.TransferSceneId,
|
||||
Openid: params.OpenID,
|
||||
UserName: params.UserName,
|
||||
TransferAmount: params.Amount,
|
||||
TransferRemark: params.Remark,
|
||||
NotifyUrl: params.NotifyURL,
|
||||
}
|
||||
// 1005=佣金报酬:微信要求同时传 transfer_scene_report_infos,岗位类型与报酬说明分开两条
|
||||
if params.TransferSceneId == "1005" {
|
||||
req.TransferSceneReportInfos = []fundAppRequest.TransferSceneReportInfo{
|
||||
{InfoType: "岗位类型", InfoContent: "会员"},
|
||||
{InfoType: "报酬说明", InfoContent: "提现"},
|
||||
}
|
||||
}
|
||||
if req.NotifyUrl == "" && cfg.WechatTransferURL != "" {
|
||||
req.NotifyUrl = cfg.WechatTransferURL
|
||||
}
|
||||
ctx := context.Background()
|
||||
resp, err := paymentApp.FundApp.TransferBills(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发起转账失败: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("转账返回为空")
|
||||
}
|
||||
// 微信返回 4xx 时 body 可能被解析到 resp,需根据 code 或 out_bill_no 判断是否成功
|
||||
if resp.Code != "" {
|
||||
msg := resp.Message
|
||||
if msg == "" {
|
||||
msg = resp.Code
|
||||
}
|
||||
return nil, fmt.Errorf("微信接口报错: %s", msg)
|
||||
}
|
||||
if resp.OutBillNo == "" {
|
||||
return nil, fmt.Errorf("微信未返回商户单号,可能请求被拒绝(如IP未加入白名单)")
|
||||
}
|
||||
result := &FundAppTransferResult{
|
||||
OutBillNo: resp.OutBillNo,
|
||||
TransferBillNo: resp.TransferBillNo,
|
||||
State: resp.State,
|
||||
PackageInfo: resp.PackageInfo,
|
||||
CreateTime: resp.CreateTime,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// QueryTransferByOutBill 按商户单号查询单笔转账结果(FundApp 接口,用于 sync)
|
||||
func QueryTransferByOutBill(outBillNo string) (state, transferBillNo, failReason string, err error) {
|
||||
if paymentApp == nil || paymentApp.FundApp == nil {
|
||||
return "", "", "", fmt.Errorf("支付/转账未初始化")
|
||||
}
|
||||
ctx := context.Background()
|
||||
resp, err := paymentApp.FundApp.QueryOutBill(ctx, outBillNo)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", "", "", nil
|
||||
}
|
||||
return resp.State, resp.TransferBillNo, resp.FailReason, nil
|
||||
}
|
||||
120
soul-api/internal/wechat/transferv3/client.go
Normal file
120
soul-api/internal/wechat/transferv3/client.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package transferv3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const wechatAPIBase = "https://api.mch.weixin.qq.com"
|
||||
|
||||
func nonce() string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
for i := range b {
|
||||
b[i] = chars[int(b[i])%len(chars)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Client 文档 V3 商家转账到零钱(签名 + HTTP)
|
||||
type Client struct {
|
||||
MchID string
|
||||
AppID string
|
||||
SerialNo string
|
||||
PrivateKey *rsa.PrivateKey
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// NewClient 使用已有私钥创建 Client
|
||||
func NewClient(mchID, appID, serialNo string, privateKey *rsa.PrivateKey) *Client {
|
||||
base := wechatAPIBase
|
||||
return &Client{
|
||||
MchID: mchID,
|
||||
AppID: appID,
|
||||
SerialNo: serialNo,
|
||||
PrivateKey: privateKey,
|
||||
BaseURL: base,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadPrivateKeyFromPath 从 PEM 文件路径加载商户私钥
|
||||
func LoadPrivateKeyFromPath(path string) (*rsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return LoadPrivateKeyFromPEM(data)
|
||||
}
|
||||
|
||||
// LoadPrivateKeyFromPEM 从 PEM 内容解析商户私钥(支持 PKCS#1 或 PKCS#8)
|
||||
func LoadPrivateKeyFromPEM(pemContent []byte) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(pemContent)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err == nil {
|
||||
return key, nil
|
||||
}
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsaKey, ok := k.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not RSA private key")
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
|
||||
// do 带签名的 HTTP 请求
|
||||
func (c *Client) do(method, path, body string) ([]byte, int, error) {
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonceStr := nonce()
|
||||
signMsg := BuildSignMessage(method, path, timestamp, nonceStr, body)
|
||||
sig, err := Sign(signMsg, c.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
auth := BuildAuthorization(c.MchID, nonceStr, sig, timestamp, c.SerialNo)
|
||||
|
||||
fullURL := c.BaseURL + path
|
||||
req, err := http.NewRequest(method, fullURL, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// PostBatches 发起转账(文档:POST /v3/transfer/batches)
|
||||
func (c *Client) PostBatches(body []byte) ([]byte, int, error) {
|
||||
return c.do("POST", "/v3/transfer/batches", string(body))
|
||||
}
|
||||
|
||||
// GetTransferDetail 按商户批次单号、商户明细单号查询(文档:GET .../batch-id/{}/details/detail-id/{})
|
||||
func (c *Client) GetTransferDetail(outBatchNo, outDetailNo string) ([]byte, int, error) {
|
||||
path := "/v3/transfer/batches/batch-id/" + url.PathEscape(outBatchNo) +
|
||||
"/details/detail-id/" + url.PathEscape(outDetailNo)
|
||||
return c.do("GET", path, "")
|
||||
}
|
||||
52
soul-api/internal/wechat/transferv3/decrypt.go
Normal file
52
soul-api/internal/wechat/transferv3/decrypt.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package transferv3
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DecryptResource 解密回调 resource(文档:AEAD_AES_256_GCM,密钥 APIv3 32 字节,密文=实际密文+16 字节 tag)
|
||||
func DecryptResource(ciphertextBase64, nonce, associatedData string, apiV3Key []byte) ([]byte, error) {
|
||||
if len(apiV3Key) != 32 {
|
||||
return nil, fmt.Errorf("apiV3 key must be 32 bytes, got %d", len(apiV3Key))
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(ciphertextBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
if len(raw) < 16 {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
tag := raw[len(raw)-16:]
|
||||
ctext := raw[:len(raw)-16]
|
||||
|
||||
block, err := aes.NewCipher(apiV3Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := aead.Open(nil, []byte(nonce), append(ctext, tag...), []byte(associatedData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes-gcm decrypt: %w", err)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// DecryptResourceJSON 解密并解析为 JSON 对象(回调解密后的 resource)
|
||||
func DecryptResourceJSON(ciphertextBase64, nonce, associatedData string, apiV3Key []byte) (map[string]interface{}, error) {
|
||||
plain, err := DecryptResource(ciphertextBase64, nonce, associatedData, apiV3Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(plain, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
48
soul-api/internal/wechat/transferv3/sign.go
Normal file
48
soul-api/internal/wechat/transferv3/sign.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package transferv3
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildSignMessage 构建请求签名串(文档:请求方法\n请求URL路径\n时间戳\n随机串\n请求报文主体\n)
|
||||
func BuildSignMessage(method, urlPath, timestamp, nonce, body string) string {
|
||||
return method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"
|
||||
}
|
||||
|
||||
// Sign 使用商户私钥 SHA256withRSA 签名,返回 Base64
|
||||
func Sign(signMessage string, privateKey *rsa.PrivateKey) (string, error) {
|
||||
h := sha256.Sum256([]byte(signMessage))
|
||||
sig, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, h[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rsa sign: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// BuildAuthorization 构建 Authorization 头(文档格式)
|
||||
func BuildAuthorization(mchID, nonce, signature, timestamp, serialNo string) string {
|
||||
return fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`,
|
||||
mchID, nonce, signature, timestamp, serialNo)
|
||||
}
|
||||
|
||||
// BuildVerifyMessage 构建回调验签串(文档:时间戳\n随机串\n请求报文主体\n)
|
||||
func BuildVerifyMessage(timestamp, nonce, body string) string {
|
||||
return timestamp + "\n" + nonce + "\n" + body + "\n"
|
||||
}
|
||||
|
||||
// VerifySignature 使用平台公钥验证回调签名(Wechatpay-Signature 为 Base64)
|
||||
func VerifySignature(timestamp, nonce, body, signatureBase64 string, publicKey *rsa.PublicKey) bool {
|
||||
msg := BuildVerifyMessage(timestamp, nonce, body)
|
||||
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signatureBase64))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
h := sha256.Sum256([]byte(msg))
|
||||
err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, h[:], sig)
|
||||
return err == nil
|
||||
}
|
||||
Binary file not shown.
12
soul-api/scripts/sync-orders.sh
Normal file
12
soul-api/scripts/sync-orders.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/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 ""
|
||||
93
soul-api/scripts/test_transfer_notify.py
Normal file
93
soul-api/scripts/test_transfer_notify.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/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()
|
||||
64
soul-api/scripts/test_withdraw.py
Normal file
64
soul-api/scripts/test_withdraw.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/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.
24
soul-api/商家转账.md
Normal file
24
soul-api/商家转账.md
Normal file
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
76
soul-api/宝塔反向代理说明.txt
Normal file
76
soul-api/宝塔反向代理说明.txt
Normal file
@@ -0,0 +1,76 @@
|
||||
# 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/ 应看到“部署成功”页面。
|
||||
1020
soul-api/提现功能完整技术文档.md
Normal file
1020
soul-api/提现功能完整技术文档.md
Normal file
File diff suppressed because it is too large
Load Diff
116
soul-api/管理端鉴权设计.md
Normal file
116
soul-api/管理端鉴权设计.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 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())) |
|
||||
23
soul-api/订阅消息.md
Normal file
23
soul-api/订阅消息.md
Normal file
@@ -0,0 +1,23 @@
|
||||
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