更新.gitignore文件,移除不必要的soul-api目录,确保版本控制的清晰性与一致性。

This commit is contained in:
Alex-larget
2026-03-06 17:52:52 +08:00
parent 2af49611e9
commit 9aaffd8024
117 changed files with 13609 additions and 1 deletions

1
.gitignore vendored
View File

@@ -2,5 +2,4 @@
soul-api/wechat/info.log
next-project
soul-admin/node_modules
soul-api
soul-api.exe

Binary file not shown.

25
soul-api/.air.toml Normal file
View File

@@ -0,0 +1,25 @@
# Air 热重载配置:改 .go 后自动重新编译并重启
# 默认使用开发/测试环境env_files 加载 .env.development
root = "."
tmp_dir = "tmp"
env_files = [".env", ".env.development"]
# 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

44
soul-api/.env Normal file
View File

@@ -0,0 +1,44 @@
# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效)
PORT=8080
GIN_MODE=debug
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
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
# 统一 API 域名支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
API_BASE_URL=https://soul.quwanzhi.com
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
WECHAT_MCH_ID=1318592501
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
# 支付回调:未设置时由 API_BASE_URL + /api/miniprogram/pay/notify 派生
# WECHAT_NOTIFY_URL=https://soul.quwanzhi.com/api/miniprogram/pay/notify
# 小程序码/订阅消息跳转版本formal=正式版(默认) | trial=体验版 | developer=开发版
WECHAT_MINI_PROGRAM_STATE=formal
# 微信转账配置API v3
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
WECHAT_KEY_PATH=certs/apiclient_key.pem
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
# 转账回调:未设置时由 API_BASE_URL + /api/payment/wechat/transfer/notify 派生
# WECHAT_TRANSFER_URL=https://souladmin.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:5175,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

46
soul-api/.env.development Normal file
View File

@@ -0,0 +1,46 @@
# 测试环境配置air / make dev 时加载,见 .air.toml env_files
APP_ENV=development
# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效)
PORT=8080
GIN_MODE=debug
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
APP_VERSION=0.0.0
# 数据库(测试环境 souldev
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
# 统一 API 域名(测试环境)
API_BASE_URL=https://souldev.quwanzhi.com
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
WECHAT_MCH_ID=1318592501
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
# 支付回调:未设置时由 API_BASE_URL 派生
# WECHAT_NOTIFY_URL=https://souldev.quwanzhi.com/api/miniprogram/pay/notify
# 小程序码/订阅消息跳转版本formal=正式版(默认) | trial=体验版 | developer=开发版
WECHAT_MINI_PROGRAM_STATE=formal
# 微信转账配置API v3
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
WECHAT_KEY_PATH=certs/apiclient_key.pem
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
# 转账回调:未设置时由 API_BASE_URL 派生
# WECHAT_TRANSFER_URL=https://souldev.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:5175,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

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

@@ -0,0 +1,44 @@
# 正式环境配置(部署时复制为 .envdevlop.py 打包用)
APP_ENV=production
# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效)
PORT=8080
GIN_MODE=debug
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
APP_VERSION=0.0.0
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# 统一 API 域名支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
API_BASE_URL=https://soulapi.quwanzhi.com
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
WECHAT_MCH_ID=1318592501
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
# 支付回调:未设置时由 API_BASE_URL 派生
# WECHAT_NOTIFY_URL=https://soulapi.quwanzhi.com/api/miniprogram/pay/notify
# 微信转账配置API v3
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
WECHAT_KEY_PATH=certs/apiclient_key.pem
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
# 转账回调:未设置时由 API_BASE_URL 派生
# WECHAT_TRANSFER_URL=https://soulapi.quwanzhi.com/api/payment/wechat/transfer/notify
# 管理端登录(与 next-project 一致,默认 admin / admin123
# ADMIN_USERNAME=admin
# ADMIN_PASSWORD=admin123
# ADMIN_SESSION_SECRET=soul-admin-secret-change-in-prod
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1
# 跨域 CORS允许的源逗号分隔。未设置时使用默认值含 localhost、soul.quwanzhi.com
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com

5
soul-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
tmp/
log/
soul-api
server.exe
soul-api.exe

9
soul-api/Makefile Normal file
View File

@@ -0,0 +1,9 @@
# 开发:热重载(需先安装 air: go install github.com/air-verse/air@latest
dev:
air
# 普通运行(无热重载)
run:
go run ./cmd/server
.PHONY: dev run

Binary file not shown.

View File

@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIEKzCCAxOgAwIBAgIUSh22LNXJvgtvxRwwYh1vmWhudcUwDQYJKoZIhvcNAQEL
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
Q0EwHhcNMjYwMTIyMDgzMzQ2WhcNMzEwMTIxMDgzMzQ2WjCBhDETMBEGA1UEAwwK
MTMxODU5MjUwMTEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTAwLgYDVQQL
DCfms4nlt57luILljaHoi6XnvZHnu5zmioDmnK/mnInpmZDlhazlj7gxCzAJBgNV
BAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAOn4ggY2z0VowJyd1ml7vlry47+qgqMxgLqHAKzaOuETI/8lDRWd
LDfOgdVBtZNJJWF9Dk313k9UjmospjufthZ9QdTHFdK+76dnHws19ZMEaGIEJC3j
xr5fI9SJqLXq8KmxogHSHss7Nc4e5nAvVb7cgqp8kjvNOPoJxrpKH8KFtfSOKOs1
BxQdkwyhBZ70O9gbh7vEZM3k/zN3JsZfqssSTcKQm6u4fszPhbVeYPbZvgD6UN8B
H465/PZqS2UwbjrPj6v6SkJgl77xqcXAhHWxISUD6NWgJaU58Idtm2M+5C0vi68u
WcUmosOXeOHxC3IQTTlFYnqjThdvJt+qifsCAwEAAaOBuTCBtjAJBgNVHRMEAjAA
MAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2
Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJD
MDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJC
MjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQCD
nXigQonbIBZp1EdNqd1zR9alTB0KL3Z7KRxGXogUSSn/F4FGcXxvYKeOIJNg5g89
EDsopqyzwG999lIG+D34lyabbh/j7M7JegAdCAr06X7cBxIF+ujOecotesF/dtl/
5hWXEU3yVZSwzjvOkMAL4xnXBwIZeXQJ8fD6vLZRsRTXfm7qi88MSuWWLuB+5X2l
CwS7e6Zu2kgL+U2YeA9cu7/l5zL1wfQqjlk1PTMwKAstvSNzamnpLAzhJ8U5g7lh
lF9Pbbbs5Hq6VblRqCUyMDATqhqKQTAeXn3soQodHqxLw8MeL7QICQGQxBxFmItj
TwZDp4hd2oka3oS1VsV0
-----END CERTIFICATE-----

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDp+IIGNs9FaMCc
ndZpe75a8uO/qoKjMYC6hwCs2jrhEyP/JQ0VnSw3zoHVQbWTSSVhfQ5N9d5PVI5q
LKY7n7YWfUHUxxXSvu+nZx8LNfWTBGhiBCQt48a+XyPUiai16vCpsaIB0h7LOzXO
HuZwL1W+3IKqfJI7zTj6Cca6Sh/ChbX0jijrNQcUHZMMoQWe9DvYG4e7xGTN5P8z
dybGX6rLEk3CkJuruH7Mz4W1XmD22b4A+lDfAR+Oufz2aktlMG46z4+r+kpCYJe+
8anFwIR1sSElA+jVoCWlOfCHbZtjPuQtL4uvLlnFJqLDl3jh8QtyEE05RWJ6o04X
bybfqon7AgMBAAECggEAbi3WnTKGXPs9aQNzCu148L9cvM+BAXS4WB5nFP8XpxIq
a2Z5SOpg/k7DGTf+V8OkVMpdSB02eUkqX5lzFrTZPLHzpE20WzALD1wiZFcetALp
XO7yUqHm35NR/i5tQm3Gs0KxNgZK9g2GAvDON5oy2NRivAI5ouu7nxOnf+aUGjeS
vAgfuP8O0CADFIyAoUeo9ZpPhMTehfSBUzPWMdXk2UAeoJQR8tp4t8Uh3AMPO/oF
ZLo+l9dEbK3iojCjzkRXvMznx0A8Eo1Zns/2A8jG6g/QIz8ZZLmAP7cgoGGimj+y
lbawi933yLMtGq+UlO4Xydk5LX1B8YWh6U2IsIAsYQKBgQD2cTs5B91Jr544WmKf
dAZRD62spomnGmwC2DSQa807/W7QbwhCUCB/6UmwjX8ev4aw8ypi7Bsj3Fp26QCD
mI75rJozReiCXvOggPi8gy4eaodsfZiplfOV5Eb0SNFkYcrvDMzj6hu/FvQwMTc7
2X9lTjB6cZgQg2j8H3YX2YIqKQKBgQDzC3RI80u68avfLeAw/6TJV/jBIJLjs64D
aN7vsY1zPWn03i+Wma/Bjbh8JBk69St0t/ILS7jn8ESN1RzizODYd6yFn56345zo
zrTzZoQK3+xjMDnrdEYCww+u47pmhTVGDqxcy4nbHEN8sVw/DX+P4Ho3wd3u+8Kp
TqCAXdQfgwKBgQDQSEDSYYgwB8JERHfH5gqUphiVq6b5WQZinRJH4SRzCC2I8d5c
FVZyZNuH4P7IIP0YPlvbgUsq0siOaTyq+9wSvkMRBIuO6+siAv62bHQk9sn/8mJ9
KaPWUjl5qrV2DoSx5vKfybOrnB3DQUU6Swc1upCUW782bankND7dx1IQiQKBgQDb
ogY7xmExVyPSU0q9/MeVjAInxJ/5VW5zdlnAkdsZwO33crHejpPdfYyx4o1KUjQr
De+VdaBrOR06btPjwPGPrNYjCtQLqY0qdWHgc0vv59te5z3wIOsDo/KQQQs5ijdS
UABC+0xgzXHPRRfvgus7wcewi2lbhferuHoihqgisQKBgQDJBBIJsqtdbQs8IN3p
2uEIswKgGvUTPScrrcXNH2Jox7XYIZ8GtPhspWqrudKTPdXZwVKR9wXTGc2cBZm8
mKB5oE+cQ/a+Ub6QTZwL/vj+y8ogUvPKI7hnNaV+AFNMrwXopAmvLiAvPRuD9mIx
RQ27dKDYfWqBlj4ssiBPeVVVWw==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,81 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/handler"
"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)
}
}()
// 内置订单对账定时任务SYNC_ORDERS_INTERVAL_MINUTES > 0 时启动)
if cfg.SyncOrdersIntervalMinutes > 0 {
interval := time.Duration(cfg.SyncOrdersIntervalMinutes) * time.Minute
go func() {
// 启动后延迟 1 分钟执行第一次,避免与启动流程抢资源
time.Sleep(1 * time.Minute)
ticker := time.NewTicker(interval)
defer ticker.Stop()
handler.SyncOrdersLogf("内置定时任务已启动,间隔 %d 分钟", cfg.SyncOrdersIntervalMinutes)
for {
ctx := context.Background()
synced, total, err := handler.RunSyncOrders(ctx, 7)
if err != nil {
handler.SyncOrdersLogf("对账失败: %v", err)
} else if total > 0 {
handler.SyncOrdersLogf("本轮检查 %d 笔,补齐 %d 笔漏单", total, synced)
}
<-ticker.C
}
}()
}
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")
}

391
soul-api/dev_dev.py Normal file
View File

@@ -0,0 +1,391 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soul-api Go 项目一键部署到宝塔(测试环境),重启的是宝塔里的 soulDev 项目
- 打包使用 .env.development 作为服务器 .env
- 本地交叉编译 Linux 二进制
- 上传到 /www/wwwroot/self/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/self/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.development")
staging_env = os.path.join(staging, ".env")
if include_env and os.path.isfile(env_src):
shutil.copy2(env_src, staging_env)
print(" [已包含] .env.development -> .env")
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_projectPOST 带 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())

391
soul-api/devlop.py Normal file
View File

@@ -0,0 +1,391 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soulApp (soul-api) Go 项目一键部署到宝塔(正式环境)
- 打包使用 .env.production 作为服务器 .env
- 本地交叉编译 Linux 二进制
- 上传到 /www/wwwroot/self/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/self/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.production")
staging_env = os.path.join(staging, ".env")
if include_env and os.path.isfile(env_src):
shutil.copy2(env_src, staging_env)
print(" [已包含] .env.production -> .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_projectPOST 带 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
View 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
View 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=

View File

@@ -0,0 +1,71 @@
// 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 签名role 为空时默认 admin
func IssueAdminJWT(secret, username, role string) (string, error) {
if role == "" {
role = "admin"
}
now := time.Now()
claims := AdminClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(adminJWTExpire)),
IssuedAt: jwt.NewNumericDate(now),
Subject: "admin",
},
Username: username,
Role: role,
}
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)
}

View 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)
}

View File

@@ -0,0 +1,256 @@
package config
import (
"os"
"path/filepath"
"strconv"
"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 返回
// 统一 API 域名字段支付回调、转账回调、apiDomain 等均由 BaseURL 拼接
BaseURL string // API_BASE_URL如 https://soulapi.quwanzhi.com无尾部斜杠
// 微信小程序配置
WechatAppID string
WechatAppSecret string
WechatMchID string
WechatMchKey string
WechatNotifyURL string // 由 BaseURL + /api/miniprogram/pay/notify 派生
WechatMiniProgramState string // 订阅消息跳转版本developer/formal从 .env WECHAT_MINI_PROGRAM_STATE 读取
// 微信转账配置API v3
WechatAPIv3Key string
WechatCertPath string
WechatKeyPath string
WechatSerialNo string
WechatTransferURL string // 由 BaseURL + /api/payment/wechat/transfer/notify 派生
// 管理端登录(与 next-project 一致ADMIN_USERNAME / ADMIN_PASSWORD / ADMIN_SESSION_SECRET
AdminUsername string
AdminPassword string
AdminSessionSecret string
// 订单对账定时任务间隔分钟0 表示不启动内置定时任务
SyncOrdersIntervalMinutes int
}
// BaseURLJoin 将路径拼接到 BaseURLpath 应以 / 开头
func (c *Config) BaseURLJoin(path string) string {
base := strings.TrimSuffix(c.BaseURL, "/")
if base == "" {
return ""
}
p := strings.TrimSpace(path)
if p != "" && p[0] != '/' {
p = "/" + p
}
return base + p
}
// 默认 CORS 允许的源(零配置:不设环境变量也能用)
var defaultCORSOrigins = []string{
"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",
}
// 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 读取。
// 环境区分APP_ENV=development 加载 .env.developmentAPP_ENV=production 加载 .env.production
// air 运行时通过 env_files 或 full_bin 设置 APP_ENV开发用 .env.development部署用 .env.production。
func Load() (*Config, error) {
workDir, _ := os.Getwd()
execDir := ""
if execPath, err := os.Executable(); err == nil {
execDir = filepath.Dir(execPath)
}
loadEnv := func(name string) {
for _, dir := range []string{execDir, workDir, "."} {
if dir == "" {
continue
}
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
_ = godotenv.Load(p)
break
}
}
}
overloadEnv := func(name string) {
for _, dir := range []string{execDir, workDir, "."} {
if dir == "" {
continue
}
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
_ = godotenv.Overload(p)
break
}
}
}
// 1. 加载 .env 作为基础
loadEnv(".env")
// 2. 按 APP_ENV 覆盖(优先读已设置的 APP_ENV如 air 的 env_files 已注入)
appEnv := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV")))
if appEnv == "development" {
overloadEnv(".env.development")
} else if appEnv == "production" {
overloadEnv(".env.production")
}
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)
}
// 统一域名API_BASE_URL 派生支付/转账回调,可选 WECHAT_NOTIFY_URL 覆盖
baseURL := strings.TrimSpace(strings.TrimSuffix(os.Getenv("API_BASE_URL"), "/"))
if baseURL == "" {
baseURL = "https://soulapi.quwanzhi.com"
}
wechatNotifyURL := os.Getenv("WECHAT_NOTIFY_URL")
if wechatNotifyURL == "" {
wechatNotifyURL = baseURL + "/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 = baseURL + "/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"
}
syncOrdersInterval := 5
if s := os.Getenv("SYNC_ORDERS_INTERVAL_MINUTES"); s != "" {
if n, e := strconv.Atoi(s); e == nil && n >= 0 {
syncOrdersInterval = n
}
}
return &Config{
Port: port,
Mode: mode,
DBDSN: dsn,
TrustedProxies: []string{"127.0.0.1", "::1"},
CORSOrigins: parseCORSOrigins(),
Version: version,
BaseURL: baseURL,
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,
SyncOrdersIntervalMinutes: syncOrdersInterval,
}, nil
}

View File

@@ -0,0 +1,58 @@
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)
}
if err := db.AutoMigrate(&model.VipRole{}); err != nil {
log.Printf("database: vip_roles migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Order{}); err != nil {
log.Printf("database: orders migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Mentor{}); err != nil {
log.Printf("database: mentors migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.MentorConsultation{}); err != nil {
log.Printf("database: mentor_consultations migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.AuthorConfig{}); err != nil {
log.Printf("database: author_config migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.AdminUser{}); err != nil {
log.Printf("database: admin_users migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}
// DB 返回全局 *gorm.DB仅在 Init 成功后调用
func DB() *gorm.DB {
return db
}

View File

@@ -0,0 +1,152 @@
package handler
import (
"net/http"
"soul-api/internal/auth"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// AdminCheck GET /api/admin 鉴权检查JWTAuthorization 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_users 表,表空时回退 ADMIN_USERNAME/PASSWORD 并自动初始化)
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
db := database.DB()
// 1. 尝试从 admin_users 表校验
var u model.AdminUser
err := db.Where("username = ?", username).First(&u).Error
if err == nil {
if u.Status != "active" {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "账号已禁用"})
return
}
if bcryptErr := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); bcryptErr != nil {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, u.Username, u.Role)
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": u.ID, "username": u.Username, "role": u.Role, "name": u.Name},
})
return
}
// 2. 表内无匹配:若表为空且 env 账号正确,则创建初始 super_admin 并登录
if err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "系统错误"})
return
}
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
if username != cfg.AdminUsername || password != cfg.AdminPassword {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
// 表为空时初始化超级管理员
var cnt int64
if db.Model(&model.AdminUser{}).Count(&cnt).Error != nil || cnt > 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
return
}
initial := model.AdminUser{
Username: cfg.AdminUsername,
PasswordHash: string(hash),
Role: "super_admin",
Name: "卡若",
Status: "active",
}
if err := db.Create(&initial).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "初始化失败"})
return
}
token, err := auth.IssueAdminJWT(cfg.AdminSessionSecret, initial.Username, initial.Role)
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": initial.ID, "username": initial.Username, "role": initial.Role, "name": initial.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]
}

View File

@@ -0,0 +1,160 @@
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"`
EditionStandard *bool `json:"editionStandard,omitempty"`
EditionPremium *bool `json:"editionPremium,omitempty"`
}
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,
EditionStandard: row.EditionStandard, EditionPremium: row.EditionPremium,
})
}
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"`
ChapterID string `json:"chapterId"` // 前端兼容section id
SectionTitle string `json:"sectionTitle"`
Ids []string `json:"ids"` // reorder新顺序的 section id 列表
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
Status *string `json:"status"`
EditionStandard *bool `json:"editionStandard"`
EditionPremium *bool `json:"editionPremium"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
resolveID := func() string {
if body.ID != "" {
return body.ID
}
return body.ChapterID
}
db := database.DB()
if body.Action == "updatePrice" {
id := resolveID()
if id != "" && body.Price != nil {
db.Model(&model.Chapter{}).Where("id = ?", id).Update("price", *body.Price)
}
}
if body.Action == "toggleFree" {
id := resolveID()
if id != "" && body.IsFree != nil {
db.Model(&model.Chapter{}).Where("id = ?", id).Update("is_free", *body.IsFree)
}
}
if body.Action == "updateStatus" {
id := resolveID()
if id != "" && body.Status != nil {
db.Model(&model.Chapter{}).Where("id = ?", id).Update("status", *body.Status)
}
}
if body.Action == "rename" {
id := resolveID()
if id != "" && body.SectionTitle != "" {
db.Model(&model.Chapter{}).Where("id = ?", id).Update("section_title", body.SectionTitle)
}
}
if body.Action == "delete" {
id := resolveID()
if id != "" {
db.Where("id = ?", id).Delete(&model.Chapter{})
}
}
if body.Action == "reorder" && len(body.Ids) > 0 {
for i, id := range body.Ids {
if id != "" {
db.Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i)
}
}
}
if body.Action == "updateEdition" {
id := resolveID()
if id != "" {
updates := make(map[string]interface{})
if body.EditionStandard != nil {
updates["edition_standard"] = *body.EditionStandard
}
if body.EditionPremium != nil {
updates["edition_premium"] = *body.EditionPremium
}
if len(updates) > 0 {
db.Model(&model.Chapter{}).Where("id = ?", id).Updates(updates)
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View 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) + "%"
}

View 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})
}

View File

@@ -0,0 +1,240 @@
package handler
import (
"net/http"
"strconv"
"strings"
"soul-api/internal/database"
"soul-api/internal/middleware"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// AdminUsersList GET /api/admin/users 管理员用户列表(仅 super_admin
func AdminUsersList(c *gin.Context) {
claims := middleware.GetAdminClaims(c)
if claims == nil || claims.Role != "super_admin" {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限"})
return
}
db := database.DB()
page, _ := strconv.Atoi(c.Query("page"))
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(c.Query("pageSize"))
if pageSize < 1 {
pageSize = 10
}
if pageSize > 100 {
pageSize = 100
}
search := strings.TrimSpace(c.Query("search"))
var total int64
q := db.Model(&model.AdminUser{})
if search != "" {
q = q.Where("username LIKE ? OR name LIKE ?", "%"+search+"%", "%"+search+"%")
}
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
var list []model.AdminUser
offset := (page - 1) * pageSize
if err := q.Order("id ASC").Offset(offset).Limit(pageSize).Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
records := make([]gin.H, 0, len(list))
for _, u := range list {
records = append(records, gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
"name": u.Name,
"status": u.Status,
"createdAt": u.CreatedAt,
"updatedAt": u.UpdatedAt,
})
}
totalPages := (int(total) + pageSize - 1) / pageSize
c.JSON(http.StatusOK, gin.H{
"success": true,
"records": records,
"total": total,
"page": page,
"pageSize": pageSize,
"totalPages": totalPages,
})
}
// AdminUsersAction POST/PUT/DELETE /api/admin/users 管理员用户增删改(仅 super_admin
func AdminUsersAction(c *gin.Context) {
claims := middleware.GetAdminClaims(c)
if claims == nil || claims.Role != "super_admin" {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限"})
return
}
db := database.DB()
switch c.Request.Method {
case http.MethodPost:
var body struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Name string `json:"name"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
username := trimSpace(body.Username)
if len(username) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名至少 2 个字符"})
return
}
if len(body.Password) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码至少 6 位"})
return
}
role := trimSpace(body.Role)
if role != "super_admin" && role != "admin" {
role = "admin"
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "密码加密失败"})
return
}
u := model.AdminUser{
Username: username,
PasswordHash: string(hash),
Role: role,
Name: trimSpace(body.Name),
Status: "active",
}
if err := db.Create(&u).Error; err != nil {
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名已存在"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": u.ID,
"username": u.Username,
"role": u.Role,
"name": u.Name,
"status": u.Status,
"createdAt": u.CreatedAt,
},
})
case http.MethodPut:
var body struct {
ID uint `json:"id" binding:"required"`
Password string `json:"password"`
Name string `json:"name"`
Role string `json:"role"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
var u model.AdminUser
if err := db.First(&u, body.ID).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
}
updates := make(map[string]interface{})
updates["name"] = trimSpace(body.Name)
if body.Role == "super_admin" || body.Role == "admin" {
updates["role"] = body.Role
}
if body.Status == "active" || body.Status == "disabled" {
updates["status"] = body.Status
}
if body.Password != "" {
if len(body.Password) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "新密码至少 6 位"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "密码加密失败"})
return
}
updates["password_hash"] = string(hash)
}
if len(updates) > 0 {
if err := db.Model(&u).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
}
var updated model.AdminUser
_ = db.First(&updated, u.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": updated.ID,
"username": updated.Username,
"role": updated.Role,
"name": updated.Name,
"status": updated.Status,
"updatedAt": updated.UpdatedAt,
},
})
case http.MethodDelete:
id := c.Query("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
return
}
uid64, err := strconv.ParseUint(strings.TrimSpace(id), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "id 无效"})
return
}
uid := uint(uid64)
var u model.AdminUser
if err := db.First(&u, uid).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if u.Username == claims.Username {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能删除当前登录账号"})
return
}
if u.Role == "super_admin" {
var cnt int64
db.Model(&model.AdminUser{}).Where("role = ?", "super_admin").Count(&cnt)
if cnt <= 1 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "至少保留一个超级管理员"})
return
}
}
if err := db.Delete(&u).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
default:
c.JSON(http.StatusMethodNotAllowed, gin.H{"success": false, "error": "方法不支持"})
}
}

View File

@@ -0,0 +1,421 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
// AdminWithdrawalsList GET /api/admin/withdrawals支持分页 page、pageSize筛选 status
func AdminWithdrawalsList(c *gin.Context) {
statusFilter := c.Query("status")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
db := database.DB()
q := db.Model(&model.Withdrawal{})
if statusFilter != "" && statusFilter != "all" {
q = q.Where("status = ?", statusFilter)
}
var total int64
q.Count(&total)
var list []model.Withdrawal
query := db.Order("created_at DESC")
if statusFilter != "" && statusFilter != "all" {
query = query.Where("status = ?", statusFilter)
}
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).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,
})
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
var pendingCount, successCount, failedCount int64
var pendingAmount, successAmount float64
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Count(&pendingCount)
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Select("COALESCE(SUM(amount), 0)").Scan(&pendingAmount)
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Count(&successCount)
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Select("COALESCE(SUM(amount), 0)").Scan(&successAmount)
db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"failed", "rejected"}).Count(&failedCount)
c.JSON(http.StatusOK, gin.H{
"success": true, "withdrawals": withdrawals,
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
"stats": gin.H{
"total": total, "pendingCount": pendingCount, "pendingAmount": pendingAmount,
"successCount": successCount, "successAmount": successAmount, "failedCount": failedCount,
},
})
}
// 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),
})
}

View 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})
}

View File

@@ -0,0 +1,343 @@
package handler
import (
"net/http"
"strconv"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
var excludeParts = []string{"序言", "尾声", "附录"}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
func BookAllChapters(c *gin.Context) {
q := database.DB().Model(&model.Chapter{})
if c.Query("excludeFixed") == "1" {
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
}
var list []model.Chapter
if err := q.Order("COALESCE(sort_order, 999999) 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
}
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,
}
if body.EditionStandard != nil {
updates["edition_standard"] = body.EditionStandard
}
if body.EditionPremium != nil {
updates["edition_premium"] = body.EditionPremium
}
if err := db.Model(&model.Chapter{}).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})
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": "不支持的请求方法"})
}
// bookHotChaptersSorted 按精选推荐算法排序:阅读量优先,同量按更新时间;排除序言/尾声/附录
func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
q := db.Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
var all []model.Chapter
if err := q.Order("sort_order ASC, id ASC").Find(&all).Error; err != nil || len(all) == 0 {
return nil
}
// 从 reading_progress 统计阅读量
ids := make([]string, 0, len(all))
for _, c := range all {
ids = append(ids, c.ID)
}
var counts []struct {
SectionID string `gorm:"column:section_id"`
Cnt int64 `gorm:"column:cnt"`
}
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
Where("section_id IN ?", ids).Group("section_id").Scan(&counts)
countMap := make(map[string]int64)
for _, r := range counts {
countMap[r.SectionID] = r.Cnt
}
// 按阅读量降序、同量按 updated_at 降序
type withSort struct {
ch model.Chapter
cnt int64
}
withCnt := make([]withSort, 0, len(all))
for _, c := range all {
withCnt = append(withCnt, withSort{ch: c, cnt: countMap[c.ID]})
}
for i := 0; i < len(withCnt)-1; i++ {
for j := i + 1; j < len(withCnt); j++ {
if withCnt[j].cnt > withCnt[i].cnt ||
(withCnt[j].cnt == withCnt[i].cnt && withCnt[j].ch.UpdatedAt.After(withCnt[i].ch.UpdatedAt)) {
withCnt[i], withCnt[j] = withCnt[j], withCnt[i]
}
}
}
out := make([]model.Chapter, 0, limit)
for i := 0; i < limit && i < len(withCnt); i++ {
out = append(out, withCnt[i].ch)
}
return out
}
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录)
func BookHot(c *gin.Context) {
list := bookHotChaptersSorted(database.DB(), 10)
if len(list) == 0 {
// 兜底:按 sort_order 取前 10同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章,带 热门/推荐/精选 标签)
func BookRecommended(c *gin.Context) {
list := bookHotChaptersSorted(database.DB(), 3)
if len(list) == 0 {
// 兜底:按 updated_at 取前 3同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("updated_at DESC, id ASC").Limit(3).Find(&list)
}
tags := []string{"热门", "推荐", "精选"}
out := make([]gin.H, 0, len(list))
for i, ch := range list {
tag := "精选"
if i < len(tags) {
tag = tags[i]
}
out = append(out, gin.H{
"id": ch.ID, "mid": ch.MID, "sectionTitle": ch.SectionTitle, "partTitle": ch.PartTitle,
"chapterTitle": ch.ChapterTitle, "tag": tag,
"isFree": ch.IsFree, "price": ch.Price, "isNew": ch.IsNew,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
// 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 维护"})
}

View File

@@ -0,0 +1,324 @@
package handler
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"soul-api/internal/database"
"soul-api/internal/model"
)
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 {
// 资源对接:同步更新用户资料中的 help_offer、help_need、phone、wechat_id
if body.Type == "investor" && body.UserID != "" {
updates := map[string]interface{}{}
if body.CanHelp != "" {
updates["help_offer"] = body.CanHelp
}
if body.NeedHelp != "" {
updates["help_need"] = body.NeedHelp
}
if body.Phone != "" {
updates["phone"] = body.Phone
}
if body.Wechat != "" {
updates["wechat_id"] = body.Wechat
}
if len(updates) > 0 {
database.DB().Model(&model.User{}).Where("id = ?", body.UserID).Updates(updates)
}
}
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})
}
// CKBLead POST /api/miniprogram/ckb/lead 小程序-链接卡若:上报线索到存客宝,便于卡若添加好友
// 请求体phone可选、wechatId可选、name可选、userId可选用于补全昵称
// 至少传 phone 或 wechatId 之一;签名规则同 api_v1.md
func CKBLead(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
Name string `json:"name"`
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
wechatId := strings.TrimSpace(body.WechatID)
if phone == "" && wechatId == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请提供手机号或微信号"})
return
}
name := strings.TrimSpace(body.Name)
if name == "" && body.UserID != "" {
var u model.User
if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
name = *u.Nickname
}
}
if name == "" {
name = "小程序用户"
}
ts := time.Now().Unix()
params := map[string]interface{}{
"timestamp": ts,
"source": "小程序-链接卡若",
"remark": "首页点击「链接卡若」留资",
"name": name,
}
if phone != "" {
params["phone"] = phone
}
if wechatId != "" {
params["wechatId"] = wechatId
}
params["apiKey"] = ckbAPIKey
params["sign"] = ckbSign(params, ckbAPIKey)
raw, _ := json.Marshal(params)
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusOK, 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 := "提交成功,卡若会尽快联系您"
if result.Message == "已存在" {
msg = "您已留资,我们会尽快联系您"
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data})
return
}
errMsg := result.Message
if errMsg == "" {
errMsg = "提交失败,请稍后重试"
}
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
}

View 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)
}

View 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})
}

View File

@@ -0,0 +1,161 @@
package handler
import (
"context"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
var (
syncOrdersLogger *log.Logger
syncOrdersLoggerOnce sync.Once
)
// syncOrdersLogf 将订单同步日志写入 log/sync-orders.log不输出到控制台
func syncOrdersLogf(format string, args ...interface{}) {
syncOrdersLoggerOnce.Do(func() {
_ = os.MkdirAll("log", 0755)
f, err := os.OpenFile(filepath.Join("log", "sync-orders.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
syncOrdersLogger = log.New(io.Discard, "", 0)
return
}
syncOrdersLogger = log.New(f, "[SyncOrders] ", log.Ldate|log.Ltime)
})
if syncOrdersLogger != nil {
syncOrdersLogger.Printf(format, args...)
}
}
// SyncOrdersLogf 供 main 等调用,将订单同步相关日志写入 log/sync-orders.log
func SyncOrdersLogf(format string, args ...interface{}) {
syncOrdersLogf(format, args...)
}
// RunSyncOrders 订单对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
// 可被 HTTP 接口和内置定时任务调用。days 为查询范围(天),建议 7。
func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error) {
if days < 1 {
days = 7
}
if days > 30 {
days = 30
}
db := database.DB()
cutoff := time.Now().AddDate(0, 0, -days)
var createdOrders []model.Order
if err := db.Where("status = ? AND created_at > ?", "created", cutoff).Find(&createdOrders).Error; err != nil {
return 0, 0, err
}
total = len(createdOrders)
for _, o := range createdOrders {
select {
case <-ctx.Done():
return synced, total, ctx.Err()
default:
}
tradeState, transactionID, totalFee, qerr := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN)
if qerr != nil {
syncOrdersLogf("查询订单 %s 失败: %v", o.OrderSN, qerr)
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 {
syncOrdersLogf("更新订单 %s 失败: %v", o.OrderSN, err)
continue
}
synced++
syncOrdersLogf("补齐漏单: %s, amount=%.2f", o.OrderSN, totalAmount)
// 同步后续逻辑全书、VIP、分销等与 PayNotify 一致)
pt := "fullbook"
if o.ProductType != "" {
pt = o.ProductType
}
productID := ""
if o.ProductID != nil {
productID = *o.ProductID
}
if productID == "" {
productID = "fullbook"
}
switch pt {
case "fullbook":
db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true)
syncOrdersLogf("用户已购全书: %s", o.UserID)
case "vip":
expireDate := now.AddDate(0, 0, 365)
db.Model(&model.User{}).Where("id = ?", o.UserID).Updates(map[string]interface{}{
"is_vip": true,
"vip_expire_date": expireDate,
"vip_activated_at": now,
})
syncOrdersLogf("用户 VIP 已激活: %s, 过期日=%s", o.UserID, expireDate.Format("2006-01-02"))
case "match":
syncOrdersLogf("用户购买匹配次数: %s", o.UserID)
case "section":
syncOrdersLogf("用户购买章节: %s - %s", o.UserID, productID)
}
// 取消同商品未支付订单(与 PayNotify 一致)
db.Where(
"user_id = ? AND product_type = ? AND product_id = ? AND status = ? AND order_sn != ?",
o.UserID, pt, productID, "created", o.OrderSN,
).Delete(&model.Order{})
processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, &o)
}
return synced, total, nil
}
// CronSyncOrders GET/POST /api/cron/sync-orders
// 对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
// 支持 ?days=7 扩展时间范围,默认 7 天
func CronSyncOrders(c *gin.Context) {
days := 7
if d := c.Query("days"); d != "" {
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 30 {
days = n
}
}
synced, total, err := RunSyncOrders(c.Request.Context(), days)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"synced": synced,
"total": total,
"days": days,
})
}
// CronUnbindExpired GET/POST /api/cron/unbind-expired
func CronUnbindExpired(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
package handler
import (
"context"
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// listSelectCols 列表/导出不加载 content大幅加速
var listSelectCols = []string{
"id", "section_title", "price", "is_free", "is_new",
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
}
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
type sectionListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Price float64 `json:"price"`
IsFree *bool `json:"isFree,omitempty"`
IsNew *bool `json:"isNew,omitempty"` // stitch_soul标记最新新增
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.Select(listSelectCols).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,
IsNew: r.IsNew,
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,
"isNew": ch.IsNew,
"partId": ch.PartID,
"partTitle": ch.PartTitle,
"chapterId": ch.ChapterID,
"chapterTitle": ch.ChapterTitle,
},
})
return
case "export":
var rows []model.Chapter
if err := db.Select(listSelectCols).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, IsNew: r.IsNew,
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 {
Action string `json:"action"`
// reorder新顺序支持跨篇跨章时附带 partId/chapterId
IDs []string `json:"ids"`
Items []reorderItem `json:"items"`
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
IsNew *bool `json:"isNew"` // stitch_soul标记最新新增
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.Action == "reorder" {
// 立即返回成功,后台异步执行排序更新
if len(body.Items) > 0 {
items := make([]reorderItem, len(body.Items))
copy(items, body.Items)
c.JSON(http.StatusOK, gin.H{"success": true})
go func() {
db := database.DB()
for i, it := range items {
if it.ID == "" {
continue
}
up := map[string]interface{}{"sort_order": i}
if it.PartID != "" {
up["part_id"] = it.PartID
}
if it.PartTitle != "" {
up["part_title"] = it.PartTitle
}
if it.ChapterID != "" {
up["chapter_id"] = it.ChapterID
}
if it.ChapterTitle != "" {
up["chapter_title"] = it.ChapterTitle
}
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
}
}()
return
}
if len(body.IDs) > 0 {
ids := make([]string, len(body.IDs))
copy(ids, body.IDs)
c.JSON(http.StatusOK, gin.H{"success": true})
go func() {
db := database.DB()
for i, id := range ids {
if id != "" {
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error
}
}
}()
return
}
}
if 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,
}
if body.IsNew != nil {
updates["is_new"] = *body.IsNew
}
if body.EditionStandard != nil {
updates["edition_standard"] = *body.EditionStandard
}
if body.EditionPremium != nil {
updates["edition_premium"] = *body.EditionPremium
}
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 reorderItem struct {
ID string `json:"id"`
PartID string `json:"partId"`
PartTitle string `json:"partTitle"`
ChapterID string `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
}
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})
}

View 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})
}

View 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})
}

View 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),
})
}

View File

@@ -0,0 +1,96 @@
package handler
import (
"net/http"
"strconv"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBMatchRecordsList GET /api/db/match-records 管理端-匹配记录列表(分页、按类型筛选)
func DBMatchRecordsList(c *gin.Context) {
db := database.DB()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
matchType := c.Query("matchType")
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
q := db.Model(&model.MatchRecord{})
if matchType != "" {
q = q.Where("match_type = ?", matchType)
}
var total int64
q.Count(&total)
var records []model.MatchRecord
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "records": []interface{}{}})
return
}
userIDs := make(map[string]bool)
for _, r := range records {
userIDs[r.UserID] = true
userIDs[r.MatchedUserID] = true
}
ids := make([]string, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []model.User
if len(ids) > 0 {
database.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 {
return ""
}
return *s
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
u := userMap[r.UserID]
mu := userMap[r.MatchedUserID]
userAvatar := ""
matchedUserAvatar := ""
if u != nil && u.Avatar != nil {
userAvatar = *u.Avatar
}
if mu != nil && mu.Avatar != nil {
matchedUserAvatar = *mu.Avatar
}
out = append(out, gin.H{
"id": r.ID, "userId": r.UserID, "matchedUserId": r.MatchedUserID,
"matchType": r.MatchType, "phone": getStr(r.Phone), "wechatId": getStr(r.WechatID),
"userNickname": getStr(u.Nickname),
"matchedNickname": getStr(mu.Nickname),
"userAvatar": userAvatar,
"matchedUserAvatar": matchedUserAvatar,
"matchScore": r.MatchScore,
"createdAt": r.CreatedAt,
})
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
c.JSON(http.StatusOK, gin.H{
"success": true, "records": out,
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
})
}

View File

@@ -0,0 +1,340 @@
// Soul创业派对 - 导师模块stitch_soul
// 小程序GET /api/miniprogram/mentors 列表、GET /api/miniprogram/mentors/:id 详情、POST /api/miniprogram/mentors/:id/book 预约
// 管理端db 组 CRUD + 预约列表
package handler
import (
"net/http"
"strconv"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// MiniprogramMentorsList GET /api/miniprogram/mentors 导师列表(支持 q 搜索、skill 筛选)
func MiniprogramMentorsList(c *gin.Context) {
db := database.DB()
q := c.Query("q")
skill := c.Query("skill")
query := db.Model(&model.Mentor{}).Where("(enabled IS NULL OR enabled = 1)")
if q != "" {
query = query.Where("name LIKE ? OR intro LIKE ? OR tags LIKE ?", "%"+q+"%", "%"+q+"%", "%"+q+"%")
}
if skill != "" {
query = query.Where("tags LIKE ?", "%"+skill+"%")
}
var list []model.Mentor
if err := query.Order("sort ASC, id ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
// 转为前端友好格式tags 拆成数组)
type mentorItem struct {
model.Mentor
TagsArr []string `json:"tagsArr"`
}
result := make([]mentorItem, len(list))
for i, m := range list {
result[i] = mentorItem{Mentor: m}
if m.Tags != "" {
result[i].TagsArr = strings.Split(m.Tags, ",")
for j := range result[i].TagsArr {
result[i].TagsArr[j] = strings.TrimSpace(result[i].TagsArr[j])
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": result})
}
// MiniprogramMentorsDetail GET /api/miniprogram/mentors/:id 导师详情
func MiniprogramMentorsDetail(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的导师ID"})
return
}
db := database.DB()
var m model.Mentor
if err := db.Where("id = ? AND (enabled IS NULL OR enabled = 1)", id).First(&m).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "导师不存在"})
return
}
tagsArr := []string{}
if m.Tags != "" {
for _, s := range strings.Split(m.Tags, ",") {
if t := strings.TrimSpace(s); t != "" {
tagsArr = append(tagsArr, t)
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": m.ID,
"avatar": m.Avatar,
"name": m.Name,
"intro": m.Intro,
"tags": m.Tags,
"tagsArr": tagsArr,
"priceSingle": m.PriceSingle,
"priceHalfYear": m.PriceHalfYear,
"priceYear": m.PriceYear,
"quote": m.Quote,
"whyFind": m.WhyFind,
"offering": m.Offering,
"judgmentStyle": m.JudgmentStyle,
},
})
}
// MiniprogramMentorsBook POST /api/miniprogram/mentors/:id/book 创建预约(选择咨询类型后创建,后续走支付)
func MiniprogramMentorsBook(c *gin.Context) {
idStr := c.Param("id")
mentorID, err := strconv.Atoi(idStr)
if err != nil || mentorID <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的导师ID"})
return
}
var body struct {
UserID int `json:"userId" binding:"required"`
ConsultationType string `json:"consultationType" binding:"required"` // single, half_year, year
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数不完整"})
return
}
if body.ConsultationType != "single" && body.ConsultationType != "half_year" && body.ConsultationType != "year" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "consultationType 需为 single/half_year/year"})
return
}
db := database.DB()
var mentor model.Mentor
if err := db.Where("id = ? AND (enabled IS NULL OR enabled = 1)", mentorID).First(&mentor).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "导师不存在"})
return
}
var amount float64
switch body.ConsultationType {
case "single":
if mentor.PriceSingle != nil {
amount = *mentor.PriceSingle
}
case "half_year":
if mentor.PriceHalfYear != nil {
amount = *mentor.PriceHalfYear
}
case "year":
if mentor.PriceYear != nil {
amount = *mentor.PriceYear
}
}
if amount <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该咨询类型暂未配置价格"})
return
}
consult := model.MentorConsultation{
UserID: body.UserID,
MentorID: mentorID,
ConsultationType: body.ConsultationType,
Amount: amount,
Status: "created",
}
if err := db.Create(&consult).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
// 返回预约单,前端可据此调 pay 接口productType: mentor_consultation, productId: consult.ID
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": consult.ID,
"mentorId": mentorID,
"consultationType": body.ConsultationType,
"amount": amount,
"status": "created",
},
})
}
// DBMentorsList GET /api/db/mentors 管理端导师列表
func DBMentorsList(c *gin.Context) {
db := database.DB()
var list []model.Mentor
if err := db.Order("sort ASC, id ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// DBMentorsAction POST 新增 / PUT 更新 / DELETE 删除 导师
func DBMentorsAction(c *gin.Context) {
db := database.DB()
method := c.Request.Method
if method == "POST" {
var body struct {
Name string `json:"name" binding:"required"`
Avatar string `json:"avatar"`
Intro string `json:"intro"`
Tags string `json:"tags"`
PriceSingle *float64 `json:"priceSingle"`
PriceHalfYear *float64 `json:"priceHalfYear"`
PriceYear *float64 `json:"priceYear"`
Quote string `json:"quote"`
WhyFind string `json:"whyFind"`
Offering string `json:"offering"`
JudgmentStyle string `json:"judgmentStyle"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 不能为空"})
return
}
m := model.Mentor{
Name: body.Name,
Avatar: body.Avatar,
Intro: body.Intro,
Tags: body.Tags,
PriceSingle: body.PriceSingle,
PriceHalfYear: body.PriceHalfYear,
PriceYear: body.PriceYear,
Quote: body.Quote,
WhyFind: body.WhyFind,
Offering: body.Offering,
JudgmentStyle: body.JudgmentStyle,
Sort: body.Sort,
Enabled: body.Enabled,
}
if m.Enabled == nil {
trueVal := true
m.Enabled = &trueVal
}
if err := db.Create(&m).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": m})
return
}
if method == "PUT" {
var body struct {
ID int `json:"id" binding:"required"`
Name *string `json:"name"`
Avatar *string `json:"avatar"`
Intro *string `json:"intro"`
Tags *string `json:"tags"`
PriceSingle *float64 `json:"priceSingle"`
PriceHalfYear *float64 `json:"priceHalfYear"`
PriceYear *float64 `json:"priceYear"`
Quote *string `json:"quote"`
WhyFind *string `json:"whyFind"`
Offering *string `json:"offering"`
JudgmentStyle *string `json:"judgmentStyle"`
Sort *int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"})
return
}
updates := map[string]interface{}{}
if body.Name != nil {
updates["name"] = *body.Name
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
}
if body.Intro != nil {
updates["intro"] = *body.Intro
}
if body.Tags != nil {
updates["tags"] = *body.Tags
}
if body.PriceSingle != nil {
updates["price_single"] = *body.PriceSingle
}
if body.PriceHalfYear != nil {
updates["price_half_year"] = *body.PriceHalfYear
}
if body.PriceYear != nil {
updates["price_year"] = *body.PriceYear
}
if body.Quote != nil {
updates["quote"] = *body.Quote
}
if body.WhyFind != nil {
updates["why_find"] = *body.WhyFind
}
if body.Offering != nil {
updates["offering"] = *body.Offering
}
if body.JudgmentStyle != nil {
updates["judgment_style"] = *body.JudgmentStyle
}
if body.Sort != nil {
updates["sort"] = *body.Sort
}
if body.Enabled != nil {
updates["enabled"] = *body.Enabled
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "无更新"})
return
}
if err := db.Model(&model.Mentor{}).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": "更新成功"})
return
}
if method == "DELETE" {
id := c.Query("id")
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"})
return
}
if err := db.Where("id = ?", id).Delete(&model.Mentor{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
}
// DBMentorConsultationsList GET /api/db/mentor-consultations 预约列表(支持 status 筛选)
func DBMentorConsultationsList(c *gin.Context) {
db := database.DB()
status := c.Query("status")
query := db.Model(&model.MentorConsultation{})
if status != "" {
query = query.Where("status = ?", status)
}
var list []model.MentorConsultation
if err := query.Order("created_at DESC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}

View 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{}{}})
}

View File

@@ -0,0 +1,831 @@
package handler
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var (
orderPollLogger *log.Logger
orderPollLoggerOnce sync.Once
)
// orderPollLogf 将订单轮询检测日志写入 log/order-poll.log不输出到控制台
func orderPollLogf(format string, args ...interface{}) {
orderPollLoggerOnce.Do(func() {
_ = os.MkdirAll("log", 0755)
f, err := os.OpenFile(filepath.Join("log", "order-poll.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
orderPollLogger = log.New(io.Discard, "", 0)
return
}
orderPollLogger = log.New(f, "[OrderPoll] ", log.Ldate|log.Ltime)
})
if orderPollLogger != nil {
orderPollLogger.Printf(format, args...)
}
}
// 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 == "vip" {
description = "卡若创业派对VIP年度会员365天"
} 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 == "" {
switch req.ProductType {
case "vip":
productID = "vip_annual"
case "match":
productID = "match"
default:
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,
})
orderPollLogf("主动同步订单已支付: %s", 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/notifyv3 支付回调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 == "vip" {
// VIP 支付成功:更新 users.is_vip、vip_expire_date、vip_activated_at排序后付款在前
expireDate := time.Now().AddDate(0, 0, 365)
vipActivatedAt := time.Now()
if order.PayTime != nil {
vipActivatedAt = *order.PayTime
}
db.Model(&model.User{}).Where("id = ?", buyerUserID).Updates(map[string]interface{}{
"is_vip": true,
"vip_expire_date": expireDate,
"vip_activated_at": vipActivatedAt,
})
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
} 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)
}
// 处理分销佣金(会员订单 20%/10%,内容订单 90%
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string, order *model.Order) {
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
}
// 确保 order 有 referrer_id补记订单可能缺失
if order != nil && (order.ReferrerID == nil || *order.ReferrerID == "") {
order.ReferrerID = &binding.ReferrerID
db.Model(order).Update("referrer_id", binding.ReferrerID)
}
// 构建用于计算的 order若为 nil 则用 binding 信息)
calcOrder := order
if calcOrder == nil {
calcOrder = &model.Order{Amount: amount, ProductType: "unknown", ReferrerID: &binding.ReferrerID}
}
commission := computeOrderCommission(db, calcOrder, nil)
if commission <= 0 {
fmt.Printf("[PayNotify] 佣金为 0跳过分佣: orderSn=%s\n", orderSn)
return
}
newPurchaseCount := binding.PurchaseCount + 1
newTotalCommission := binding.TotalCommission + commission
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f\n",
binding.ReferrerID, amount, commission)
db.Model(&model.User{}).Where("id = ?", binding.ReferrerID).
Update("pending_earnings", db.Raw("pending_earnings + ?", commission))
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()
}
// MiniprogramUsers GET /api/miniprogram/users 小程序-用户列表/单个(首页超级个体补充、会员详情回退)
// 支持 ?limit=20 返回列表;?id=xxx 返回单个。返回 { success, data } 格式
func MiniprogramUsers(c *gin.Context) {
id := c.Query("id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit < 1 || limit > 50 {
limit = 20
}
db := database.DB()
if id != "" {
var user model.User
if err := db.Where("id = ?", id).First(&user).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
return
}
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
id, "paid", "fullbook", "vip").Count(&cnt)
// 用户信息与会员资料vip*、P3 资料扩展,供会员详情页完整展示
item := gin.H{
"id": user.ID,
"nickname": getStringValue(user.Nickname),
"avatar": getStringValue(user.Avatar),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"vipName": getStringValue(user.VipName),
"vipAvatar": getStringValue(user.VipAvatar),
"vipContact": getStringValue(user.VipContact),
"vipProject": getStringValue(user.VipProject),
"vipBio": getStringValue(user.VipBio),
"mbti": getStringValue(user.Mbti),
"region": getStringValue(user.Region),
"industry": getStringValue(user.Industry),
"position": getStringValue(user.Position),
"businessScale": getStringValue(user.BusinessScale),
"skills": getStringValue(user.Skills),
"storyBestMonth": getStringValue(user.StoryBestMonth),
"storyAchievement": getStringValue(user.StoryAchievement),
"storyTurning": getStringValue(user.StoryTurning),
"helpOffer": getStringValue(user.HelpOffer),
"helpNeed": getStringValue(user.HelpNeed),
"projectIntro": getStringValue(user.ProjectIntro),
"is_vip": cnt > 0,
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
return
}
var users []model.User
db.Order("created_at DESC").Limit(limit).Find(&users)
list := make([]gin.H, 0, len(users))
for i := range users {
u := &users[i]
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
u.ID, "paid", "fullbook", "vip").Count(&cnt)
list = append(list, gin.H{
"id": u.ID,
"nickname": getStringValue(u.Nickname),
"avatar": getStringValue(u.Avatar),
"is_vip": cnt > 0,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}

View File

@@ -0,0 +1,267 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"sync"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// OrdersList GET /api/orders带用户昵称/头像/手机号,分销佣金按配置比例计算;支持分页 page、pageSize筛选 status搜索 search
func OrdersList(c *gin.Context) {
db := database.DB()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
statusFilter := c.Query("status")
search := strings.TrimSpace(c.Query("search"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
// 预加载 referral_config避免订单循环内 N+1 查询
var refCfgRow model.SystemConfig
refCfg := (*model.SystemConfig)(nil)
if err := db.Where("config_key = ?", "referral_config").First(&refCfgRow).Error; err == nil {
refCfg = &refCfgRow
}
// 构建带筛选的查询count 与 list 共用条件)
applyOrdersFilter := func(q *gorm.DB) *gorm.DB {
if statusFilter != "" && statusFilter != "all" {
if statusFilter == "completed" {
q = q.Where("status IN ?", []string{"paid", "completed"})
} else {
q = q.Where("status = ?", statusFilter)
}
}
if search != "" {
pattern := "%" + search + "%"
q = q.Where("order_sn LIKE ? OR id LIKE ? OR user_id IN (SELECT id FROM users WHERE COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?)",
pattern, pattern, pattern, pattern, pattern)
}
return q
}
var total int64
var totalRevenue, todayRevenue float64
var orders []model.Order
var ordersErr error
var wg sync.WaitGroup
// 并行count、营收统计、订单列表
wg.Add(3)
go func() {
defer wg.Done()
applyOrdersFilter(db.Model(&model.Order{})).Count(&total)
}()
go func() {
defer wg.Done()
db.Model(&model.Order{}).Select("COALESCE(SUM(amount), 0)").
Where("status IN ?", []string{"paid", "completed"}).Scan(&totalRevenue)
todayStart := time.Now().Truncate(24 * time.Hour)
todayEnd := todayStart.Add(24 * time.Hour)
db.Model(&model.Order{}).Select("COALESCE(SUM(amount), 0)").
Where("status IN ? AND created_at >= ? AND created_at < ?", []string{"paid", "completed"}, todayStart, todayEnd).
Scan(&todayRevenue)
}()
go func() {
defer wg.Done()
query := applyOrdersFilter(db.Model(&model.Order{}))
ordersErr = query.Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&orders).Error
}()
wg.Wait()
if ordersErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": ordersErr.Error(), "orders": []interface{}{}, "total": 0})
return
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
if len(orders) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true, "orders": []interface{}{},
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
})
return
}
// 收集订单中的 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)
}
}
// 分销佣金:仅对已支付且存在推荐人的订单,按 computeOrderCommission会员 20%/10%,内容 90%
status := getStr(o.Status)
if status == "paid" && o.ReferrerID != nil && *o.ReferrerID != "" {
var refUser *model.User
if u := userMap[*o.ReferrerID]; u != nil {
refUser = u
}
m["referrerEarnings"] = computeOrderCommission(db, &o, refUser, refCfg)
} else {
m["referrerEarnings"] = nil
}
out = append(out, m)
}
c.JSON(http.StatusOK, gin.H{
"success": true, "orders": out,
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
"totalRevenue": totalRevenue, "todayRevenue": todayRevenue,
})
}
// MiniprogramOrders GET /api/miniprogram/orders 小程序-当前用户订单列表(按 userId 过滤,返回 data
func MiniprogramOrders(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
db := database.DB()
var orders []model.Order
if err := db.Where("user_id = ?", userID).Order("created_at DESC").Find(&orders).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
out := make([]gin.H, 0, len(orders))
for _, o := range orders {
desc := ""
if o.Description != nil {
desc = *o.Description
}
productID := ""
if o.ProductID != nil {
productID = *o.ProductID
}
status := "created"
if o.Status != nil {
status = *o.Status
}
out = append(out, gin.H{
"id": o.ID, "order_sn": o.OrderSN, "user_id": o.UserID,
"product_id": productID, "product_type": o.ProductType,
"product_name": desc, "section_id": productID,
"amount": o.Amount, "status": status,
"created_at": o.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
// AdminOrderRefund PUT /api/admin/orders/refund 管理端-订单退款(仅支持已支付订单,调用微信支付退款)
func AdminOrderRefund(c *gin.Context) {
var req struct {
OrderSn string `json:"orderSn" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少订单号"})
return
}
db := database.DB()
var order model.Order
if err := db.Where("order_sn = ?", req.OrderSn).First(&order).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "订单不存在"})
return
}
status := ""
if order.Status != nil {
status = *order.Status
}
if status != "paid" && status != "completed" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅支持已支付订单退款"})
return
}
transactionID := ""
if order.TransactionID != nil {
transactionID = *order.TransactionID
}
if transactionID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "订单缺少微信支付单号,无法退款"})
return
}
totalCents := int(order.Amount * 100)
if totalCents < 1 {
totalCents = 1
}
if err := wechat.RefundOrder(context.Background(), order.OrderSN, transactionID, totalCents, req.Reason); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "微信退款失败: " + err.Error()})
return
}
refunded := "refunded"
updates := map[string]interface{}{"status": refunded}
if req.Reason != "" {
updates["refund_reason"] = req.Reason
}
if err := db.Model(&order).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": "退款成功"})
}

View 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)
}

View File

@@ -0,0 +1,525 @@
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 []model.Order
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders)
totalAmount := 0.0
totalCommission := 0.0
uniqueUsers := make(map[string]bool)
for i := range paidOrders {
totalAmount += paidOrders[i].Amount
totalCommission += computeOrderCommission(db, &paidOrders[i], nil)
uniqueUsers[paidOrders[i].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 i := range earningsDetailsList {
e := &earningsDetailsList[i]
var buyer model.User
db.Where("id = ?", e.UserID).First(&buyer)
commission := computeOrderCommission(db, e, nil)
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 已按订单逐条计算)
estimatedEarnings := totalCommission
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()
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 []model.Order
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders)
totalCommission := 0.0
for i := range paidOrders {
totalCommission += computeOrderCommission(db, &paidOrders[i], nil)
}
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
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
}

View File

@@ -0,0 +1,79 @@
package handler
import (
"encoding/json"
"time"
"soul-api/internal/model"
"gorm.io/gorm"
)
// computeOrderCommission 按订单计算应付给推广者的佣金
// 会员订单:推广者会员 20%、非会员 10%内容订单90%(好友优惠 5% 仅针对内容)
// order: 已支付订单,需有 product_type、amount、referrer_id
// referrerUser: 推广者用户信息,用于判断 is_vip可为 nil会查库
// preloadConfig: 可选,预加载的 referral_config避免 N+1 查询
func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model.User, preloadConfig ...*model.SystemConfig) float64 {
if order == nil || order.ReferrerID == nil || *order.ReferrerID == "" {
return 0
}
// 读取推广配置
distributorShare := 0.9
userDiscount := 0.0
vipOrderShareVip := 20.0
vipOrderShareNonVip := 10.0
var cfg *model.SystemConfig
if len(preloadConfig) > 0 && preloadConfig[0] != nil {
cfg = preloadConfig[0]
} else if row, err := (func() (*model.SystemConfig, error) {
var r model.SystemConfig
e := db.Where("config_key = ?", "referral_config").First(&r).Error
return &r, e
})(); err == nil {
cfg = row
}
if cfg != 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
}
if v, ok := config["vipOrderShareVip"].(float64); ok {
vipOrderShareVip = v / 100
}
if v, ok := config["vipOrderShareNonVip"].(float64); ok {
vipOrderShareNonVip = v / 100
}
}
}
// 会员订单:无好友优惠,按推广者是否会员分 20%/10%
if order.ProductType == "vip" {
base := order.Amount
var referrer model.User
if referrerUser != nil {
referrer = *referrerUser
} else if err := db.Where("id = ?", *order.ReferrerID).First(&referrer).Error; err != nil {
return 0
}
isVip := referrer.IsVip != nil && *referrer.IsVip
if referrer.VipExpireDate != nil && referrer.VipExpireDate.Before(time.Now()) {
isVip = false
}
if isVip {
return base * vipOrderShareVip
}
return base * vipOrderShareNonVip
}
// 内容订单:若有推荐人且 userDiscount>0反推原价否则按实付
commissionBase := order.Amount
if userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || (order.ReferralCode != nil && *order.ReferralCode != "")) {
if (1 - userDiscount) > 0 {
commissionBase = order.Amount / (1 - userDiscount)
}
}
return commissionBase * distributorShare
}

View 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},
})
}

View 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})
}

View 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": "删除成功"})
}

View File

@@ -0,0 +1,609 @@
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
q := db.Select("id", "open_id", "nickname", "avatar", "phone", "wechat_id", "referral_code",
"has_full_book", "earnings", "pending_earnings", "referral_count", "created_at",
"mbti", "region", "industry", "position", "business_scale", "skills",
"story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro")
if userId != "" {
q = q.Where("id = ?", userId)
} else {
q = q.Where("open_id = ?", openId)
}
if err := q.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
str := func(p *string) interface{} { if p != nil { return *p }; return "" }
resp := gin.H{
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar),
"phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode,
"hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings,
"referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar,
"createdAt": user.CreatedAt,
// P3 资料扩展:统一返回所有表单字段,空值用 "" 便于前端回显
"mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry),
"position": str(user.Position), "businessScale": str(user.BusinessScale), "skills": str(user.Skills),
"storyBestMonth": str(user.StoryBestMonth), "storyAchievement": str(user.StoryAchievement),
"storyTurning": str(user.StoryTurning), "helpOffer": str(user.HelpOffer), "helpNeed": str(user.HelpNeed),
"projectIntro": str(user.ProjectIntro),
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
// 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"`
Mbti *string `json:"mbti"`
Region *string `json:"region"`
Industry *string `json:"industry"`
Position *string `json:"position"`
BusinessScale *string `json:"businessScale"`
Skills *string `json:"skills"`
StoryBestMonth *string `json:"storyBestMonth"`
StoryAchievement *string `json:"storyAchievement"`
StoryTurning *string `json:"storyTurning"`
HelpOffer *string `json:"helpOffer"`
HelpNeed *string `json:"helpNeed"`
ProjectIntro *string `json:"projectIntro"`
}
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 body.Mbti != nil { updates["mbti"] = *body.Mbti }
if body.Region != nil { updates["region"] = *body.Region }
if body.Industry != nil { updates["industry"] = *body.Industry }
if body.Position != nil { updates["position"] = *body.Position }
if body.BusinessScale != nil { updates["business_scale"] = *body.BusinessScale }
if body.Skills != nil { updates["skills"] = *body.Skills }
if body.StoryBestMonth != nil { updates["story_best_month"] = *body.StoryBestMonth }
if body.StoryAchievement != nil { updates["story_achievement"] = *body.StoryAchievement }
if body.StoryTurning != nil { updates["story_turning"] = *body.StoryTurning }
if body.HelpOffer != nil { updates["help_offer"] = *body.HelpOffer }
if body.HelpNeed != nil { updates["help_need"] = *body.HelpNeed }
if body.ProjectIntro != nil { updates["project_intro"] = *body.ProjectIntro }
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "没有需要更新的字段"})
return
}
updates["updated_at"] = time.Now()
db.Model(&user).Updates(updates)
// 重新查询并返回与 GET 一致的完整资料结构,空值统一为 ""
profileCols := []string{"id", "open_id", "nickname", "avatar", "phone", "wechat_id", "referral_code", "created_at",
"mbti", "region", "industry", "position", "business_scale", "skills",
"story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro"}
if err := database.DB().Select(profileCols).Where("id = ?", user.ID).First(&user).Error; err == nil {
str := func(p *string) interface{} { if p != nil { return *p }; return "" }
resp := gin.H{
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar),
"phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode,
"createdAt": user.CreatedAt,
"mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry),
"position": str(user.Position), "businessScale": str(user.BusinessScale), "skills": str(user.Skills),
"storyBestMonth": str(user.StoryBestMonth), "storyAchievement": str(user.StoryAchievement),
"storyTurning": str(user.StoryTurning), "helpOffer": str(user.HelpOffer), "helpNeed": str(user.HelpNeed),
"projectIntro": str(user.ProjectIntro),
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": resp})
} else {
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": "更新成功"})
}

View File

@@ -0,0 +1,356 @@
package handler
import (
"net/http"
"strconv"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 默认 VIP 价格与权益(与 next-project 一致)
const defaultVipPrice = 1980
var defaultVipRights = []string{
"智能纪要 - 每天推送派对精华",
"会议纪要库 - 所有场次会议纪要",
"案例库 - 30-100个创业项目案例",
"链接资源 - 进群聊天链接资源",
"解锁全部章节内容365天",
"匹配所有创业伙伴",
"创业老板排行榜展示",
"专属VIP标识",
}
// isVipFromUsers 从 users 表判断是否 VIPis_vip=1 且 vip_expire_date>NOW
func isVipFromUsers(db *gorm.DB, userID string) (bool, *time.Time) {
var u struct {
IsVip *bool
VipExpireDate *time.Time
}
err := db.Table("users").Select("is_vip", "vip_expire_date").Where("id = ?", userID).First(&u).Error
if err != nil || u.IsVip == nil || !*u.IsVip || u.VipExpireDate == nil {
return false, nil
}
if u.VipExpireDate.Before(time.Now()) {
return false, nil
}
return true, u.VipExpireDate
}
// isVipFromOrders 从 orders 表判断是否 VIP兜底
func isVipFromOrders(db *gorm.DB, userID string) (bool, *time.Time) {
var order model.Order
err := db.Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
userID, "paid", "completed", "fullbook", "vip").
Order("pay_time DESC").First(&order).Error
if err != nil || order.PayTime == nil {
return false, nil
}
exp := order.PayTime.AddDate(0, 0, 365)
if exp.Before(time.Now()) {
return false, nil
}
return true, &exp
}
// VipStatus GET /api/miniprogram/vip/status 小程序-查询用户 VIP 状态
// 优先 users 表is_vip、vip_expire_date无则从 orders 兜底
func VipStatus(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
// 1. 优先 users 表
isVip, expireDate := isVipFromUsers(db, userID)
if !isVip {
// 2. 兜底:从 orders 查
isVip, expireDate = isVipFromOrders(db, userID)
if !isVip {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"isVip": false,
"daysRemaining": 0,
"expireDate": "",
"profile": gin.H{"vipName": "", "vipProject": "", "vipContact": "", "vipAvatar": "", "vipBio": ""},
"price": float64(defaultVipPrice),
"rights": defaultVipRights,
},
})
return
}
}
// 查用户 VIP 资料profile
var user model.User
_ = db.Where("id = ?", userID).First(&user).Error
profile := buildVipProfile(&user)
daysRemaining := 0
expStr := ""
if expireDate != nil {
daysRemaining = int(expireDate.Sub(time.Now()).Hours()/24) + 1
if daysRemaining < 0 {
daysRemaining = 0
}
expStr = expireDate.Format("2006-01-02")
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"isVip": true,
"daysRemaining": daysRemaining,
"expireDate": expStr,
"profile": profile,
"price": float64(defaultVipPrice),
"rights": defaultVipRights,
},
})
}
// buildVipProfile 仅从 vip_* 字段构建会员资料不混入用户信息nickname/avatar/phone/wechat_id
// 返回字段与 users 表 vip_* 对应,统一 vipName/vipProject/vipContact/vipAvatar/vipBio
func buildVipProfile(u *model.User) gin.H {
return gin.H{
"vipName": getStr(u.VipName),
"vipProject": getStr(u.VipProject),
"vipContact": getStr(u.VipContact),
"vipAvatar": getStr(u.VipAvatar),
"vipBio": getStr(u.VipBio),
}
}
func getStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// VipProfileGet GET /api/miniprogram/vip/profile 小程序-获取 VIP 资料
func VipProfileGet(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusOK, 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.StatusOK, gin.H{"success": true, "data": gin.H{"vipName": "", "vipProject": "", "vipContact": "", "vipAvatar": "", "vipBio": ""}})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": buildVipProfile(&user),
})
}
// VipProfilePost POST /api/miniprogram/vip/profile 小程序-更新 VIP 资料
// 请求/响应字段与 users 表 vip_* 一致vipName/vipProject/vipContact/vipAvatar/vipBio
func VipProfilePost(c *gin.Context) {
var req struct {
UserID string `json:"userId" binding:"required"`
VipName string `json:"vipName"`
VipProject string `json:"vipProject"`
VipContact string `json:"vipContact"`
VipAvatar string `json:"vipAvatar"`
VipBio string `json:"vipBio"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
db := database.DB()
// 校验是否 VIPusers 或 orders
isVip, _ := isVipFromUsers(db, req.UserID)
if !isVip {
isVip, _ = isVipFromOrders(db, req.UserID)
}
if !isVip {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅VIP会员可填写资料"})
return
}
updates := map[string]interface{}{}
if req.VipName != "" {
updates["vip_name"] = req.VipName
}
if req.VipProject != "" {
updates["vip_project"] = req.VipProject
}
if req.VipContact != "" {
updates["vip_contact"] = req.VipContact
}
if req.VipAvatar != "" {
updates["vip_avatar"] = req.VipAvatar
}
if req.VipBio != "" {
updates["vip_bio"] = req.VipBio
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无更新内容"})
return
}
if err := db.Model(&model.User{}).Where("id = ?", req.UserID).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "更新失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料已更新"})
}
// VipMembers GET /api/miniprogram/vip/members 小程序-VIP 会员列表(无 id 返回列表;有 id 返回单个)
// 优先 users 表is_vip=1 且 vip_expire_date>NOW无则从 orders 兜底
func VipMembers(c *gin.Context) {
id := c.Query("id")
limit := 20
if l := c.Query("limit"); l != "" {
if n, err := parseInt(l); err == nil && n > 0 && n <= 100 {
limit = n
}
}
db := database.DB()
if id != "" {
// 单个:优先 users 表
var user model.User
if err := db.Where("id = ?", id).First(&user).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
return
}
isVip, _ := isVipFromUsers(db, id)
if !isVip {
isVip, _ = isVipFromOrders(db, id)
}
if !isVip {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "会员不存在或已过期"})
return
}
item := formatVipMember(&user, true)
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
return
}
// 列表:优先 users 表is_vip=1 且 vip_expire_date>NOW排序vip_sort 优先(小在前),否则 vip_activated_at DESC
var users []model.User
err := db.Table("users").
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort").
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
Limit(limit).
Find(&users).Error
if err != nil || len(users) == 0 {
// 兜底:从 orders 查
var userIDs []string
db.Model(&model.Order{}).Select("DISTINCT user_id").
Where("(status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", "paid", "completed", "fullbook", "vip").
Pluck("user_id", &userIDs)
if len(userIDs) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "total": 0})
return
}
db.Where("id IN ?", userIDs).Find(&users)
}
list := make([]gin.H, 0, len(users))
for i := range users {
list = append(list, formatVipMember(&users[i], true))
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
}
// formatVipMember 构建会员展示数据;超级个体头像和昵称用用户资料(随「我的」修改实时生效)
// 优先 nickname/avatar无则回退 vip_name/vip_avatar用于首页超级个体、会员详情页等
func formatVipMember(u *model.User, isVip bool) gin.H {
name := ""
if u.Nickname != nil && *u.Nickname != "" {
name = *u.Nickname
}
if name == "" && u.VipName != nil && *u.VipName != "" {
name = *u.VipName
}
if name == "" {
name = "创业者"
}
avatar := ""
if u.Avatar != nil && *u.Avatar != "" {
avatar = *u.Avatar
}
if avatar == "" && u.VipAvatar != nil && *u.VipAvatar != "" {
avatar = *u.VipAvatar
}
project := getStringValue(u.VipProject)
if project == "" {
project = getStringValue(u.ProjectIntro)
}
bio := ""
if u.VipBio != nil {
bio = *u.VipBio
}
contact := ""
if u.VipContact != nil {
contact = *u.VipContact
}
if contact == "" {
contact = getStringValue(u.Phone)
}
vipRole := ""
if u.VipRole != nil {
vipRole = *u.VipRole
}
return gin.H{
"id": u.ID,
"name": name,
"nickname": name,
"avatar": avatar,
"vip_name": name,
"vipName": name,
"vipRole": vipRole,
"vip_avatar": avatar,
"vipAvatar": avatar,
"vipProject": project,
"vip_project": project,
"vipContact": contact,
"vip_contact": contact,
"vipBio": bio,
"wechatId": getStringValue(u.WechatID),
"wechat_id": getStringValue(u.WechatID),
"phone": getStringValue(u.Phone),
"mbti": getStringValue(u.Mbti),
"region": getStringValue(u.Region),
"industry": getStringValue(u.Industry),
"position": getStringValue(u.Position),
"businessScale": getStringValue(u.BusinessScale),
"business_scale": getStringValue(u.BusinessScale),
"skills": getStringValue(u.Skills),
"storyBestMonth": getStringValue(u.StoryBestMonth),
"story_best_month": getStringValue(u.StoryBestMonth),
"storyAchievement": getStringValue(u.StoryAchievement),
"story_achievement": getStringValue(u.StoryAchievement),
"storyTurning": getStringValue(u.StoryTurning),
"story_turning": getStringValue(u.StoryTurning),
"helpOffer": getStringValue(u.HelpOffer),
"help_offer": getStringValue(u.HelpOffer),
"helpNeed": getStringValue(u.HelpNeed),
"help_need": getStringValue(u.HelpNeed),
"projectIntro": getStringValue(u.ProjectIntro),
"project_intro": getStringValue(u.ProjectIntro),
"is_vip": isVip,
}
}
func parseInt(s string) (int, error) {
return strconv.Atoi(s)
}

View File

@@ -0,0 +1,90 @@
package handler
import (
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBVipRolesList GET /api/db/vip-roles 角色列表(管理端 Set VIP 下拉用)
func DBVipRolesList(c *gin.Context) {
db := database.DB()
var roles []model.VipRole
if err := db.Order("sort ASC, id ASC").Find(&roles).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": roles})
}
// DBVipRolesAction POST /api/db/vip-roles 新增角色PUT 更新DELETE 删除
func DBVipRolesAction(c *gin.Context) {
db := database.DB()
method := c.Request.Method
if method == "POST" {
var body struct {
Name string `json:"name" binding:"required"`
Sort int `json:"sort"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 不能为空"})
return
}
role := model.VipRole{Name: body.Name, Sort: body.Sort}
if err := db.Create(&role).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": role})
return
}
if method == "PUT" {
var body struct {
ID int `json:"id" binding:"required"`
Name *string `json:"name"`
Sort *int `json:"sort"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"})
return
}
updates := map[string]interface{}{}
if body.Name != nil {
updates["name"] = *body.Name
}
if body.Sort != nil {
updates["sort"] = *body.Sort
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
return
}
if err := db.Model(&model.VipRole{}).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": "更新成功"})
return
}
if method == "DELETE" {
id := c.Query("id")
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"})
return
}
if err := db.Where("id = ?", id).Delete(&model.VipRole{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
}

View 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() 的 codephoneCode 为 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
}

View File

@@ -0,0 +1,363 @@
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 页可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核
// 佣金按订单逐条 computeOrderCommission 求和(会员订单 20%/10%,内容订单 90%
func computeAvailableWithdraw(db *gorm.DB, userID string) (available, totalCommission, withdrawn, pending float64, minAmount float64) {
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 m, ok := config["minWithdrawAmount"].(float64); ok {
minAmount = m
}
}
}
var orders []model.Order
db.Where("referrer_id = ? AND status = ?", userID, "paid").Find(&orders)
for i := range orders {
totalCommission += computeOrderCommission(db, &orders[i], nil)
}
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": "已记录确认收款"})
}

View 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,
})
}

View File

@@ -0,0 +1,44 @@
package middleware
import (
"net/http"
"soul-api/internal/auth"
"soul-api/internal/config"
"github.com/gin-gonic/gin"
)
const adminClaimsKey = "admin_claims"
// AdminAuth 管理端鉴权:校验 JWTAuthorization: Bearer 或 Cookie admin_session未登录返回 401通过则设置 admin_claims 到 context
func AdminAuth() gin.HandlerFunc {
return func(c *gin.Context) {
cfg := config.Get()
if cfg == nil {
c.Next()
return
}
token := auth.GetAdminJWTFromRequest(c.Request)
claims, ok := auth.ParseAdminJWT(token, cfg.AdminSessionSecret)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未授权访问,请先登录"})
return
}
c.Set(adminClaimsKey, claims)
c.Next()
}
}
// GetAdminClaims 从 context 获取 admin claims需在 AdminAuth 之后调用)
func GetAdminClaims(c *gin.Context) *auth.AdminClaims {
v, ok := c.Get(adminClaimsKey)
if !ok || v == nil {
return nil
}
claims, ok := v.(*auth.AdminClaims)
if !ok {
return nil
}
return claims
}

View 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()
}
}()
}

View 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()
}
}

View File

@@ -0,0 +1 @@
在此目录放置 GORM 模型与请求/响应结构体,例如 User、Order、Withdrawal、Config 等。

View File

@@ -0,0 +1,24 @@
package model
import (
"time"
"gorm.io/gorm"
)
// AdminUser 后台管理员用户
type AdminUser struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"column:username;size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"column:password_hash;size:128;not null" json:"-"`
Role string `gorm:"column:role;size:32;not null;default:admin" json:"role"` // super_admin | admin
Name string `gorm:"column:name;size:64" json:"name"`
Status string `gorm:"column:status;size:16;not null;default:active" json:"status"` // active | disabled
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (AdminUser) TableName() string {
return "admin_users"
}

View File

@@ -0,0 +1,19 @@
package model
import "time"
// AuthorConfig 作者详情配置,小程序「关于作者」页展示,单行记录
type AuthorConfig struct {
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Name string `gorm:"column:name;size:80;not null" json:"name"`
Avatar string `gorm:"column:avatar;size:4" json:"avatar"` // 首字母占位,无头像时显示
AvatarImg string `gorm:"column:avatar_img;size:500" json:"avatarImg"` // 头像图片 URL
Title string `gorm:"column:title;size:200" json:"title"`
Bio string `gorm:"column:bio;type:text" json:"bio"`
Stats string `gorm:"column:stats;type:text" json:"-"` // JSON: [{"label":"","value":""}]
Highlights string `gorm:"column:highlights;type:text" json:"-"` // JSON: ["a","b"]
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (AuthorConfig) TableName() string { return "author_config" }

View File

@@ -0,0 +1,28 @@
package model
import "time"
// Chapter 对应表 chaptersmid 为自增主键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"`
IsNew *bool `gorm:"column:is_new" json:"isNew,omitempty"` // stitch_soul目录/首页「最新新增」标记
// 普通版/增值版:两者分开互斥,添加文章时勾选归属
EditionStandard *bool `gorm:"column:edition_standard" json:"editionStandard,omitempty"` // 是否属于普通版
EditionPremium *bool `gorm:"column:edition_premium" json:"editionPremium,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" }

View 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" }

View File

@@ -0,0 +1,25 @@
package model
import "time"
// Mentor stitch_soul 导师,独立于 match 类型
type Mentor struct {
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Avatar string `gorm:"column:avatar;size:500" json:"avatar"`
Name string `gorm:"column:name;size:80;not null" json:"name"`
Intro string `gorm:"column:intro;size:500" json:"intro"` // 简介/头衔
Tags string `gorm:"column:tags;size:500" json:"tags"` // 技能标签,逗号分隔
PriceSingle *float64 `gorm:"column:price_single;type:decimal(10,2)" json:"priceSingle"` // 单次咨询
PriceHalfYear *float64 `gorm:"column:price_half_year;type:decimal(10,2)" json:"priceHalfYear"` // 半年
PriceYear *float64 `gorm:"column:price_year;type:decimal(10,2)" json:"priceYear"` // 年度
Quote string `gorm:"column:quote;size:500" json:"quote"` // 引言
WhyFind string `gorm:"column:why_find;type:text" json:"whyFind"` // 为什么找
Offering string `gorm:"column:offering;type:text" json:"offering"` // 提供什么
JudgmentStyle string `gorm:"column:judgment_style;size:500" json:"judgmentStyle"` // 判断风格,逗号分隔
Sort int `gorm:"column:sort;default:0" json:"sort"`
Enabled *bool `gorm:"column:enabled;default:1" json:"enabled"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Mentor) TableName() string { return "mentors" }

View File

@@ -0,0 +1,19 @@
package model
import "time"
// MentorConsultation 导师预约单
type MentorConsultation struct {
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID int `gorm:"column:user_id;not null;index" json:"userId"`
MentorID int `gorm:"column:mentor_id;not null;index" json:"mentorId"`
ConsultationType string `gorm:"column:consultation_type;size:20;not null" json:"consultationType"` // single, half_year, year
Amount float64 `gorm:"column:amount;type:decimal(10,2);not null" json:"amount"`
Status string `gorm:"column:status;size:20;default:created;index" json:"status"` // created, pending_pay, paid, completed, cancelled
OrderID *int `gorm:"column:order_id" json:"orderId,omitempty"` // 关联支付订单
ScheduledAt *time.Time `gorm:"column:scheduled_at" json:"scheduledAt,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (MentorConsultation) TableName() string { return "mentor_consultations" }

View File

@@ -0,0 +1,25 @@
package model
import "time"
// Order 对应表 ordersJSON 输出与现网接口 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"`
RefundReason *string `gorm:"column:refund_reason;size:500" json:"refundReason,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" }

View 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" }

View 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" }

View 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" }

View File

@@ -0,0 +1,35 @@
package model
import (
"database/sql/driver"
"time"
)
// ConfigValue 存 system_config.config_valueJSON 列,可为 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_configJSON 输出与现网 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" }

View File

@@ -0,0 +1,55 @@
package model
import "time"
// User 对应表 usersJSON 输出与现网接口 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"`
// P3 资料扩展stitch_soul
Mbti *string `gorm:"column:mbti;size:16" json:"mbti,omitempty"`
Region *string `gorm:"column:region;size:100" json:"region,omitempty"`
Industry *string `gorm:"column:industry;size:100" json:"industry,omitempty"`
Position *string `gorm:"column:position;size:100" json:"position,omitempty"`
BusinessScale *string `gorm:"column:business_scale;size:100" json:"businessScale,omitempty"`
Skills *string `gorm:"column:skills;size:500" json:"skills,omitempty"`
StoryBestMonth *string `gorm:"column:story_best_month;type:text" json:"storyBestMonth,omitempty"`
StoryAchievement *string `gorm:"column:story_achievement;type:text" json:"storyAchievement,omitempty"`
StoryTurning *string `gorm:"column:story_turning;type:text" json:"storyTurning,omitempty"`
HelpOffer *string `gorm:"column:help_offer;size:500" json:"helpOffer,omitempty"`
HelpNeed *string `gorm:"column:help_need;size:500" json:"helpNeed,omitempty"`
ProjectIntro *string `gorm:"column:project_intro;type:text" json:"projectIntro,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"`
// VIP 相关(与 next-project 线上 users 表一致,支持手动设置;管理端需读写)
IsVip *bool `gorm:"column:is_vip" json:"isVip,omitempty"`
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"vipExpireDate,omitempty"`
VipActivatedAt *time.Time `gorm:"column:vip_activated_at" json:"vipActivatedAt,omitempty"` // 成为 VIP 时间,排序用:付款=pay_time手动=now
VipSort *int `gorm:"column:vip_sort" json:"vipSort,omitempty"` // 手动排序越小越前NULL 按 vip_activated_at
VipRole *string `gorm:"column:vip_role;size:50" json:"vipRole,omitempty"` // 角色:从 vip_roles 选或手动填写
VipName *string `gorm:"column:vip_name;size:100" json:"vipName,omitempty"`
VipAvatar *string `gorm:"column:vip_avatar;size:500" json:"vipAvatar,omitempty"`
VipProject *string `gorm:"column:vip_project;size:200" json:"vipProject,omitempty"`
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`
}
func (User) TableName() string { return "users" }

View 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" }

View 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" }

View File

@@ -0,0 +1,14 @@
package model
import "time"
// VipRole 超级个体固定角色,用于 Set VIP 时下拉选择
type VipRole struct {
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Name string `gorm:"column:name;size:50;not null" json:"name"`
Sort int `gorm:"column:sort;default:0" json:"sort"` // 下拉展示顺序,越小越前
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (VipRole) TableName() string { return "vip_roles" }

View 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" }

View 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" }

View File

@@ -0,0 +1 @@
在此目录放置数据库访问层,供 service 调用,例如 UserRepo、OrderRepo、ConfigRepo 等。

View File

@@ -0,0 +1,317 @@
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)
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
admin.PUT("/orders/refund", handler.AdminOrderRefund)
admin.GET("/users", handler.AdminUsersList)
admin.POST("/users", handler.AdminUsersAction)
admin.PUT("/users", handler.AdminUsersAction)
admin.DELETE("/users", handler.AdminUsersAction)
}
// ----- 鉴权 -----
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/recommended", handler.BookRecommended)
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)
db.GET("/vip-roles", handler.DBVipRolesList)
db.POST("/vip-roles", handler.DBVipRolesAction)
db.PUT("/vip-roles", handler.DBVipRolesAction)
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/match-records", handler.DBMatchRecordsList)
db.GET("/mentors", handler.DBMentorsList)
db.POST("/mentors", handler.DBMentorsAction)
db.PUT("/mentors", handler.DBMentorsAction)
db.DELETE("/mentors", handler.DBMentorsAction)
db.GET("/mentor-consultations", handler.DBMentorConsultationsList)
}
// ----- 分销 -----
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/recommended", handler.BookRecommended)
miniprogram.GET("/book/latest-chapters", handler.BookLatestChapters)
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("/ckb/lead", handler.CKBLead)
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)
// VIP 接口(小程序专用,按使用方区分路径)
miniprogram.GET("/vip/status", handler.VipStatus)
miniprogram.GET("/vip/profile", handler.VipProfileGet)
miniprogram.POST("/vip/profile", handler.VipProfilePost)
miniprogram.GET("/vip/members", handler.VipMembers)
// 用户列表/单个(首页超级个体、会员详情回退)
miniprogram.GET("/users", handler.MiniprogramUsers)
miniprogram.GET("/orders", handler.MiniprogramOrders)
// 导师stitch_soul
miniprogram.GET("/mentors", handler.MiniprogramMentorsList)
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
}
// ----- 提现 -----
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
}

View File

@@ -0,0 +1 @@
在此目录放置业务逻辑,供 handler 调用,例如 AdminService、UserService、PaymentService 等。

View File

@@ -0,0 +1,449 @@
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"
subrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/basicService/subscribeMessage/request"
"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"
)
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 返回支付回调地址(从 config.BaseURL 派生,与商户平台配置一致)
func GetPayNotifyURL() string {
if cfg != nil && cfg.WechatNotifyURL != "" {
return cfg.WechatNotifyURL
}
if cfg != nil && cfg.BaseURL != "" {
return cfg.BaseURLJoin("/api/miniprogram/pay/notify")
}
return "https://soulapi.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 为接收人 openidamount 为提现金额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
}

View File

@@ -0,0 +1,41 @@
package wechat
import (
"context"
"fmt"
"time"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment/refund/request"
)
// RefundOrder 调用微信支付 v3 退款接口(全额退款)
// orderSn: 商户订单号transactionID: 微信支付单号(二选一,有则优先用 transactionIDtotalCents: 订单金额reason: 退款原因
func RefundOrder(ctx context.Context, orderSn, transactionID string, totalCents int, reason string) error {
if paymentApp == nil || paymentApp.Refund == nil {
return fmt.Errorf("支付/退款未初始化")
}
if totalCents <= 0 {
return fmt.Errorf("退款金额必须大于 0")
}
outRefundNo := fmt.Sprintf("RF%s%06d", time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
if reason == "" {
reason = "用户申请退款"
}
req := &request.RequestRefund{
OutRefundNo: outRefundNo,
Reason: reason,
Amount: &request.RefundAmount{
Refund: totalCents,
Total: totalCents,
Currency: "CNY",
From: []*request.RefundAmountFrom{},
},
}
if transactionID != "" {
req.TransactionID = transactionID
} else {
req.OutTradeNo = orderSn
}
_, err := paymentApp.Refund.Refund(ctx, req)
return err
}

View 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
}

View 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, "")
}

View 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
}

View 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
}

1
soul-api/qgL5DeGe9A.txt Normal file
View File

@@ -0,0 +1 @@
16d770afdc8b7273eb7a93814af01b23

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ""

Some files were not shown because too many files have changed in this diff Show More