diff --git a/.gitignore b/.gitignore index 8050feeb..321aa95a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ soul-api/wechat/info.log next-project soul-admin/node_modules soul-api -soul-api.exe \ No newline at end of file +soul-api.exe +soul-api diff --git a/soul-api/.ArtisanCloud/cache b/soul-api/.ArtisanCloud/cache deleted file mode 100644 index dc7a404b..00000000 Binary files a/soul-api/.ArtisanCloud/cache and /dev/null differ diff --git a/soul-api/.air.toml b/soul-api/.air.toml deleted file mode 100644 index f530a27d..00000000 --- a/soul-api/.air.toml +++ /dev/null @@ -1,25 +0,0 @@ -# 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 diff --git a/soul-api/.env b/soul-api/.env deleted file mode 100644 index 2bff56d2..00000000 --- a/soul-api/.env +++ /dev/null @@ -1,43 +0,0 @@ -# 服务(启动端口在 .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://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 -# 公钥证书(本地或 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem -WECHAT_CERT_PATH=certs/apiclient_cert.pem -# 私钥(线上用 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem -WECHAT_KEY_PATH=certs/apiclient_key.pem -WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5 -# 转账回调:未设置时由 API_BASE_URL + /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 diff --git a/soul-api/.gitignore b/soul-api/.gitignore deleted file mode 100644 index b2d0c993..00000000 --- a/soul-api/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -tmp/ -log/ -soul-api -server.exe -soul-api.exe diff --git a/soul-api/Makefile b/soul-api/Makefile deleted file mode 100644 index f4970f68..00000000 --- a/soul-api/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -# 开发:热重载(需先安装 air: go install github.com/air-verse/air@latest) -dev: - air - -# 普通运行(无热重载) -run: - go run ./cmd/server - -.PHONY: dev run diff --git a/soul-api/__pycache__/devlop.cpython-311.pyc b/soul-api/__pycache__/devlop.cpython-311.pyc deleted file mode 100644 index f515d814..00000000 Binary files a/soul-api/__pycache__/devlop.cpython-311.pyc and /dev/null differ diff --git a/soul-api/certs/apiclient_cert.pem b/soul-api/certs/apiclient_cert.pem deleted file mode 100644 index ef4a885a..00000000 --- a/soul-api/certs/apiclient_cert.pem +++ /dev/null @@ -1,25 +0,0 @@ ------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----- diff --git a/soul-api/certs/apiclient_key.pem b/soul-api/certs/apiclient_key.pem deleted file mode 100644 index ac63c73d..00000000 --- a/soul-api/certs/apiclient_key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------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----- diff --git a/soul-api/cmd/server/main.go b/soul-api/cmd/server/main.go deleted file mode 100644 index ea54c78b..00000000 --- a/soul-api/cmd/server/main.go +++ /dev/null @@ -1,81 +0,0 @@ -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") -} diff --git a/soul-api/dev_dev.py b/soul-api/dev_dev.py deleted file mode 100644 index dcd798e5..00000000 --- a/soul-api/dev_dev.py +++ /dev/null @@ -1,391 +0,0 @@ -#!/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_project,POST 带 action、project_name - for action in ("stop_go_project", "start_go_project"): - data = dict(params) - data["action"] = action - data["project_name"] = name - r = requests.post( - base + "/plugin?name=go_project", - data=data, - timeout=15, - verify=False, - ) - if r.status_code != 200: - continue - j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} - if action == "stop_go_project": - time.sleep(2) - if j.get("status") is False and j.get("msg"): - print(" [宝塔API] %s: %s" % (action, j.get("msg", ""))) - # 再调一次 start 确保启动 - data = dict(params) - data["action"] = "start_go_project" - data["project_name"] = name - r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False) - if r.status_code == 200: - j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} - if j.get("status") is True: - print(" [成功] 已通过宝塔 API 重启 Go 项目: %s" % name) - return True - return False - except Exception as e: - print(" [宝塔API 失败] %s" % str(e)) - return False - - -# ==================== SSH 上传 ==================== - - -def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"): - """上传 tar.gz 到服务器并解压、重启""" - print("[3/4] SSH 上传并解压 ...") - if not cfg.get("password") and not cfg.get("ssh_key"): - print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") - return False - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): - client.connect( - cfg["host"], port=DEFAULT_SSH_PORT, - username=cfg["user"], key_filename=cfg["ssh_key"], - timeout=15, - ) - else: - client.connect( - cfg["host"], port=DEFAULT_SSH_PORT, - username=cfg["user"], password=cfg["password"], - timeout=15, - ) - sftp = client.open_sftp() - remote_tar = "/tmp/soul_api_deploy.tar.gz" - project_path = cfg["project_path"] - sftp.put(tarball_path, remote_tar) - sftp.close() - - cmd = ( - "mkdir -p %s && cd %s && tar -xzf %s && " - "chmod +x soul-api && rm -f %s && echo OK" - ) % (project_path, project_path, remote_tar, remote_tar) - stdin, stdout, stderr = client.exec_command(cmd, timeout=60) - out = stdout.read().decode("utf-8", errors="replace").strip() - exit_status = stdout.channel.recv_exit_status() - if exit_status != 0 or "OK" not in out: - print(" [失败] 解压失败,退出码:", exit_status) - return False - print(" [成功] 已解压到: %s" % project_path) - - if not no_restart: - print("[4/4] 重启 soulDev 服务 ...") - ok = False - if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")): - ok = restart_via_bt_api(cfg) - if not ok and restart_method in ("auto", "ssh"): - # SSH:只杀「工作目录为本项目」的 soul-api,避免误杀其他 Go 项目 - restart_cmd = ( - "cd %s && T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do " - "[ \"$(readlink -f /proc/$p/cwd 2>/dev/null)\" = \"$T\" ] && kill $p 2>/dev/null; done; " - "sleep 2; setsid nohup ./soul-api >> soul-api.log 2>&1 /dev/null); 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()) diff --git a/soul-api/devlop.py b/soul-api/devlop.py deleted file mode 100644 index 557901ab..00000000 --- a/soul-api/devlop.py +++ /dev/null @@ -1,391 +0,0 @@ -#!/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_project,POST 带 action、project_name - for action in ("stop_go_project", "start_go_project"): - data = dict(params) - data["action"] = action - data["project_name"] = name - r = requests.post( - base + "/plugin?name=go_project", - data=data, - timeout=15, - verify=False, - ) - if r.status_code != 200: - continue - j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} - if action == "stop_go_project": - time.sleep(2) - if j.get("status") is False and j.get("msg"): - print(" [宝塔API] %s: %s" % (action, j.get("msg", ""))) - # 再调一次 start 确保启动 - data = dict(params) - data["action"] = "start_go_project" - data["project_name"] = name - r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False) - if r.status_code == 200: - j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} - if j.get("status") is True: - print(" [成功] 已通过宝塔 API 重启 Go 项目: %s" % name) - return True - return False - except Exception as e: - print(" [宝塔API 失败] %s" % str(e)) - return False - - -# ==================== SSH 上传 ==================== - - -def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"): - """上传 tar.gz 到服务器并解压、重启""" - print("[3/4] SSH 上传并解压 ...") - if not cfg.get("password") and not cfg.get("ssh_key"): - print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") - return False - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - try: - if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): - client.connect( - cfg["host"], port=DEFAULT_SSH_PORT, - username=cfg["user"], key_filename=cfg["ssh_key"], - timeout=15, - ) - else: - client.connect( - cfg["host"], port=DEFAULT_SSH_PORT, - username=cfg["user"], password=cfg["password"], - timeout=15, - ) - sftp = client.open_sftp() - remote_tar = "/tmp/soul_api_deploy.tar.gz" - project_path = cfg["project_path"] - sftp.put(tarball_path, remote_tar) - sftp.close() - - cmd = ( - "mkdir -p %s && cd %s && tar -xzf %s && " - "chmod +x soul-api && rm -f %s && echo OK" - ) % (project_path, project_path, remote_tar, remote_tar) - stdin, stdout, stderr = client.exec_command(cmd, timeout=60) - out = stdout.read().decode("utf-8", errors="replace").strip() - exit_status = stdout.channel.recv_exit_status() - if exit_status != 0 or "OK" not in out: - print(" [失败] 解压失败,退出码:", exit_status) - return False - print(" [成功] 已解压到: %s" % project_path) - - if not no_restart: - print("[4/4] 重启 soulApp 服务 ...") - ok = False - if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")): - ok = restart_via_bt_api(cfg) - if not ok and restart_method in ("auto", "ssh"): - # SSH:只杀「工作目录为本项目」的 soul-api,避免误杀其他 Go 项目 - restart_cmd = ( - "cd %s && T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do " - "[ \"$(readlink -f /proc/$p/cwd 2>/dev/null)\" = \"$T\" ] && kill $p 2>/dev/null; done; " - "sleep 2; setsid nohup ./soul-api >> soul-api.log 2>&1 /dev/null); 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()) diff --git a/soul-api/go.mod b/soul-api/go.mod deleted file mode 100644 index bb000653..00000000 --- a/soul-api/go.mod +++ /dev/null @@ -1,59 +0,0 @@ -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 -) diff --git a/soul-api/go.sum b/soul-api/go.sum deleted file mode 100644 index 51573120..00000000 --- a/soul-api/go.sum +++ /dev/null @@ -1,158 +0,0 @@ -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= diff --git a/soul-api/internal/auth/adminjwt.go b/soul-api/internal/auth/adminjwt.go deleted file mode 100644 index 49c7e9bf..00000000 --- a/soul-api/internal/auth/adminjwt.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package auth 管理端 JWT:签发与校验,使用 Authorization: Bearer -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 ,其次 Cookie admin_session(兼容旧端) -func GetAdminJWTFromRequest(r *http.Request) string { - // 1. Authorization: Bearer - 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) -} diff --git a/soul-api/internal/auth/adminsession.go b/soul-api/internal/auth/adminsession.go deleted file mode 100644 index 6dcb6583..00000000 --- a/soul-api/internal/auth/adminsession.go +++ /dev/null @@ -1,71 +0,0 @@ -// 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) -} diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go deleted file mode 100644 index d3b4f252..00000000 --- a/soul-api/internal/config/config.go +++ /dev/null @@ -1,256 +0,0 @@ -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 将路径拼接到 BaseURL,path 应以 / 开头 -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.development,APP_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 -} diff --git a/soul-api/internal/database/database.go b/soul-api/internal/database/database.go deleted file mode 100644 index 0dfa7bdb..00000000 --- a/soul-api/internal/database/database.go +++ /dev/null @@ -1,58 +0,0 @@ -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 -} diff --git a/soul-api/internal/handler/admin.go b/soul-api/internal/handler/admin.go deleted file mode 100644 index 793fc365..00000000 --- a/soul-api/internal/handler/admin.go +++ /dev/null @@ -1,152 +0,0 @@ -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 鉴权检查(JWT:Authorization Bearer 或 Cookie),已登录返回 success 或概览占位 -func AdminCheck(c *gin.Context) { - cfg := config.Get() - if cfg == nil { - c.JSON(http.StatusOK, gin.H{"success": true}) - return - } - token := auth.GetAdminJWTFromRequest(c.Request) - if _, ok := auth.ParseAdminJWT(token, cfg.AdminSessionSecret); !ok { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未授权访问,请先登录"}) - return - } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "content": gin.H{ - "totalChapters": 0, "totalWords": 0, "publishedChapters": 0, "draftChapters": 0, - "lastUpdate": nil, - }, - "payment": gin.H{ - "totalRevenue": 0, "todayRevenue": 0, "totalOrders": 0, "todayOrders": 0, "averagePrice": 0, - }, - "referral": gin.H{ - "totalReferrers": 0, "activeReferrers": 0, "totalCommission": 0, "paidCommission": 0, "pendingCommission": 0, - }, - "users": gin.H{ - "totalUsers": 0, "purchasedUsers": 0, "activeUsers": 0, "todayNewUsers": 0, - }, - }) -} - -// AdminLogin POST /api/admin 登录(优先校验 admin_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] -} diff --git a/soul-api/internal/handler/admin_chapters.go b/soul-api/internal/handler/admin_chapters.go deleted file mode 100644 index ea044d86..00000000 --- a/soul-api/internal/handler/admin_chapters.go +++ /dev/null @@ -1,160 +0,0 @@ -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}) -} diff --git a/soul-api/internal/handler/admin_distribution.go b/soul-api/internal/handler/admin_distribution.go deleted file mode 100644 index 2fb89d1a..00000000 --- a/soul-api/internal/handler/admin_distribution.go +++ /dev/null @@ -1,99 +0,0 @@ -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) + "%" -} diff --git a/soul-api/internal/handler/admin_extra.go b/soul-api/internal/handler/admin_extra.go deleted file mode 100644 index caa50181..00000000 --- a/soul-api/internal/handler/admin_extra.go +++ /dev/null @@ -1,22 +0,0 @@ -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}) -} diff --git a/soul-api/internal/handler/admin_withdrawals.go b/soul-api/internal/handler/admin_withdrawals.go deleted file mode 100644 index 627f55fd..00000000 --- a/soul-api/internal/handler/admin_withdrawals.go +++ /dev/null @@ -1,421 +0,0 @@ -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), - }) -} diff --git a/soul-api/internal/handler/auth.go b/soul-api/internal/handler/auth.go deleted file mode 100644 index 9c72e9eb..00000000 --- a/soul-api/internal/handler/auth.go +++ /dev/null @@ -1,17 +0,0 @@ -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}) -} diff --git a/soul-api/internal/handler/book.go b/soul-api/internal/handler/book.go deleted file mode 100644 index 186795af..00000000 --- a/soul-api/internal/handler/book.go +++ /dev/null @@ -1,343 +0,0 @@ -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 维护"}) -} diff --git a/soul-api/internal/handler/ckb.go b/soul-api/internal/handler/ckb.go deleted file mode 100644 index 6526f294..00000000 --- a/soul-api/internal/handler/ckb.go +++ /dev/null @@ -1,252 +0,0 @@ -package handler - -import ( - "bytes" - "crypto/md5" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strconv" - "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}) -} diff --git a/soul-api/internal/handler/config.go b/soul-api/internal/handler/config.go deleted file mode 100644 index d9347cb9..00000000 --- a/soul-api/internal/handler/config.go +++ /dev/null @@ -1,63 +0,0 @@ -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) -} diff --git a/soul-api/internal/handler/content.go b/soul-api/internal/handler/content.go deleted file mode 100644 index 1a617739..00000000 --- a/soul-api/internal/handler/content.go +++ /dev/null @@ -1,12 +0,0 @@ -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}) -} diff --git a/soul-api/internal/handler/cron.go b/soul-api/internal/handler/cron.go deleted file mode 100644 index 71593834..00000000 --- a/soul-api/internal/handler/cron.go +++ /dev/null @@ -1,161 +0,0 @@ -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}) -} diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go deleted file mode 100644 index 121a5fd9..00000000 --- a/soul-api/internal/handler/db.go +++ /dev/null @@ -1,1144 +0,0 @@ -package handler - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "soul-api/internal/config" - "soul-api/internal/database" - "soul-api/internal/model" - - "github.com/gin-gonic/gin" -) - -// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐) -// 从 system_config 读取 chapter_config、feature_config、mp_config,合并后返回(免费以章节 is_free/price 为准) -func GetPublicDBConfig(c *gin.Context) { - defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9} - defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true} - apiDomain := "https://soulapi.quwanzhi.com" - if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" { - apiDomain = cfg.BaseURL - } - defaultMp := gin.H{ - "appId": "wxb8bbb2b10dec74aa", - "apiDomain": apiDomain, - "buyerDiscount": 5, - "referralBindDays": 30, - "minWithdraw": 10, - "withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE", - "mchId": "1318592501", - } - - out := gin.H{ - "success": true, - "prices": defaultPrices, - "features": defaultFeatures, - "mpConfig": defaultMp, - "configs": gin.H{}, - } - db := database.DB() - - keys := []string{"chapter_config", "feature_config", "mp_config"} - for _, k := range keys { - var row model.SystemConfig - if err := db.Where("config_key = ?", k).First(&row).Error; err != nil { - continue - } - var val interface{} - if err := json.Unmarshal(row.ConfigValue, &val); err != nil { - continue - } - switch k { - case "chapter_config": - if m, ok := val.(map[string]interface{}); ok { - if v, ok := m["prices"].(map[string]interface{}); ok { - out["prices"] = v - } - if v, ok := m["features"].(map[string]interface{}); ok { - out["features"] = v - } - out["configs"].(gin.H)["chapter_config"] = m - } - case "feature_config": - if m, ok := val.(map[string]interface{}); ok { - // 合并到 features,不整体覆盖以保留 chapter_config 里的 - cur := out["features"].(gin.H) - for kk, vv := range m { - cur[kk] = vv - } - out["configs"].(gin.H)["feature_config"] = m - } - case "mp_config": - if m, ok := val.(map[string]interface{}); ok { - // 合并默认值,DB 有则覆盖 - merged := make(gin.H) - for k, v := range defaultMp { - merged[k] = v - } - for k, v := range m { - merged[k] = v - } - out["mpConfig"] = merged - out["configs"].(gin.H)["mp_config"] = merged - } - } - } - // 好友优惠(用于 read 页展示优惠价) - var refRow model.SystemConfig - if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil { - var refVal map[string]interface{} - if err := json.Unmarshal(refRow.ConfigValue, &refVal); err == nil { - if v, ok := refVal["userDiscount"].(float64); ok { - out["userDiscount"] = v - } - } - } - if _, has := out["userDiscount"]; !has { - out["userDiscount"] = float64(5) - } - c.JSON(http.StatusOK, out) -} - -// DBConfigGet GET /api/db/config(管理端鉴权后同路径由 db 组处理时用) -func DBConfigGet(c *gin.Context) { - key := c.Query("key") - db := database.DB() - var list []model.SystemConfig - q := db.Table("system_config") - if key != "" { - q = q.Where("config_key = ?", key) - } - if err := q.Find(&list).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - if key != "" && len(list) == 1 { - var val interface{} - _ = json.Unmarshal(list[0].ConfigValue, &val) - c.JSON(http.StatusOK, gin.H{"success": true, "data": val}) - return - } - data := make([]gin.H, 0, len(list)) - for _, row := range list { - var val interface{} - _ = json.Unmarshal(row.ConfigValue, &val) - data = append(data, gin.H{"configKey": row.ConfigKey, "configValue": val}) - } - c.JSON(http.StatusOK, gin.H{"success": true, "data": data}) -} - -// AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回功能开关、站点/作者与价格、小程序配置 -func AdminSettingsGet(c *gin.Context) { - db := database.DB() - apiDomain := "https://soulapi.quwanzhi.com" - if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" { - apiDomain = cfg.BaseURL - } - defaultMp := gin.H{ - "appId": "wxb8bbb2b10dec74aa", - "apiDomain": apiDomain, - "withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE", - "mchId": "1318592501", - "minWithdraw": float64(10), - } - out := gin.H{ - "success": true, - "featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}, - "siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}}, - "mpConfig": defaultMp, - } - keys := []string{"feature_config", "site_settings", "mp_config"} - for _, k := range keys { - var row model.SystemConfig - if err := db.Where("config_key = ?", k).First(&row).Error; err != nil { - continue - } - var val interface{} - if err := json.Unmarshal(row.ConfigValue, &val); err != nil { - continue - } - switch k { - case "feature_config": - if m, ok := val.(map[string]interface{}); ok && len(m) > 0 { - out["featureConfig"] = m - } - case "site_settings": - if m, ok := val.(map[string]interface{}); ok && len(m) > 0 { - out["siteSettings"] = m - } - case "mp_config": - if m, ok := val.(map[string]interface{}); ok { - merged := make(gin.H) - for k, v := range defaultMp { - merged[k] = v - } - for k, v := range m { - merged[k] = v - } - out["mpConfig"] = merged - } - } - } - c.JSON(http.StatusOK, out) -} - -// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存功能开关、站点/作者与价格、小程序配置 -func AdminSettingsPost(c *gin.Context) { - var body struct { - FeatureConfig map[string]interface{} `json:"featureConfig"` - SiteSettings map[string]interface{} `json:"siteSettings"` - MpConfig map[string]interface{} `json:"mpConfig"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) - return - } - db := database.DB() - saveKey := func(key, desc string, value interface{}) error { - valBytes, err := json.Marshal(value) - if err != nil { - return err - } - var row model.SystemConfig - err = db.Where("config_key = ?", key).First(&row).Error - if err != nil { - row = model.SystemConfig{ConfigKey: key, ConfigValue: valBytes, Description: &desc} - return db.Create(&row).Error - } - row.ConfigValue = valBytes - if desc != "" { - row.Description = &desc - } - return db.Save(&row).Error - } - if body.FeatureConfig != nil { - if err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存功能开关失败: " + err.Error()}) - return - } - } - if body.SiteSettings != nil { - if err := saveKey("site_settings", "站点与作者配置", body.SiteSettings); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存站点设置失败: " + err.Error()}) - return - } - } - if body.MpConfig != nil { - if err := saveKey("mp_config", "小程序专用配置", body.MpConfig); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存小程序配置失败: " + err.Error()}) - return - } - } - c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"}) -} - -// AdminReferralSettingsGet GET /api/admin/referral-settings 推广设置页专用:仅返回 referral_config -func AdminReferralSettingsGet(c *gin.Context) { - db := database.DB() - defaultConfig := gin.H{ - "distributorShare": float64(90), - "minWithdrawAmount": float64(10), - "bindingDays": float64(30), - "userDiscount": float64(5), - "withdrawFee": float64(5), - "enableAutoWithdraw": false, - "vipOrderShareVip": float64(20), - "vipOrderShareNonVip": float64(10), - } - var row model.SystemConfig - if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig}) - return - } - var val map[string]interface{} - if err := json.Unmarshal(row.ConfigValue, &val); err != nil || len(val) == 0 { - c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "data": val}) -} - -// AdminReferralSettingsPost POST /api/admin/referral-settings 推广设置页专用:仅保存 referral_config(请求体为完整配置对象) -func AdminReferralSettingsPost(c *gin.Context) { - var body struct { - DistributorShare float64 `json:"distributorShare"` - MinWithdrawAmount float64 `json:"minWithdrawAmount"` - BindingDays float64 `json:"bindingDays"` - UserDiscount float64 `json:"userDiscount"` - WithdrawFee float64 `json:"withdrawFee"` - EnableAutoWithdraw bool `json:"enableAutoWithdraw"` - VipOrderShareVip float64 `json:"vipOrderShareVip"` - VipOrderShareNonVip float64 `json:"vipOrderShareNonVip"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) - return - } - vipOrderShareVip := body.VipOrderShareVip - if vipOrderShareVip == 0 { - vipOrderShareVip = 20 - } - vipOrderShareNonVip := body.VipOrderShareNonVip - if vipOrderShareNonVip == 0 { - vipOrderShareNonVip = 10 - } - val := gin.H{ - "distributorShare": body.DistributorShare, - "minWithdrawAmount": body.MinWithdrawAmount, - "bindingDays": body.BindingDays, - "userDiscount": body.UserDiscount, - "withdrawFee": body.WithdrawFee, - "enableAutoWithdraw": body.EnableAutoWithdraw, - "vipOrderShareVip": vipOrderShareVip, - "vipOrderShareNonVip": vipOrderShareNonVip, - } - valBytes, err := json.Marshal(val) - if err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - db := database.DB() - desc := "分销 / 推广规则配置" - var row model.SystemConfig - if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil { - row = model.SystemConfig{ConfigKey: "referral_config", ConfigValue: valBytes, Description: &desc} - err = db.Create(&row).Error - } else { - row.ConfigValue = valBytes - row.Description = &desc - err = db.Save(&row).Error - } - if err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"}) -} - -func authorConfigToResponse(row *model.AuthorConfig) gin.H { - defaultStats := []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}} - defaultHighlights := []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"} - var stats []gin.H - if row.Stats != "" { - _ = json.Unmarshal([]byte(row.Stats), &stats) - } - if len(stats) == 0 { - stats = defaultStats - } - var highlights []string - if row.Highlights != "" { - _ = json.Unmarshal([]byte(row.Highlights), &highlights) - } - if len(highlights) == 0 { - highlights = defaultHighlights - } - return gin.H{ - "name": row.Name, - "avatar": row.Avatar, - "avatarImg": row.AvatarImg, - "title": row.Title, - "bio": row.Bio, - "stats": stats, - "highlights": highlights, - } -} - -// AdminAuthorSettingsGet GET /api/admin/author-settings 作者详情配置(管理端专用) -func AdminAuthorSettingsGet(c *gin.Context) { - defaultAuthor := gin.H{ - "name": "卡若", - "avatar": "K", - "avatarImg": "", - "title": "Soul派对房主理人 · 私域运营专家", - "bio": "每天早上6点到9点,在Soul派对房分享真实的创业故事。专注私域运营与项目变现,用云阿米巴模式帮助创业者构建可持续的商业体系。", - "stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}, - "highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"}, - } - db := database.DB() - var row model.AuthorConfig - if err := db.First(&row).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)}) -} - -// AdminAuthorSettingsPost POST /api/admin/author-settings 保存作者详情配置 -func AdminAuthorSettingsPost(c *gin.Context) { - var body map[string]interface{} - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) - return - } - str := func(k string) string { - if v, ok := body[k]; ok && v != nil { - if s, ok := v.(string); ok { - return s - } - return fmt.Sprintf("%v", v) - } - return "" - } - name := str("name") - if name == "" { - name = "卡若" - } - avatar := str("avatar") - if avatar == "" { - avatar = "K" - } - statsVal := body["stats"] - if statsVal == nil { - statsVal = []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}} - } - highlightsVal := body["highlights"] - if highlightsVal == nil { - highlightsVal = []string{} - } - statsBytes, _ := json.Marshal(statsVal) - highlightsBytes, _ := json.Marshal(highlightsVal) - - db := database.DB() - var row model.AuthorConfig - err := db.First(&row).Error - if err != nil { - row = model.AuthorConfig{ - Name: name, - Avatar: avatar, - AvatarImg: str("avatarImg"), - Title: str("title"), - Bio: str("bio"), - Stats: string(statsBytes), - Highlights: string(highlightsBytes), - } - err = db.Create(&row).Error - } else { - row.Name = name - row.Avatar = avatar - row.AvatarImg = str("avatarImg") - row.Title = str("title") - row.Bio = str("bio") - row.Stats = string(statsBytes) - row.Highlights = string(highlightsBytes) - err = db.Save(&row).Error - } - if err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "message": "作者设置已保存"}) -} - -// MiniprogramAboutAuthor GET /api/miniprogram/about/author 小程序-关于作者页拉取作者配置(公开,无需鉴权) -func MiniprogramAboutAuthor(c *gin.Context) { - defaultAuthor := gin.H{ - "name": "卡若", - "avatar": "K", - "avatarImg": "", - "title": "Soul派对房主理人 · 私域运营专家", - "bio": "每天早上6点到9点,在Soul派对房分享真实的创业故事。", - "stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}, - "highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"}, - } - db := database.DB() - var row model.AuthorConfig - if err := db.First(&row).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)}) -} - -// DBConfigPost POST /api/db/config -func DBConfigPost(c *gin.Context) { - var body struct { - Key string `json:"key"` - Value interface{} `json:"value"` - Description string `json:"description"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.Key == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"}) - return - } - valBytes, err := json.Marshal(body.Value) - if err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - db := database.DB() - desc := body.Description - var row model.SystemConfig - err = db.Where("config_key = ?", body.Key).First(&row).Error - if err != nil { - row = model.SystemConfig{ConfigKey: body.Key, ConfigValue: valBytes, Description: &desc} - err = db.Create(&row).Error - } else { - row.ConfigValue = valBytes - if body.Description != "" { - row.Description = &desc - } - err = db.Save(&row).Error - } - if err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"}) -} - -// DBUsersList GET /api/db/users(支持分页 page、pageSize,可选搜索 search;有 id 时返回单个 user;购买状态、分销收益、绑定人数从订单/绑定表实时计算) -func DBUsersList(c *gin.Context) { - db := database.DB() - id := strings.TrimSpace(c.Query("id")) - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) - search := strings.TrimSpace(c.DefaultQuery("search", "")) - vipFilter := c.Query("vip") // "true" 时仅返回 VIP(hasFullBook) - if page < 1 { - page = 1 - } - if pageSize < 1 || pageSize > 100 { - pageSize = 10 - } - - // 有 id 时返回单个用户(供 UserDetailModal 等使用) - 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, "user": nil}) - return - } - // 填充 hasFullBook(含 is_vip 或 orders) - var cnt int64 - db.Model(&model.Order{}).Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", - id, "paid", "completed", "fullbook", "vip").Count(&cnt) - user.HasFullBook = ptrBool(cnt > 0) - if user.IsVip != nil && *user.IsVip { - user.HasFullBook = ptrBool(true) - } - c.JSON(http.StatusOK, gin.H{"success": true, "user": user}) - return - } - - q := db.Model(&model.User{}) - if search != "" { - pattern := "%" + search + "%" - q = q.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern) - } - if vipFilter == "true" || vipFilter == "1" { - q = q.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND (status = ? OR status = ?)) OR (is_vip = 1 AND vip_expire_date > ?)", - []string{"fullbook", "vip"}, "paid", "completed", time.Now()) - } - var total int64 - q.Count(&total) - - var users []model.User - query := db.Model(&model.User{}) - if search != "" { - pattern := "%" + search + "%" - query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern) - } - if vipFilter == "true" || vipFilter == "1" { - query = query.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND (status = ? OR status = ?)) OR (is_vip = 1 AND vip_expire_date > ?)", - []string{"fullbook", "vip"}, "paid", "completed", time.Now()) - } - if err := query.Order("created_at DESC"). - Offset((page - 1) * pageSize). - Limit(pageSize). - Find(&users).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "users": []interface{}{}}) - return - } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - - if len(users) == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": true, "users": users, - "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, - }) - return - } - - userIDs := make([]string, 0, len(users)) - for _, u := range users { - userIDs = append(userIDs, u.ID) - } - - // 1. 购买状态:全书已购、已付费章节数(从 orders 计算) - hasFullBookMap := make(map[string]bool) - sectionCountMap := make(map[string]int) - var fullbookRows []struct { - UserID string - } - db.Model(&model.Order{}).Select("user_id").Where("product_type IN ? AND status = ?", []string{"fullbook", "vip"}, "paid").Find(&fullbookRows) - for _, r := range fullbookRows { - hasFullBookMap[r.UserID] = true - } - var sectionRows []struct { - UserID string - Count int64 - } - db.Model(&model.Order{}).Select("user_id, COUNT(*) as count"). - Where("product_type = ? AND status = ?", "section", "paid"). - Group("user_id").Find(§ionRows) - for _, r := range sectionRows { - sectionCountMap[r.UserID] = int(r.Count) - } - - // 2. 分销收益:从 referrer 订单逐条 computeOrderCommission 求和(会员订单 20%/10%,内容订单 90%) - referrerEarningsMap := make(map[string]float64) - var referrerOrders []model.Order - db.Where("referrer_id IS NOT NULL AND referrer_id != '' AND status = ?", "paid").Find(&referrerOrders) - for i := range referrerOrders { - rid := referrerOrders[i].ReferrerID - if rid != nil && *rid != "" { - referrerEarningsMap[*rid] += computeOrderCommission(db, &referrerOrders[i], nil) - } - } - withdrawnMap := make(map[string]float64) - var withdrawnRows []struct { - UserID string - Total float64 - } - db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total"). - Where("status = ?", "success"). - Group("user_id").Find(&withdrawnRows) - for _, r := range withdrawnRows { - withdrawnMap[r.UserID] = r.Total - } - pendingWithdrawMap := make(map[string]float64) - var pendingRows []struct { - UserID string - Total float64 - } - db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total"). - Where("status IN ?", []string{"pending", "processing", "pending_confirm"}). - Group("user_id").Find(&pendingRows) - for _, r := range pendingRows { - pendingWithdrawMap[r.UserID] = r.Total - } - - // 3. 绑定人数:从 referral_bindings 计算 - referralCountMap := make(map[string]int) - var refCountRows []struct { - ReferrerID string - Count int64 - } - db.Model(&model.ReferralBinding{}).Select("referrer_id, COUNT(*) as count"). - Group("referrer_id").Find(&refCountRows) - for _, r := range refCountRows { - referralCountMap[r.ReferrerID] = int(r.Count) - } - - // 填充每个用户的实时计算字段 - for i := range users { - uid := users[i].ID - // 购买状态(含手动设置的 VIP:is_vip=1 且 vip_expire_date>NOW) - hasFull := hasFullBookMap[uid] - if users[i].IsVip != nil && *users[i].IsVip && users[i].VipExpireDate != nil && users[i].VipExpireDate.After(time.Now()) { - hasFull = true - } - users[i].HasFullBook = ptrBool(hasFull) - users[i].PurchasedSectionCount = sectionCountMap[uid] - // 分销收益 - totalE := referrerEarningsMap[uid] - withdrawn := withdrawnMap[uid] - pendingWd := pendingWithdrawMap[uid] - available := totalE - withdrawn - pendingWd - if available < 0 { - available = 0 - } - users[i].Earnings = ptrFloat64(totalE) - users[i].PendingEarnings = ptrFloat64(available) - users[i].ReferralCount = ptrInt(referralCountMap[uid]) - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, "users": users, - "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, - }) -} - -func ptrBool(b bool) *bool { return &b } -func ptrFloat64(f float64) *float64 { v := f; return &v } -func ptrInt(n int) *int { return &n } - -// DBUsersAction POST /api/db/users(创建)、PUT /api/db/users(更新) -func DBUsersAction(c *gin.Context) { - db := database.DB() - if c.Request.Method == http.MethodPost { - var body struct { - OpenID *string `json:"openId"` - Phone *string `json:"phone"` - Nickname *string `json:"nickname"` - WechatID *string `json:"wechatId"` - Avatar *string `json:"avatar"` - IsAdmin *bool `json:"isAdmin"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) - return - } - userID := "user_" + randomSuffix() - code := "SOUL" + randomSuffix()[:4] - nick := "用户" - if body.Nickname != nil && *body.Nickname != "" { - nick = *body.Nickname - } else { - nick = nick + userID[len(userID)-4:] - } - u := model.User{ - ID: userID, Nickname: &nick, ReferralCode: &code, - OpenID: body.OpenID, Phone: body.Phone, WechatID: body.WechatID, Avatar: body.Avatar, - } - if body.IsAdmin != nil { - u.IsAdmin = body.IsAdmin - } - if err := db.Create(&u).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "user": u, "isNew": true, "message": "用户创建成功"}) - return - } - // PUT 更新(含 VIP 手动设置:is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio) - var body struct { - ID string `json:"id"` - Nickname *string `json:"nickname"` - Phone *string `json:"phone"` - WechatID *string `json:"wechatId"` - Avatar *string `json:"avatar"` - HasFullBook *bool `json:"hasFullBook"` - IsAdmin *bool `json:"isAdmin"` - Earnings *float64 `json:"earnings"` - PendingEarnings *float64 `json:"pendingEarnings"` - IsVip *bool `json:"isVip"` - VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59" - VipSort *int `json:"vipSort"` // 手动排序,越小越前 - VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写 - VipName *string `json:"vipName"` - VipAvatar *string `json:"vipAvatar"` - VipProject *string `json:"vipProject"` - VipContact *string `json:"vipContact"` - VipBio *string `json:"vipBio"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"}) - return - } - // 手动设置 VIP 时,必须提供有效到期日 - if body.IsVip != nil && *body.IsVip { - if body.VipExpireDate == nil || strings.TrimSpace(*body.VipExpireDate) == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "开启 VIP 时请填写有效到期日"}) - return - } - if _, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(*body.VipExpireDate), time.Local); err != nil { - if _, err2 := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(*body.VipExpireDate), time.Local); err2 != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "到期日格式无效,请使用 YYYY-MM-DD"}) - return - } - } - } - updates := map[string]interface{}{} - if body.Nickname != nil { - updates["nickname"] = *body.Nickname - } - if body.Phone != nil { - updates["phone"] = *body.Phone - } - if body.WechatID != nil { - updates["wechat_id"] = *body.WechatID - } - if body.Avatar != nil { - updates["avatar"] = *body.Avatar - } - if body.HasFullBook != nil { - updates["has_full_book"] = *body.HasFullBook - } - if body.IsAdmin != nil { - updates["is_admin"] = *body.IsAdmin - } - if body.Earnings != nil { - updates["earnings"] = *body.Earnings - } - if body.PendingEarnings != nil { - updates["pending_earnings"] = *body.PendingEarnings - } - if body.IsVip != nil { - updates["is_vip"] = *body.IsVip - if *body.IsVip { - now := time.Now() - updates["vip_activated_at"] = now // 手动设置时与付款一致:按时间排序,最新在前 - } else { - updates["vip_activated_at"] = nil - } - } - if body.VipExpireDate != nil { - if *body.VipExpireDate == "" { - updates["vip_expire_date"] = nil - } else { - if t, err := time.ParseInLocation("2006-01-02", *body.VipExpireDate, time.Local); err == nil { - updates["vip_expire_date"] = t - } else if t, err := time.ParseInLocation("2006-01-02 15:04:05", *body.VipExpireDate, time.Local); err == nil { - updates["vip_expire_date"] = t - } - } - } - if body.VipSort != nil { - updates["vip_sort"] = *body.VipSort - } - if body.VipRole != nil { - s := strings.TrimSpace(*body.VipRole) - if s == "" { - updates["vip_role"] = nil - } else { - updates["vip_role"] = s - } - } - if body.VipName != nil { - updates["vip_name"] = *body.VipName - } - if body.VipAvatar != nil { - updates["vip_avatar"] = *body.VipAvatar - } - if body.VipProject != nil { - updates["vip_project"] = *body.VipProject - } - if body.VipContact != nil { - updates["vip_contact"] = *body.VipContact - } - if body.VipBio != nil { - updates["vip_bio"] = *body.VipBio - } - if len(updates) == 0 { - c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"}) - return - } - // VIP 相关更新时记录日志(手动设置) - if body.IsVip != nil || body.VipExpireDate != nil || body.VipName != nil || body.VipAvatar != nil || body.VipProject != nil || body.VipContact != nil || body.VipBio != nil { - isVipStr := "-" - if body.IsVip != nil { - isVipStr = fmt.Sprintf("%v", *body.IsVip) - } - vipExpire := "-" - if body.VipExpireDate != nil { - vipExpire = *body.VipExpireDate - } - fmt.Printf("[VIP] 设置方式=手动设置, userId=%s, isVip=%s, vipExpireDate=%s\n", body.ID, isVipStr, vipExpire) - } - if err := db.Model(&model.User{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户更新成功"}) -} - -func randomSuffix() string { - return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff) -} - -// DBUsersDelete DELETE /api/db/users -func DBUsersDelete(c *gin.Context) { - id := c.Query("id") - if id == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"}) - return - } - if err := database.DB().Where("id = ?", id).Delete(&model.User{}).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户删除成功"}) -} - -// DBUsersReferrals GET /api/db/users/referrals(绑定关系详情弹窗;收益与「已付费」与小程序口径一致:订单+提现表实时计算) -func DBUsersReferrals(c *gin.Context) { - userId := c.Query("userId") - if userId == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"}) - return - } - db := database.DB() - - var bindings []model.ReferralBinding - if err := db.Where("referrer_id = ?", userId).Order("binding_date DESC").Find(&bindings).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": true, "referrals": []interface{}{}, "stats": gin.H{"total": 0, "purchased": 0, "free": 0, "earnings": 0, "pendingEarnings": 0, "withdrawnEarnings": 0}}) - return - } - refereeIds := make([]string, 0, len(bindings)) - for _, b := range bindings { - refereeIds = append(refereeIds, b.RefereeID) - } - var users []model.User - if len(refereeIds) > 0 { - db.Where("id IN ?", refereeIds).Find(&users) - } - userMap := make(map[string]*model.User) - for i := range users { - userMap[users[i].ID] = &users[i] - } - referrals := make([]gin.H, 0, len(bindings)) - for _, b := range bindings { - u := userMap[b.RefereeID] - nick := "微信用户" - var avatar *string - var phone *string - hasFullBook := false - if u != nil { - if u.Nickname != nil { - nick = *u.Nickname - } - avatar, phone = u.Avatar, u.Phone - if u.HasFullBook != nil { - hasFullBook = *u.HasFullBook - } - } - status := "active" - if b.Status != nil { - status = *b.Status - } - daysRemaining := 0 - if b.ExpiryDate.After(time.Now()) { - daysRemaining = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24) - } - // 已付费:与小程序一致,以绑定记录的 purchase_count > 0 为准(支付回调会更新该字段) - hasPaid := b.PurchaseCount != nil && *b.PurchaseCount > 0 - displayStatus := bindingStatusDisplay(hasPaid, hasFullBook) // vip | paid | free,供前端徽章展示 - referrals = append(referrals, gin.H{ - "id": b.RefereeID, "nickname": nick, "avatar": avatar, "phone": phone, - "hasFullBook": hasFullBook || status == "converted", - "purchasedSections": getBindingPurchaseCount(b), - "createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission, - "status": displayStatus, - }) - } - - // 累计收益、待提现:与小程序 MyEarnings 一致,从订单逐条 computeOrderCommission 求和 - var refOrders []model.Order - db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&refOrders) - earningsE := 0.0 - for i := range refOrders { - earningsE += computeOrderCommission(db, &refOrders[i], nil) - } - - var withdrawnSum struct{ Total float64 } - db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total"). - Where("user_id = ? AND status = ?", userId, "success"). - Scan(&withdrawnSum) - withdrawnE := withdrawnSum.Total - - var pendingWdSum struct{ Total float64 } - db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total"). - Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}). - Scan(&pendingWdSum) - availableE := earningsE - withdrawnE - pendingWdSum.Total - if availableE < 0 { - availableE = 0 - } - - // 已付费人数:与小程序一致,绑定中 purchase_count > 0 的条数 - purchased := 0 - for _, b := range bindings { - if b.PurchaseCount != nil && *b.PurchaseCount > 0 { - purchased++ - } - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, "referrals": referrals, - "stats": gin.H{ - "total": len(bindings), "purchased": purchased, "free": len(bindings) - purchased, - "earnings": roundFloat(earningsE, 2), "pendingEarnings": roundFloat(availableE, 2), "withdrawnEarnings": roundFloat(withdrawnE, 2), - }, - }) -} - -func getBindingPurchaseCount(b model.ReferralBinding) int { - if b.PurchaseCount == nil { - return 0 - } - return *b.PurchaseCount -} - -func bindingStatusDisplay(hasPaid bool, hasFullBook bool) string { - if hasFullBook { - return "vip" - } - if hasPaid { - return "paid" - } - return "free" -} - -func roundFloat(v float64, prec int) float64 { - ratio := 1.0 - for i := 0; i < prec; i++ { - ratio *= 10 - } - return float64(int(v*ratio+0.5)) / ratio -} - -// DBInit POST /api/db/init -func DBInit(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "初始化接口已就绪(表结构由迁移维护)"}}) -} - -// DBDistribution GET /api/db/distribution(支持分页 page、pageSize,筛选 status、search) -func DBDistribution(c *gin.Context) { - userId := c.Query("userId") - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) - statusFilter := c.Query("status") - if page < 1 { - page = 1 - } - if pageSize < 1 || pageSize > 100 { - pageSize = 10 - } - - db := database.DB() - q := db.Model(&model.ReferralBinding{}) - if userId != "" { - q = q.Where("referrer_id = ?", userId) - } - if statusFilter != "" && statusFilter != "all" { - q = q.Where("status = ?", statusFilter) - } - var total int64 - q.Count(&total) - - var bindings []model.ReferralBinding - query := db.Model(&model.ReferralBinding{}).Order("binding_date DESC") - if userId != "" { - query = query.Where("referrer_id = ?", userId) - } - if statusFilter != "" && statusFilter != "all" { - query = query.Where("status = ?", statusFilter) - } - if err := query.Offset((page-1)*pageSize).Limit(pageSize).Find(&bindings).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": true, "bindings": []interface{}{}, "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0}) - return - } - referrerIds := make(map[string]bool) - refereeIds := make(map[string]bool) - for _, b := range bindings { - referrerIds[b.ReferrerID] = true - refereeIds[b.RefereeID] = true - } - allIds := make([]string, 0, len(referrerIds)+len(refereeIds)) - for id := range referrerIds { - allIds = append(allIds, id) - } - for id := range refereeIds { - if !referrerIds[id] { - allIds = append(allIds, id) - } - } - var users []model.User - if len(allIds) > 0 { - db.Where("id IN ?", allIds).Find(&users) - } - userMap := make(map[string]*model.User) - for i := range users { - userMap[users[i].ID] = &users[i] - } - getStr := func(s *string) string { - if s == nil || *s == "" { - return "" - } - return *s - } - out := make([]gin.H, 0, len(bindings)) - for _, b := range bindings { - refNick := "微信用户" - var refereePhone, refereeAvatar *string - if u := userMap[b.RefereeID]; u != nil { - if u.Nickname != nil && *u.Nickname != "" { - refNick = *u.Nickname - } else { - refNick = "微信用户" - } - refereePhone = u.Phone - refereeAvatar = u.Avatar - } - var referrerName, referrerAvatar *string - if u := userMap[b.ReferrerID]; u != nil { - referrerName = u.Nickname - referrerAvatar = u.Avatar - } - days := 0 - if b.ExpiryDate.After(time.Now()) { - days = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24) - } - // 佣金展示用累计佣金 total_commission(支付回调累加),无则用 commission_amount - commissionVal := b.TotalCommission - if commissionVal == nil { - commissionVal = b.CommissionAmount - } - statusVal := "" - if b.Status != nil { - statusVal = *b.Status - } - out = append(out, gin.H{ - "id": b.ID, "referrerId": b.ReferrerID, "referrerName": getStr(referrerName), "referrerCode": b.ReferralCode, "referrerAvatar": getStr(referrerAvatar), - "refereeId": b.RefereeID, "refereeNickname": refNick, "refereePhone": getStr(refereePhone), "refereeAvatar": getStr(refereeAvatar), - "boundAt": b.BindingDate, "expiresAt": b.ExpiryDate, "status": statusVal, - "daysRemaining": days, "commission": commissionVal, "totalCommission": commissionVal, "source": "miniprogram", - }) - } - totalPages := int(total) / pageSize - if int(total)%pageSize > 0 { - totalPages++ - } - c.JSON(http.StatusOK, gin.H{ - "success": true, "bindings": out, - "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, - }) -} - -// DBChapters GET/POST /api/db/chapters -func DBChapters(c *gin.Context) { - var list []model.Chapter - if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "data": []interface{}{}}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) -} - -// DBConfigDelete DELETE /api/db/config -func DBConfigDelete(c *gin.Context) { - key := c.Query("key") - if key == "" { - c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"}) - return - } - if err := database.DB().Where("config_key = ?", key).Delete(&model.SystemConfig{}).Error; err != nil { - c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"success": true}) -} - -// DBInitGet GET /api/db/init -func DBInitGet(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "ok"}}) -} - -// DBMigrateGet GET /api/db/migrate -func DBMigrateGet(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移状态查询(由 Prisma/外部维护)"}) -} - -// DBMigratePost POST /api/db/migrate -func DBMigratePost(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移由 Prisma/外部执行"}) -} diff --git a/soul-api/internal/handler/db_book.go b/soul-api/internal/handler/db_book.go deleted file mode 100644 index f9f83c90..00000000 --- a/soul-api/internal/handler/db_book.go +++ /dev/null @@ -1,330 +0,0 @@ -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}) -} diff --git a/soul-api/internal/handler/distribution.go b/soul-api/internal/handler/distribution.go deleted file mode 100644 index f0c44ede..00000000 --- a/soul-api/internal/handler/distribution.go +++ /dev/null @@ -1,22 +0,0 @@ -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}) -} diff --git a/soul-api/internal/handler/documentation.go b/soul-api/internal/handler/documentation.go deleted file mode 100644 index 00699750..00000000 --- a/soul-api/internal/handler/documentation.go +++ /dev/null @@ -1,12 +0,0 @@ -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}) -} diff --git a/soul-api/internal/handler/match.go b/soul-api/internal/handler/match.go deleted file mode 100644 index e933ef99..00000000 --- a/soul-api/internal/handler/match.go +++ /dev/null @@ -1,253 +0,0 @@ -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), - }) -} diff --git a/soul-api/internal/handler/match_records.go b/soul-api/internal/handler/match_records.go deleted file mode 100644 index e236e5cd..00000000 --- a/soul-api/internal/handler/match_records.go +++ /dev/null @@ -1,96 +0,0 @@ -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, - }) -} diff --git a/soul-api/internal/handler/menu.go b/soul-api/internal/handler/menu.go deleted file mode 100644 index 43f6c32e..00000000 --- a/soul-api/internal/handler/menu.go +++ /dev/null @@ -1,12 +0,0 @@ -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{}{}}) -} diff --git a/soul-api/internal/handler/miniprogram.go b/soul-api/internal/handler/miniprogram.go deleted file mode 100644 index f0c5558b..00000000 --- a/soul-api/internal/handler/miniprogram.go +++ /dev/null @@ -1,831 +0,0 @@ -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/notify(v3 支付回调,PowerWeChat 验签解密) -func MiniprogramPayNotify(c *gin.Context) { - resp, err := wechat.HandlePayNotify(c.Request, func(orderSn, transactionID string, totalFee int, attachStr, openID string) error { - totalAmount := float64(totalFee) / 100 - fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount) - - var attach struct { - ProductType string `json:"productType"` - ProductID string `json:"productId"` - UserID string `json:"userId"` - } - if attachStr != "" { - _ = json.Unmarshal([]byte(attachStr), &attach) - } - - db := database.DB() - buyerUserID := attach.UserID - if openID != "" { - var user model.User - if err := db.Where("open_id = ?", openID).First(&user).Error; err == nil { - if attach.UserID != "" && user.ID != attach.UserID { - fmt.Printf("[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准\n") - } - buyerUserID = user.ID - } - } - if buyerUserID == "" && attach.UserID != "" { - buyerUserID = attach.UserID - } - - var order model.Order - result := db.Where("order_sn = ?", orderSn).First(&order) - if result.Error != nil { - fmt.Printf("[PayNotify] 订单不存在,补记订单: %s\n", orderSn) - productID := attach.ProductID - if productID == "" { - productID = "fullbook" - } - productType := attach.ProductType - if productType == "" { - productType = "unknown" - } - desc := "支付回调补记订单" - status := "paid" - now := time.Now() - order = model.Order{ - ID: orderSn, - OrderSN: orderSn, - UserID: buyerUserID, - OpenID: openID, - ProductType: productType, - ProductID: &productID, - Amount: totalAmount, - Description: &desc, - Status: &status, - TransactionID: &transactionID, - PayTime: &now, - } - if err := db.Create(&order).Error; err != nil { - fmt.Printf("[PayNotify] 补记订单失败: %s, err=%v\n", orderSn, err) - return fmt.Errorf("create order: %w", err) - } - } else if *order.Status != "paid" { - status := "paid" - now := time.Now() - if err := db.Model(&order).Updates(map[string]interface{}{ - "status": status, - "transaction_id": transactionID, - "pay_time": now, - }).Error; err != nil { - fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err) - return fmt.Errorf("update order: %w", err) - } - fmt.Printf("[PayNotify] 订单状态已更新为已支付: %s\n", orderSn) - } else { - fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn) - } - - if buyerUserID != "" && attach.ProductType != "" { - if attach.ProductType == "fullbook" { - db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true) - fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID) - } else if attach.ProductType == "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 `` -} - -func failResponse() string { - return `` -} - -// 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}) -} diff --git a/soul-api/internal/handler/orders.go b/soul-api/internal/handler/orders.go deleted file mode 100644 index fb47a71c..00000000 --- a/soul-api/internal/handler/orders.go +++ /dev/null @@ -1,267 +0,0 @@ -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": "退款成功"}) -} diff --git a/soul-api/internal/handler/payment.go b/soul-api/internal/handler/payment.go deleted file mode 100644 index c49453d0..00000000 --- a/soul-api/internal/handler/payment.go +++ /dev/null @@ -1,141 +0,0 @@ -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) -} diff --git a/soul-api/internal/handler/referral.go b/soul-api/internal/handler/referral.go deleted file mode 100644 index 38adb087..00000000 --- a/soul-api/internal/handler/referral.go +++ /dev/null @@ -1,525 +0,0 @@ -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 -} diff --git a/soul-api/internal/handler/referral_commission.go b/soul-api/internal/handler/referral_commission.go deleted file mode 100644 index 5011da4a..00000000 --- a/soul-api/internal/handler/referral_commission.go +++ /dev/null @@ -1,79 +0,0 @@ -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 -} diff --git a/soul-api/internal/handler/search.go b/soul-api/internal/handler/search.go deleted file mode 100644 index f8dd7b83..00000000 --- a/soul-api/internal/handler/search.go +++ /dev/null @@ -1,81 +0,0 @@ -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}, - }) -} diff --git a/soul-api/internal/handler/sync.go b/soul-api/internal/handler/sync.go deleted file mode 100644 index a5bf15eb..00000000 --- a/soul-api/internal/handler/sync.go +++ /dev/null @@ -1,22 +0,0 @@ -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}) -} diff --git a/soul-api/internal/handler/upload.go b/soul-api/internal/handler/upload.go deleted file mode 100644 index b8ff1fa7..00000000 --- a/soul-api/internal/handler/upload.go +++ /dev/null @@ -1,81 +0,0 @@ -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": "删除成功"}) -} diff --git a/soul-api/internal/handler/user.go b/soul-api/internal/handler/user.go deleted file mode 100644 index 8410f4d9..00000000 --- a/soul-api/internal/handler/user.go +++ /dev/null @@ -1,609 +0,0 @@ -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": "更新成功"}) -} diff --git a/soul-api/internal/handler/vip.go b/soul-api/internal/handler/vip.go deleted file mode 100644 index fc8fbc68..00000000 --- a/soul-api/internal/handler/vip.go +++ /dev/null @@ -1,356 +0,0 @@ -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 表判断是否 VIP(is_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() - - // 校验是否 VIP(users 或 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 构建会员展示数据;优先 vip_*,无则回退到用户 nickname/avatar -// 用于首页超级个体、创业老板排行、会员详情页等场景;含 P3 资料扩展以对接 member-detail -func formatVipMember(u *model.User, isVip bool) gin.H { - name := "" - if u.VipName != nil && *u.VipName != "" { - name = *u.VipName - } - if name == "" && u.Nickname != nil && *u.Nickname != "" { - name = *u.Nickname - } - if name == "" { - name = "创业者" - } - avatar := "" - if u.VipAvatar != nil && *u.VipAvatar != "" { - avatar = *u.VipAvatar - } - if avatar == "" && u.Avatar != nil && *u.Avatar != "" { - avatar = *u.Avatar - } - 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) -} diff --git a/soul-api/internal/handler/vip_roles.go b/soul-api/internal/handler/vip_roles.go deleted file mode 100644 index 1d9c3d16..00000000 --- a/soul-api/internal/handler/vip_roles.go +++ /dev/null @@ -1,90 +0,0 @@ -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": "不支持的请求方法"}) -} diff --git a/soul-api/internal/handler/wechat.go b/soul-api/internal/handler/wechat.go deleted file mode 100644 index 555fa007..00000000 --- a/soul-api/internal/handler/wechat.go +++ /dev/null @@ -1,160 +0,0 @@ -package handler - -import ( - "fmt" - "net/http" - "strings" - "time" - - "soul-api/internal/database" - "soul-api/internal/model" - "soul-api/internal/wechat" - - "github.com/gin-gonic/gin" -) - -// WechatLogin POST /api/wechat/login -func WechatLogin(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"success": true}) -} - -// WechatPhoneLoginReq 手机号登录请求:code 为 wx.login() 的 code,phoneCode 为 getPhoneNumber 返回的 code -type WechatPhoneLoginReq struct { - Code string `json:"code"` // wx.login() 得到,用于 code2session 拿 openId - PhoneCode string `json:"phoneCode"` // getPhoneNumber 得到,用于换手机号 -} - -// WechatPhoneLogin POST /api/wechat/phone-login -// 请求体:code(必填)+ phoneCode(必填)。先 code2session 得到 openId,再 getPhoneNumber 得到手机号,创建/更新用户并返回与 /api/miniprogram/login 一致的数据结构。 -func WechatPhoneLogin(c *gin.Context) { - var req WechatPhoneLoginReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code 或 phoneCode"}) - return - } - if req.Code == "" || req.PhoneCode == "" { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供 code 与 phoneCode"}) - return - } - - openID, sessionKey, _, err := wechat.Code2Session(req.Code) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)}) - return - } - phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.PhoneCode) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("获取手机号失败: %v", err)}) - return - } - - db := database.DB() - var user model.User - result := db.Where("open_id = ?", openID).First(&user) - isNewUser := result.Error != nil - - if isNewUser { - referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:]) - nickname := "微信用户" + openID[len(openID)-4:] - avatar := "" - hasFullBook := false - earnings := 0.0 - pendingEarnings := 0.0 - referralCount := 0 - purchasedSections := "[]" - phone := phoneNumber - if countryCode != "" && countryCode != "86" { - phone = "+" + countryCode + " " + phoneNumber - } - user = model.User{ - ID: openID, - OpenID: &openID, - SessionKey: &sessionKey, - Nickname: &nickname, - Avatar: &avatar, - Phone: &phone, - ReferralCode: &referralCode, - HasFullBook: &hasFullBook, - PurchasedSections: &purchasedSections, - Earnings: &earnings, - PendingEarnings: &pendingEarnings, - ReferralCount: &referralCount, - } - if err := db.Create(&user).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"}) - return - } - } else { - phone := phoneNumber - if countryCode != "" && countryCode != "86" { - phone = "+" + countryCode + " " + phoneNumber - } - db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone}) - user.Phone = &phone - } - - var orderRows []struct { - ProductID string `gorm:"column:product_id"` - } - db.Raw(` - SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section' - `, user.ID).Scan(&orderRows) - purchasedSections := []string{} - for _, row := range orderRows { - if row.ProductID != "" { - purchasedSections = append(purchasedSections, row.ProductID) - } - } - - responseUser := map[string]interface{}{ - "id": user.ID, - "openId": strVal(user.OpenID), - "nickname": strVal(user.Nickname), - "avatar": strVal(user.Avatar), - "phone": strVal(user.Phone), - "wechatId": strVal(user.WechatID), - "referralCode": strVal(user.ReferralCode), - "hasFullBook": boolVal(user.HasFullBook), - "purchasedSections": purchasedSections, - "earnings": floatVal(user.Earnings), - "pendingEarnings": floatVal(user.PendingEarnings), - "referralCount": intVal(user.ReferralCount), - "createdAt": user.CreatedAt, - } - token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix()) - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": map[string]interface{}{ - "openId": openID, - "user": responseUser, - "token": token, - }, - "isNewUser": isNewUser, - }) -} - -func strVal(p *string) string { - if p == nil { - return "" - } - return *p -} -func boolVal(p *bool) bool { - if p == nil { - return false - } - return *p -} -func floatVal(p *float64) float64 { - if p == nil { - return 0 - } - return *p -} -func intVal(p *int) int { - if p == nil { - return 0 - } - return *p -} diff --git a/soul-api/internal/handler/withdraw.go b/soul-api/internal/handler/withdraw.go deleted file mode 100644 index 0f50c05a..00000000 --- a/soul-api/internal/handler/withdraw.go +++ /dev/null @@ -1,363 +0,0 @@ -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": "已记录确认收款"}) -} diff --git a/soul-api/internal/handler/withdraw_v3.go b/soul-api/internal/handler/withdraw_v3.go deleted file mode 100644 index ac3a6047..00000000 --- a/soul-api/internal/handler/withdraw_v3.go +++ /dev/null @@ -1,341 +0,0 @@ -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, - }) -} diff --git a/soul-api/internal/middleware/admin_auth.go b/soul-api/internal/middleware/admin_auth.go deleted file mode 100644 index 72d351b2..00000000 --- a/soul-api/internal/middleware/admin_auth.go +++ /dev/null @@ -1,44 +0,0 @@ -package middleware - -import ( - "net/http" - - "soul-api/internal/auth" - "soul-api/internal/config" - - "github.com/gin-gonic/gin" -) - -const adminClaimsKey = "admin_claims" - -// AdminAuth 管理端鉴权:校验 JWT(Authorization: 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 -} diff --git a/soul-api/internal/middleware/ratelimit.go b/soul-api/internal/middleware/ratelimit.go deleted file mode 100644 index 8643e934..00000000 --- a/soul-api/internal/middleware/ratelimit.go +++ /dev/null @@ -1,65 +0,0 @@ -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() - } - }() -} diff --git a/soul-api/internal/middleware/secure.go b/soul-api/internal/middleware/secure.go deleted file mode 100644 index 7e9eec19..00000000 --- a/soul-api/internal/middleware/secure.go +++ /dev/null @@ -1,25 +0,0 @@ -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() - } -} diff --git a/soul-api/internal/model/README.txt b/soul-api/internal/model/README.txt deleted file mode 100644 index a2c6b5a7..00000000 --- a/soul-api/internal/model/README.txt +++ /dev/null @@ -1 +0,0 @@ -在此目录放置 GORM 模型与请求/响应结构体,例如 User、Order、Withdrawal、Config 等。 diff --git a/soul-api/internal/model/chapter.go b/soul-api/internal/model/chapter.go deleted file mode 100644 index 6f0272bb..00000000 --- a/soul-api/internal/model/chapter.go +++ /dev/null @@ -1,28 +0,0 @@ -package model - -import "time" - -// Chapter 对应表 chapters(mid 为自增主键,id 保留业务标识如 1.1、preface) -type Chapter struct { - MID int `gorm:"column:mid;primaryKey;autoIncrement" json:"mid"` - ID string `gorm:"column:id;size:20;uniqueIndex" json:"id"` - PartID string `gorm:"column:part_id;size:20" json:"partId"` - PartTitle string `gorm:"column:part_title;size:100" json:"partTitle"` - ChapterID string `gorm:"column:chapter_id;size:20" json:"chapterId"` - ChapterTitle string `gorm:"column:chapter_title;size:200" json:"chapterTitle"` - SectionTitle string `gorm:"column:section_title;size:200" json:"sectionTitle"` - Content string `gorm:"column:content;type:longtext" json:"content,omitempty"` - WordCount *int `gorm:"column:word_count" json:"wordCount,omitempty"` - IsFree *bool `gorm:"column:is_free" json:"isFree,omitempty"` - Price *float64 `gorm:"column:price;type:decimal(10,2)" json:"price,omitempty"` - SortOrder *int `gorm:"column:sort_order" json:"sortOrder,omitempty"` - Status *string `gorm:"column:status;size:20" json:"status,omitempty"` - 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" } diff --git a/soul-api/internal/model/match_record.go b/soul-api/internal/model/match_record.go deleted file mode 100644 index 2db72b55..00000000 --- a/soul-api/internal/model/match_record.go +++ /dev/null @@ -1,18 +0,0 @@ -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" } diff --git a/soul-api/internal/model/order.go b/soul-api/internal/model/order.go deleted file mode 100644 index 8552911e..00000000 --- a/soul-api/internal/model/order.go +++ /dev/null @@ -1,25 +0,0 @@ -package model - -import "time" - -// Order 对应表 orders,JSON 输出与现网接口 1:1(小写驼峰) -type Order struct { - ID string `gorm:"column:id;primaryKey;size:50" json:"id"` - OrderSN string `gorm:"column:order_sn;uniqueIndex;size:50" json:"orderSn"` - UserID string `gorm:"column:user_id;size:50" json:"userId"` - OpenID string `gorm:"column:open_id;size:100" json:"openId"` - ProductType string `gorm:"column:product_type;size:50" json:"productType"` - ProductID *string `gorm:"column:product_id;size:50" json:"productId,omitempty"` - Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"` - Description *string `gorm:"column:description;size:200" json:"description,omitempty"` - Status *string `gorm:"column:status;size:20" json:"status,omitempty"` - TransactionID *string `gorm:"column:transaction_id;size:100" json:"transactionId,omitempty"` - PayTime *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"` - ReferralCode *string `gorm:"column:referral_code;size:255" json:"referralCode,omitempty"` - ReferrerID *string `gorm:"column:referrer_id;size:255" json:"referrerId,omitempty"` - 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" } diff --git a/soul-api/internal/model/reading_progress.go b/soul-api/internal/model/reading_progress.go deleted file mode 100644 index a9a93c3f..00000000 --- a/soul-api/internal/model/reading_progress.go +++ /dev/null @@ -1,20 +0,0 @@ -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" } diff --git a/soul-api/internal/model/referral_binding.go b/soul-api/internal/model/referral_binding.go deleted file mode 100644 index cfd2b53f..00000000 --- a/soul-api/internal/model/referral_binding.go +++ /dev/null @@ -1,22 +0,0 @@ -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" } diff --git a/soul-api/internal/model/referral_visit.go b/soul-api/internal/model/referral_visit.go deleted file mode 100644 index 8b6918e1..00000000 --- a/soul-api/internal/model/referral_visit.go +++ /dev/null @@ -1,16 +0,0 @@ -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" } diff --git a/soul-api/internal/model/system_config.go b/soul-api/internal/model/system_config.go deleted file mode 100644 index 590e221a..00000000 --- a/soul-api/internal/model/system_config.go +++ /dev/null @@ -1,35 +0,0 @@ -package model - -import ( - "database/sql/driver" - "time" -) - -// ConfigValue 存 system_config.config_value(JSON 列,可为 object 或 array) -type ConfigValue []byte - -func (c ConfigValue) Value() (driver.Value, error) { return []byte(c), nil } -func (c *ConfigValue) Scan(value interface{}) error { - if value == nil { - *c = nil - return nil - } - b, ok := value.([]byte) - if !ok { - return nil - } - *c = append((*c)[0:0], b...) - return nil -} - -// SystemConfig 对应表 system_config,JSON 输出与现网 1:1(小写驼峰) -type SystemConfig struct { - ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - ConfigKey string `gorm:"column:config_key;uniqueIndex;size:100" json:"configKey"` - ConfigValue ConfigValue `gorm:"column:config_value;type:json" json:"configValue"` - Description *string `gorm:"column:description;size:200" json:"description,omitempty"` - CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` - UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` -} - -func (SystemConfig) TableName() string { return "system_config" } diff --git a/soul-api/internal/model/user.go b/soul-api/internal/model/user.go deleted file mode 100644 index 066f8aef..00000000 --- a/soul-api/internal/model/user.go +++ /dev/null @@ -1,55 +0,0 @@ -package model - -import "time" - -// User 对应表 users,JSON 输出与现网接口 1:1(小写驼峰) -type User struct { - ID string `gorm:"column:id;primaryKey;size:50" json:"id"` - OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"` - SessionKey *string `gorm:"column:session_key;size:200" json:"-"` // 微信 session_key,不输出到 JSON - Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"` - Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"` - Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"` - WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"` - // 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" } diff --git a/soul-api/internal/model/user_address.go b/soul-api/internal/model/user_address.go deleted file mode 100644 index 3c5b4818..00000000 --- a/soul-api/internal/model/user_address.go +++ /dev/null @@ -1,20 +0,0 @@ -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" } diff --git a/soul-api/internal/model/user_track.go b/soul-api/internal/model/user_track.go deleted file mode 100644 index f86f42dd..00000000 --- a/soul-api/internal/model/user_track.go +++ /dev/null @@ -1,16 +0,0 @@ -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" } diff --git a/soul-api/internal/model/vip_role.go b/soul-api/internal/model/vip_role.go deleted file mode 100644 index 8f253d02..00000000 --- a/soul-api/internal/model/vip_role.go +++ /dev/null @@ -1,14 +0,0 @@ -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" } diff --git a/soul-api/internal/model/wechat_callback_log.go b/soul-api/internal/model/wechat_callback_log.go deleted file mode 100644 index 4e118e0f..00000000 --- a/soul-api/internal/model/wechat_callback_log.go +++ /dev/null @@ -1,21 +0,0 @@ -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" } diff --git a/soul-api/internal/model/withdrawal.go b/soul-api/internal/model/withdrawal.go deleted file mode 100644 index ed125557..00000000 --- a/soul-api/internal/model/withdrawal.go +++ /dev/null @@ -1,24 +0,0 @@ -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" } diff --git a/soul-api/internal/repository/README.txt b/soul-api/internal/repository/README.txt deleted file mode 100644 index 4f0cfda6..00000000 --- a/soul-api/internal/repository/README.txt +++ /dev/null @@ -1 +0,0 @@ -在此目录放置数据库访问层,供 service 调用,例如 UserRepo、OrderRepo、ConfigRepo 等。 diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go deleted file mode 100644 index ba78736c..00000000 --- a/soul-api/internal/router/router.go +++ /dev/null @@ -1,316 +0,0 @@ -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("/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 -} diff --git a/soul-api/internal/service/README.txt b/soul-api/internal/service/README.txt deleted file mode 100644 index 50e47169..00000000 --- a/soul-api/internal/service/README.txt +++ /dev/null @@ -1 +0,0 @@ -在此目录放置业务逻辑,供 handler 调用,例如 AdminService、UserService、PaymentService 等。 diff --git a/soul-api/internal/wechat/miniprogram.go b/soul-api/internal/wechat/miniprogram.go deleted file mode 100644 index c2100e81..00000000 --- a/soul-api/internal/wechat/miniprogram.go +++ /dev/null @@ -1,449 +0,0 @@ -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 为接收人 openid,amount 为提现金额(元),success 为 true 表示打款已受理 -func SendWithdrawSubscribeMessage(ctx context.Context, openID string, amount float64, success bool) error { - if miniProgramApp == nil { - return fmt.Errorf("小程序未初始化") - } - phrase := "提现成功" - thing8 := "微信打款成功,请点击查收" - if !success { - phrase = "提现失败" - thing8 = "请联系官方客服" - } - amountStr := fmt.Sprintf("¥%.2f", amount) - data := &power.HashMap{ - "phrase4": object.HashMap{"value": phrase}, - "amount5": object.HashMap{"value": amountStr}, - "thing8": object.HashMap{"value": thing8}, - } - state := "formal" - if cfg != nil && cfg.WechatMiniProgramState != "" { - state = cfg.WechatMiniProgramState - } - _, err := miniProgramApp.SubscribeMessage.Send(ctx, &subrequest.RequestSubscribeMessageSend{ - ToUser: openID, - TemplateID: WithdrawSubscribeTemplateID, - Page: "/pages/my/my", - MiniProgramState: state, - Lang: "zh_CN", - Data: data, - }) - return err -} diff --git a/soul-api/internal/wechat/transfer.go b/soul-api/internal/wechat/transfer.go deleted file mode 100644 index 2b860c5e..00000000 --- a/soul-api/internal/wechat/transfer.go +++ /dev/null @@ -1,230 +0,0 @@ -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 -} diff --git a/soul-api/internal/wechat/transferv3/client.go b/soul-api/internal/wechat/transferv3/client.go deleted file mode 100644 index 471907fa..00000000 --- a/soul-api/internal/wechat/transferv3/client.go +++ /dev/null @@ -1,120 +0,0 @@ -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, "") -} diff --git a/soul-api/internal/wechat/transferv3/decrypt.go b/soul-api/internal/wechat/transferv3/decrypt.go deleted file mode 100644 index 66d5dd91..00000000 --- a/soul-api/internal/wechat/transferv3/decrypt.go +++ /dev/null @@ -1,52 +0,0 @@ -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 -} diff --git a/soul-api/internal/wechat/transferv3/sign.go b/soul-api/internal/wechat/transferv3/sign.go deleted file mode 100644 index 83fce832..00000000 --- a/soul-api/internal/wechat/transferv3/sign.go +++ /dev/null @@ -1,48 +0,0 @@ -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 -} diff --git a/soul-api/qgL5DeGe9A.txt b/soul-api/qgL5DeGe9A.txt deleted file mode 100755 index d4045a5b..00000000 --- a/soul-api/qgL5DeGe9A.txt +++ /dev/null @@ -1 +0,0 @@ -16d770afdc8b7273eb7a93814af01b23 \ No newline at end of file diff --git a/soul-api/scripts/__pycache__/test_transfer_notify.cpython-311.pyc b/soul-api/scripts/__pycache__/test_transfer_notify.cpython-311.pyc deleted file mode 100644 index e638b0a2..00000000 Binary files a/soul-api/scripts/__pycache__/test_transfer_notify.cpython-311.pyc and /dev/null differ diff --git a/soul-api/scripts/add-vip-activated-at.sql b/soul-api/scripts/add-vip-activated-at.sql deleted file mode 100644 index ee36d60f..00000000 --- a/soul-api/scripts/add-vip-activated-at.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 新增 users.vip_activated_at:成为 VIP 时间,用于排序(后付款/后设置在前) --- 执行:mysql -u user -p database < add-vip-activated-at.sql --- 若列已存在会报错,可忽略 - -ALTER TABLE users ADD COLUMN vip_activated_at DATETIME NULL COMMENT '成为VIP时间,付款=pay_time,手动=now,排序用'; - --- 可选:为已有 VIP 用户回填 vip_activated_at(取该用户最近一次 vip 订单的 pay_time) --- UPDATE users u --- SET u.vip_activated_at = ( --- SELECT MAX(o.pay_time) FROM orders o --- WHERE o.user_id = u.id AND o.product_type = 'vip' AND o.status = 'paid' --- ) --- WHERE u.is_vip = 1 AND u.vip_activated_at IS NULL; diff --git a/soul-api/scripts/add-vip-profile-fields.sql b/soul-api/scripts/add-vip-profile-fields.sql deleted file mode 100644 index 335a460b..00000000 --- a/soul-api/scripts/add-vip-profile-fields.sql +++ /dev/null @@ -1,20 +0,0 @@ --- ============================================================ --- 会员资料字段 - users 表 --- 用途:VIP 页保存资料、创业老板排行展示(与用户信息 phone/wechat_id 分离) --- 执行前请先备份数据库! --- ============================================================ - --- 1. 检查:查看 users 表是否已有这些列(可选执行) --- SHOW COLUMNS FROM users LIKE 'vip_name'; --- SHOW COLUMNS FROM users LIKE 'vip_avatar'; --- SHOW COLUMNS FROM users LIKE 'vip_project'; --- SHOW COLUMNS FROM users LIKE 'vip_contact'; --- SHOW COLUMNS FROM users LIKE 'vip_bio'; - --- 2. 新增会员资料字段(若列已存在会报 Duplicate column name,可忽略该条) --- -------------------------------------------------------- -ALTER TABLE users ADD COLUMN vip_name VARCHAR(100) NULL COMMENT '会员姓名(创业老板排行)'; -ALTER TABLE users ADD COLUMN vip_avatar VARCHAR(500) NULL COMMENT '会员头像'; -ALTER TABLE users ADD COLUMN vip_project VARCHAR(200) NULL COMMENT '项目名称'; -ALTER TABLE users ADD COLUMN vip_contact VARCHAR(100) NULL COMMENT '会员联系方式(展示用,与 phone/wechat_id 分离)'; -ALTER TABLE users ADD COLUMN vip_bio TEXT NULL COMMENT '一句话简介'; diff --git a/soul-api/scripts/add-vip-roles-and-fields.sql b/soul-api/scripts/add-vip-roles-and-fields.sql deleted file mode 100644 index adf5e0f1..00000000 --- a/soul-api/scripts/add-vip-roles-and-fields.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 1. 新建 vip_roles 表 -CREATE TABLE IF NOT EXISTS vip_roles ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称', - sort INT DEFAULT 0 COMMENT '下拉展示顺序,越小越前', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -) COMMENT '超级个体固定角色'; - --- 2. 插入默认角色(UNIQUE name 防重复) -INSERT IGNORE INTO vip_roles (name, sort) VALUES - ('创始人', 1), - ('投资人', 2), - ('产品经理', 3), - ('技术负责人', 4), - ('运营总监', 5), - ('销售总监', 6), - ('市场总监', 7), - ('合伙人', 8), - ('顾问', 9), - ('品牌主理人', 10); - --- 3. users 表新增 vip_sort、vip_role -ALTER TABLE users ADD COLUMN vip_sort INT NULL COMMENT '手动排序,越小越前'; -ALTER TABLE users ADD COLUMN vip_role VARCHAR(50) NULL COMMENT '角色:从 vip_roles 选或手动填写'; diff --git a/soul-api/scripts/fix-vip-orders.sql b/soul-api/scripts/fix-vip-orders.sql deleted file mode 100644 index 1860e29d..00000000 --- a/soul-api/scripts/fix-vip-orders.sql +++ /dev/null @@ -1,83 +0,0 @@ --- ============================================================ --- VIP 订单修复脚本 --- 场景:甲方开发的 VIP 支付可能未正确设置 product_type --- 会员价:1980 元 --- 执行前请先备份数据库! --- ============================================================ - --- 1. 诊断:查看当前疑似 VIP 订单的状态(执行后人工确认) --- -------------------------------------------------------- -SELECT id, order_sn, user_id, product_type, amount, status, pay_time, created_at -FROM orders -WHERE amount = 1980 - AND (status = 'paid' OR status = 'completed') -ORDER BY pay_time DESC; - --- 2. 统计:有多少条需要修复 --- -------------------------------------------------------- -SELECT COUNT(*) AS need_fix_count -FROM orders -WHERE amount = 1980 - AND (status = 'paid' OR status = 'completed') - AND (product_type IS NULL OR product_type = '' OR product_type NOT IN ('vip', 'fullbook')); - --- 3. 修复:将 1980 元已支付订单的 product_type 设为 'vip' --- -------------------------------------------------------- --- 条件:金额=1980 且 已支付 且 product_type 不是 vip/fullbook -UPDATE orders -SET product_type = 'vip' -WHERE amount = 1980 - AND (status = 'paid' OR status = 'completed') - AND (product_type IS NULL OR product_type = '' OR product_type NOT IN ('vip', 'fullbook')); - --- 4. 兼容大小写:若 product_type 为 'VIP'、'Vip' 等,统一为小写 --- -------------------------------------------------------- -UPDATE orders -SET product_type = 'vip' -WHERE amount = 1980 - AND (status = 'paid' OR status = 'completed') - AND LOWER(TRIM(product_type)) = 'vip' - AND product_type != 'vip'; - --- 5. 验证:修复后应无遗漏 --- -------------------------------------------------------- -SELECT id, order_sn, user_id, product_type, amount, status -FROM orders -WHERE amount = 1980 - AND (status = 'paid' OR status = 'completed') - AND product_type NOT IN ('vip', 'fullbook'); --- 期望结果:0 行 - --- ============================================================ --- 可选:若线上 next-project 用 users 表存 is_vip,需确保字段存在 --- 执行前请确认 users 表是否已有这些列! --- ============================================================ - --- 6. 检查 users 表是否有 VIP 相关列(MySQL) --- SHOW COLUMNS FROM users LIKE 'is_vip'; --- SHOW COLUMNS FROM users LIKE 'vip_expire_date'; - --- 7. 若 users 表无 VIP 列,可执行以下 ALTER(按需取消注释) --- -------------------------------------------------------- --- ALTER TABLE users ADD COLUMN is_vip TINYINT(1) DEFAULT 0; --- ALTER TABLE users ADD COLUMN vip_expire_date DATETIME NULL; --- ALTER TABLE users ADD COLUMN vip_name VARCHAR(100) NULL; --- ALTER TABLE users ADD COLUMN vip_avatar VARCHAR(500) NULL; --- ALTER TABLE users ADD COLUMN vip_project VARCHAR(200) NULL; --- ALTER TABLE users ADD COLUMN vip_contact VARCHAR(100) NULL; --- ALTER TABLE users ADD COLUMN vip_bio TEXT NULL; - --- 8. 从 orders 同步到 users(仅当用 users 表存 VIP 时) --- 将 1980 元已支付订单对应的用户标记为 VIP,过期日 = pay_time + 365 天 --- -------------------------------------------------------- --- UPDATE users u --- INNER JOIN ( --- SELECT user_id, MAX(pay_time) AS last_pay --- FROM orders --- WHERE amount = 1980 --- AND (status = 'paid' OR status = 'completed') --- AND product_type IN ('vip', 'fullbook') --- GROUP BY user_id --- ) o ON u.id = o.user_id --- SET u.is_vip = 1, --- u.vip_expire_date = DATE_ADD(o.last_pay, INTERVAL 365 DAY); diff --git a/soul-api/scripts/sync-orders.sh b/soul-api/scripts/sync-orders.sh deleted file mode 100644 index 194a64a5..00000000 --- a/soul-api/scripts/sync-orders.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# 订单对账防漏单 - 宝塔定时任务用 -# 建议每 10 分钟执行一次 - -URL="${SYNC_ORDERS_URL:-https://soul.quwanzhi.com/api/cron/sync-orders}" - -curl -s -X GET "$URL" \ - -H "User-Agent: Baota-Cron/1.0" \ - --connect-timeout 10 \ - --max-time 30 - -echo "" diff --git a/soul-api/scripts/test_transfer_notify.py b/soul-api/scripts/test_transfer_notify.py deleted file mode 100644 index 0bf114f8..00000000 --- a/soul-api/scripts/test_transfer_notify.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -模拟微信「商家转账到零钱」结果通知回调,请求本地/远程回调接口, -用于验证:1)接口是否可达 2)wechat_callback_logs 表是否会写入一条记录。 - -说明:未使用真实签名与加密,服务端会验签失败并返回 500, -但仍会写入 wechat_callback_logs 一条 handler_result=fail 的记录。 -运行前请确保 soul-api 已启动;运行后请查表 wechat_callback_logs 是否有新行。 -""" - -import json -import ssl -import sys -from datetime import datetime -from urllib.request import Request, urlopen -from urllib.error import URLError, HTTPError - -# 默认请求地址(可改环境或命令行) -DEFAULT_URL = "http://localhost:8080/api/payment/wechat/transfer/notify" - - -def main(): - args = [a for a in sys.argv[1:] if a and not a.startswith("-")] - insecure = "--insecure" in sys.argv or "-k" in sys.argv - url = args[0] if args else DEFAULT_URL - - if insecure and url.startswith("https://"): - print("已启用 --insecure,跳过 SSL 证书校验(仅用于本地/测试)") - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - else: - ctx = None - - # 模拟微信回调的请求体结构(真实场景中 resource.ciphertext 为 AEAD_AES_256_GCM 加密,这里用占位) - body = { - "id": "test-notify-id-" + datetime.now().strftime("%Y%m%d%H%M%S"), - "create_time": datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00"), - "resource_type": "encrypt-resource", - "event_type": "MCHTRANSFER.BILL.FINISHED", - "summary": "模拟转账结果通知", - "resource": { - "original_type": "mch_payment", - "algorithm": "AEAD_AES_256_GCM", - "ciphertext": "fake-base64-ciphertext-for-test", - "nonce": "fake-nonce", - "associated_data": "mch_payment", - }, - } - body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") - - headers = { - "Content-Type": "application/json", - "Wechatpay-Timestamp": str(int(datetime.now().timestamp())), - "Wechatpay-Nonce": "test-nonce-" + datetime.now().strftime("%H%M%S"), - "Wechatpay-Signature": "fake-signature-for-test", - "Wechatpay-Serial": "fake-serial-for-test", - } - - req = Request(url, data=body_bytes, headers=headers, method="POST") - - print(f"POST {url}") - print(f"Body (摘要): event_type={body['event_type']}, resource_type={body['resource_type']}") - print("-" * 50) - - try: - with urlopen(req, timeout=10, context=ctx) as resp: - print(f"HTTP 状态: {resp.status}") - raw = resp.read().decode("utf-8", errors="replace") - try: - parsed = json.loads(raw) - print("响应 JSON:", json.dumps(parsed, ensure_ascii=False, indent=2)) - except Exception: - print("响应 body:", raw[:500]) - except HTTPError as e: - print(f"HTTP 状态: {e.code}") - raw = e.read().decode("utf-8", errors="replace") - try: - parsed = json.loads(raw) - print("响应 JSON:", json.dumps(parsed, ensure_ascii=False, indent=2)) - except Exception: - print("响应 body:", raw[:500]) - except URLError as e: - print(f"请求失败: {e.reason}") - sys.exit(1) - - print("-" * 50) - print("请检查数据库表 wechat_callback_logs 是否有新记录(本次为模拟请求,预期会有一条 handler_result=fail 的记录)。") - - -if __name__ == "__main__": - main() diff --git a/soul-api/scripts/test_withdraw.py b/soul-api/scripts/test_withdraw.py deleted file mode 100644 index 77b54bbf..00000000 --- a/soul-api/scripts/test_withdraw.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -请求提现测试接口:固定用户提现 1 元(默认),无需 admin_session。 -用法: - python test_withdraw.py - python test_withdraw.py https://soul.quwanzhi.com - python test_withdraw.py http://localhost:8080 2 -""" - -import json -import sys -from urllib.request import Request, urlopen -from urllib.error import URLError, HTTPError -from urllib.parse import urlencode - -DEFAULT_BASE = "http://localhost:8080" -DEFAULT_USER_ID = "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg" -DEFAULT_AMOUNT = "1" - - -def main(): - base = DEFAULT_BASE - amount = DEFAULT_AMOUNT - args = [a for a in sys.argv[1:] if a] - if args: - if args[0].startswith("http://") or args[0].startswith("https://"): - base = args[0].rstrip("/") - args = args[1:] - if args: - amount = args[0] - - path = "/api/withdraw-test" - if not base.endswith(path): - base = base.rstrip("/") + path - url = f"{base}?{urlencode({'userId': DEFAULT_USER_ID, 'amount': amount})}" - - req = Request(url, method="GET") - req.add_header("Accept", "application/json") - - print(f"GET {url}") - print("-" * 50) - - try: - with urlopen(req, timeout=15) as resp: - raw = resp.read().decode("utf-8", errors="replace") - try: - print(json.dumps(json.loads(raw), ensure_ascii=False, indent=2)) - except Exception: - print(raw) - except HTTPError as e: - raw = e.read().decode("utf-8", errors="replace") - try: - print(json.dumps(json.loads(raw), ensure_ascii=False, indent=2)) - except Exception: - print(raw) - print(f"HTTP {e.code}", file=sys.stderr) - except URLError as e: - print(f"请求失败: {e.reason}", file=sys.stderr) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/soul-api/soul-api b/soul-api/soul-api deleted file mode 100755 index 1c90ed2c..00000000 Binary files a/soul-api/soul-api and /dev/null differ diff --git a/soul-api/uploads/avatars/1772165051417228100_qpc606.png b/soul-api/uploads/avatars/1772165051417228100_qpc606.png deleted file mode 100644 index fabb67db..00000000 Binary files a/soul-api/uploads/avatars/1772165051417228100_qpc606.png and /dev/null differ diff --git a/soul-api/商家转账.md b/soul-api/商家转账.md deleted file mode 100644 index 863c941c..00000000 --- a/soul-api/商家转账.md +++ /dev/null @@ -1,24 +0,0 @@ -req := &request.RequestTransferBills{ - Appid: "Appid", - OutBillNo: "OutBillNo", - TransferSceneId: "TransferSceneId", - Openid: "Openid", - UserName: "UserName", - TransferAmount: 1, - TransferRemark: "TransferRemark", - NotifyUrl: "NotifyUrl", - UserRecvPerception: "UserRecvPerception", - TransferSceneReportInfos: []request.TransferSceneReportInfo{ - { - InfoType: "InfoType", - InfoContent: "InfoContent", - }, - }, -} -ctx := c.Request.Context() -//fmt.Dump(ctx) -rs, err := services.PaymentApp.FundApp.TransferBills(ctx, req) -if err != nil { - panic(err) -} -c.JSON(http.StatusOK, rs) \ No newline at end of file diff --git a/soul-api/宝塔反向代理说明.txt b/soul-api/宝塔反向代理说明.txt deleted file mode 100644 index a776fa15..00000000 --- a/soul-api/宝塔反向代理说明.txt +++ /dev/null @@ -1,76 +0,0 @@ -# soul-api 域名 404 原因与解决 - -## 原因 -域名请求先到 Nginx,若没有把请求转发到本机 8080 的 Go,或站点用了 root/静态目录,就会 404。 - ---- - -## 一、先确认 Go 是否在跑(必做) - -在宝塔终端或 SSH 里执行: - - curl -s http://127.0.0.1:8080/health - -- 若返回 {"status":"ok"}:说明 Go 正常,问题在 Nginx,看下面第二步。 -- 若连接被拒绝或超时:说明 8080 没在监听。去 宝塔 → Go项目管理 → soulApi → 服务状态,看是否“运行中”;看“项目日志”是否有报错。 - ---- - -## 二、Nginx 必须“整站走代理”,不能走 root - -添加了反向代理仍 404,多半是: - -- 站点默认有 location / { root ...; index ...; },请求被当成静态文件处理,/health 找不到就 404; -- 或反向代理只绑在了子路径(如 /api),/ 和 /health 没被代理。 - -做法:让 soulapi.quwanzhi.com 的**所有路径**都走 8080,不要用 root。 - -在宝塔:网站 → soulapi.quwanzhi.com → 设置 → 配置文件,找到该站点的 server { ... },按下面两种方式之一改。 - -### 方式 A:只保留一个 location /(推荐) - -把 server 里**原来的** location / { ... }(含 root、index 的那段)**删掉或注释掉**,只保留下面这一段: - - location / { - proxy_pass http://127.0.0.1:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - -保存 → 重载 Nginx(或 宝塔 里点“重载配置”)。 - -### 方式 B:整站用下面这一整段 server(HTTPS 示例) - -若你希望整站只做反向代理、不混静态,可以把该站点的 server 块整体替换成下面内容(把 your_ssl_cert 等换成你实际的证书路径;没有 SSL 就只用 listen 80 那段): - - server { - listen 80; - listen 443 ssl http2; - server_name soulapi.quwanzhi.com; - # SSL 证书路径按宝塔实际填写,例如: - # ssl_certificate /www/server/panel/vhost/cert/soulapi.quwanzhi.com/fullchain.pem; - # ssl_certificate_key /www/server/panel/vhost/cert/soulapi.quwanzhi.com/privkey.pem; - - location / { - proxy_pass http://127.0.0.1:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - } - -保存并重载 Nginx。 - ---- - -## 三、改完后自测 - -- 本机:curl -s https://soulapi.quwanzhi.com/health -- 或浏览器打开:https://soulapi.quwanzhi.com/health -应看到:{"status":"ok"} -- 打开 https://soulapi.quwanzhi.com/ 应看到“部署成功”页面。 diff --git a/soul-api/开发文档/miniprogram接口补全说明.md b/soul-api/开发文档/miniprogram接口补全说明.md deleted file mode 100644 index 0c0c0536..00000000 --- a/soul-api/开发文档/miniprogram接口补全说明.md +++ /dev/null @@ -1,55 +0,0 @@ -# soul-api 小程序接口补全说明 - -## 变更背景 - -miniprogram 功能还原后,需将 VIP 相关接口从 `/api/vip/*` 迁移至 `/api/miniprogram/vip/*`,并补充 miniprogram 组下的 users 接口,符合项目边界(小程序只调 `/api/miniprogram/*`)。 - -## 新增接口 - -### 1. VIP 接口(handler/vip.go) - -| 路径 | 方法 | Handler | 用途 | -|------|------|---------|------| -| `/api/miniprogram/vip/status` | GET | VipStatus | 查询用户 VIP 状态 | -| `/api/miniprogram/vip/profile` | GET | VipProfileGet | 获取 VIP 资料 | -| `/api/miniprogram/vip/profile` | POST | VipProfilePost | 更新 VIP 资料 | -| `/api/miniprogram/vip/members` | GET | VipMembers | VIP 会员列表或单个 | - -**实现说明**: -- **status**:按 orders 表查 `product_type IN ('fullbook','vip')` 且 `status='paid'` 判断是否 VIP;返回 `isVip`、`daysRemaining`、`expireDate`、`price` -- **profile**:GET 从 users 表读 nickname、phone;POST 更新 nickname、phone -- **members**:无 `?id` 时返回有 fullbook/vip 订单的用户列表;有 `?id` 时返回单个用户,含 `vip_name`、`vip_avatar`、`vip_contact`、`is_vip` 等字段 - -### 2. 用户接口(handler/miniprogram.go) - -| 路径 | 方法 | Handler | 用途 | -|------|------|---------|------| -| `/api/miniprogram/users` | GET | MiniprogramUsers | 用户列表或单个 | - -**实现说明**: -- `?limit=20`:返回用户列表,用于首页「超级个体」不足 4 人时的补充 -- `?id=xxx`:返回单个用户,用于会员详情页在 vip/members 失败时的回退 -- 返回格式:`{ success, data }`,与 miniprogram 期望一致 - -## 已有接口(无需变更) - -以下接口已在 miniprogram 组挂载,miniprogram 已正确调用: - -- `/api/miniprogram/book/all-chapters` -- `/api/miniprogram/book/chapter/:id` -- `/api/miniprogram/book/chapter/by-mid/:mid` -- `/api/miniprogram/book/hot` -- `/api/miniprogram/book/search` -- `/api/miniprogram/book/stats` - -## 路由注册位置 - -`internal/router/router.go` 中 miniprogram 组末尾: - -```go -miniprogram.GET("/vip/status", handler.VipStatus) -miniprogram.GET("/vip/profile", handler.VipProfileGet) -miniprogram.POST("/vip/profile", handler.VipProfilePost) -miniprogram.GET("/vip/members", handler.VipMembers) -miniprogram.GET("/users", handler.MiniprogramUsers) -``` diff --git a/soul-api/提现功能完整技术文档.md b/soul-api/提现功能完整技术文档.md deleted file mode 100644 index 6eb1737f..00000000 --- a/soul-api/提现功能完整技术文档.md +++ /dev/null @@ -1,1020 +0,0 @@ -# 提现功能技术文档(微信支付API集成) - -## 文档说明 - -本文档专注于**微信支付商家转账到零钱API**的集成方法,包括: -- 微信支付官方API文档 -- 签名生成算法 -- 加密解密算法 -- 完整代码实现 -- 测试验证方法 - -**适用场景**:实现用户提现功能,将资金从商户号转账到用户微信零钱。 - ---- - -## 目录 - -1. [业务场景](#业务场景) -2. [微信支付官方API文档](#微信支付官方api文档) -3. [前置准备](#前置准备) -4. [API集成](#api集成) -5. [签名算法](#签名算法) -6. [加密解密](#加密解密) -7. [代码实现](#代码实现) -8. [测试验证](#测试验证) - ---- - -## 业务场景 - -### 典型流程 - -``` -用户申请提现 - ↓ -系统审核通过 - ↓ -调用微信支付【商家转账到零钱API】 - ↓ -微信返回处理中(PROCESSING) - ↓ -微信异步处理(7-15秒) - ↓ -微信【主动回调】通知转账结果 - ↓ -系统接收回调,验签、解密 - ↓ -更新提现状态 - ↓ -用户确认收款 -``` - -### 关键步骤 - -1. **发起转账**:调用微信API发起转账 -2. **接收回调**:接收微信异步通知 -3. **验证签名**:验证回调的真实性 -4. **解密数据**:解密回调中的加密数据 -5. **查询状态**:主动查询转账状态 - ---- - -## 微信支付官方API文档 - -### 核心API - -| API名称 | 官方文档地址 | -|--------|------------| -| 🔥 **商家转账到零钱** | https://pay.weixin.qq.com/doc/v3/merchant/4012716434 | -| 📋 **查询转账单(商户单号)** | https://pay.weixin.qq.com/doc/v3/merchant/4012716456 | -| 📋 **查询转账单(微信单号)** | https://pay.weixin.qq.com/doc/v3/merchant/4012716457 | -| 🔐 **签名生成与验证** | https://pay.weixin.qq.com/doc/v3/merchant/4013053249 | -| 🔒 **敏感信息加密** | https://pay.weixin.qq.com/doc/v3/merchant/4012070130 | -| 🔓 **回调通知解密** | https://pay.weixin.qq.com/doc/v3/merchant/4012071382 | -| 📝 **转账场景报备** | https://pay.weixin.qq.com/doc/v3/merchant/4012716437 | -| ❌ **错误码查询** | https://pay.weixin.qq.com/doc/v3/merchant/4012070193 | -| 📜 **平台证书管理** | https://pay.weixin.qq.com/doc/v3/merchant/4012154180 | - -### 开发指引 - -- **API V3 开发总览**:https://pay.weixin.qq.com/doc/v3/merchant/4012065168 - - ---- - -## 前置准备 - -### 1. 获取配置信息 - -登录微信商户平台:https://pay.weixin.qq.com - -| 配置项 | 说明 | 获取路径 | -|-------|------|---------| -| **商户号(mch_id)** | 微信支付商户号 | 账户中心 → 商户信息 | -| **APIv3密钥(api_v3_key)** | 32字节密钥,用于加密解密 | 账户中心 → API安全 → 设置APIv3密钥 | -| **商户私钥(apiclient_key.pem)** | 用于请求签名 | 账户中心 → API安全 → 申请证书 | -| **证书序列号(cert_serial_no)** | 商户证书标识 | 从证书文件提取 | -| **平台证书(wechat_pay_pub_key)** | 用于验证回调签名 | 下载或通过API获取 | -| **小程序AppId** | 小程序标识 | 小程序管理后台 | - -### 2. 提取证书序列号 - -**使用OpenSSL命令**: - -```bash -openssl x509 -in apiclient_cert.pem -noout -serial -``` - -输出: -``` -serial=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5 -``` - -**使用PHP**: - -```php - -``` - -### 3. 配置IP白名单 - -路径:微信商户平台 → 账户中心 → API安全 → IP配置 - -添加服务器公网IP地址。 - -**获取服务器IP**: - -```bash -curl ifconfig.me -``` - -### 4. 配置转账场景 - -路径:微信商户平台 → 产品中心 → 商家转账到零钱 → 前往功能 - -可选场景: -- **1000**:现金营销 -- **1005**:营销活动 - -**检查环境**: - - ---- - -## API集成 - -### 1. 商家转账到零钱API - -#### 基本信息 - -- **接口地址**:`https://api.mch.weixin.qq.com/v3/transfer/batches` -- **请求方法**:POST -- **Content-Type**:application/json - -#### 请求头 - -``` -Authorization: WECHATPAY2-SHA256-RSA2048 mchid="商户号",nonce_str="随机字符串",signature="签名",timestamp="时间戳",serial_no="证书序列号" -Content-Type: application/json -Accept: application/json -User-Agent: YourApp/1.0 -``` - -#### 请求参数 - -```json -{ - "appid": "wx6489c26045912fe1", - "out_batch_no": "BATCH202601291234567890", - "batch_name": "提现", - "batch_remark": "用户提现", - "total_amount": 5000, - "total_num": 1, - "transfer_detail_list": [ - { - "out_detail_no": "TX202601291234567890", - "transfer_amount": 5000, - "transfer_remark": "提现", - "openid": "odq3g5IOG-Z1WLpbeG_amUme8EZk" - } - ], - "transfer_scene_id": "1005", - "transfer_scene_report_infos": [ - { - "info_type": "岗位类型", - "info_content": "兼职人员" - }, - { - "info_type": "报酬说明", - "info_content": "当日兼职费" - } - ] -} -``` - -**参数说明**: - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| appid | string | 是 | 小程序AppId | -| out_batch_no | string | 是 | 商户批次单号,商户下唯一 | -| batch_name | string | 是 | 批次名称 | -| batch_remark | string | 是 | 批次备注 | -| total_amount | integer | 是 | 转账总金额,单位:**分** | -| total_num | integer | 是 | 转账总笔数 | -| transfer_detail_list | array | 是 | 转账明细列表 | -| transfer_scene_id | string | 是 | 转账场景ID:1000或1005 | -| transfer_scene_report_infos | array | 否 | 场景报备信息 | - -**transfer_detail_list说明**: - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| out_detail_no | string | 是 | 商户明细单号 | -| transfer_amount | integer | 是 | 转账金额,单位:**分** | -| transfer_remark | string | 是 | 转账备注 | -| openid | string | 是 | 收款用户OpenId | - -**场景报备信息(场景ID=1005)**: - -```json -[ - { - "info_type": "岗位类型", - "info_content": "兼职人员" - }, - { - "info_type": "报酬说明", - "info_content": "当日兼职费" - } -] -``` - -**重要**: -- `info_type` 必须是固定值 -- 金额单位是**分**:`元 * 100` - -#### 响应数据 - -**成功响应**: - -```json -{ - "out_batch_no": "BATCH202601291234567890", - "batch_id": "1030000071100999991182020050700019480001", - "create_time": "2026-01-29T12:30:00+08:00", - "batch_status": "PROCESSING" -} -``` - -**字段说明**: - -| 字段 | 说明 | -|------|------| -| out_batch_no | 商户批次单号 | -| batch_id | 微信批次单号 | -| create_time | 批次创建时间 | -| batch_status | 批次状态:PROCESSING/SUCCESS/FAIL | - -**失败响应**: - -```json -{ - "code": "PARAM_ERROR", - "message": "参数错误" -} -``` - -### 2. 查询转账单API - -#### 按商户单号查询 - -**接口地址**: - -``` -GET https://api.mch.weixin.qq.com/v3/transfer/batches/batch-id/{batch_id}/details/detail-id/{detail_id} -``` - -**路径参数**: -- `batch_id`:商户批次单号(需URL编码) -- `detail_id`:商户明细单号(需URL编码) - -**示例**: - -``` -GET /v3/transfer/batches/batch-id/BATCH202601291234567890/details/detail-id/TX202601291234567890 -``` - -**响应示例**: - -```json -{ - "mchid": "1318592501", - "out_batch_no": "BATCH202601291234567890", - "batch_id": "1030000071100999991182020050700019480001", - "out_detail_no": "TX202601291234567890", - "detail_id": "1040000071100999991182020050700019500100", - "detail_status": "SUCCESS", - "transfer_amount": 5000, - "transfer_remark": "提现", - "openid": "odq3g5IOG-Z1WLpbeG_amUme8EZk", - "initiate_time": "2026-01-29T12:30:00+08:00", - "update_time": "2026-01-29T12:30:15+08:00" -} -``` - -**状态说明**: - -| detail_status | 说明 | -|--------------|------| -| PROCESSING | 转账中 | -| SUCCESS | 转账成功 | -| FAIL | 转账失败 | - -### 3. 转账结果通知(回调) - -#### 回调触发 - -当转账状态变更时,微信支付会主动向配置的 `notify_url` 发送POST请求。 - -#### 回调请求头 - -``` -Wechatpay-Signature: 签名值 -Wechatpay-Timestamp: 1769653396 -Wechatpay-Nonce: R0PDA5lOV3IMrBjrvbCH5U4L3Lb0gg8L -Wechatpay-Serial: 642B2B33557205BA79A1CFF08EA2A2478D67BD63 -Wechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048 -Content-Type: application/json -``` - -#### 回调请求体(加密) - -```json -{ - "id": "cb29e425-ca17-59fb-8045-8e5b58917154", - "create_time": "2026-01-29T10:23:11+08:00", - "resource_type": "encrypt-resource", - "event_type": "MCHTRANSFER.BILL.FINISHED", - "summary": "商家转账单据终态通知", - "resource": { - "original_type": "mch_payment", - "algorithm": "AEAD_AES_256_GCM", - "ciphertext": "加密的数据...", - "associated_data": "mch_payment", - "nonce": "随机字符串" - } -} -``` - -#### 解密后的数据 - -```json -{ - "mch_id": "1318592501", - "out_bill_no": "TX202601291234567890", - "transfer_bill_no": "1330000114850082601290057112302122", - "transfer_amount": 5000, - "state": "SUCCESS", - "openid": "odq3g5IOG-Z1WLpbeG_amUme8EZk", - "create_time": "2026-01-29T12:30:00+08:00", - "update_time": "2026-01-29T12:30:15+08:00" -} -``` - -**state状态说明**: - -| state | 说明 | -|-------|------| -| PROCESSING | 转账中 | -| SUCCESS | 转账成功 | -| FAIL | 转账失败 | -| WAIT_USER_CONFIRM | 待用户确认 | -| TRANSFERING | 正在转账 | - -#### 回调响应 - -处理完成后,返回给微信: - -```json -{ - "code": "SUCCESS" -} -``` - ---- - -## 签名算法 - -### 1. 签名生成(请求签名) - -#### 签名串格式 - -``` -请求方法\n -请求URL路径\n -请求时间戳\n -随机字符串\n -请求报文主体\n -``` - -**示例**: - -``` -POST -/v3/transfer/batches -1234567890 -RandomString123456 -{"appid":"wx6489c26045912fe1"} -``` - -**重要**:每部分末尾都有 `\n` 换行符。 - -#### 签名步骤 - -1. 构建签名串 -2. 使用商户私钥进行SHA256withRSA签名 -3. 对签名结果进行Base64编码 - -#### PHP实现 - -```php -function buildSignature($method, $url, $timestamp, $nonce, $body, $privateKeyPath) { - // 1. 构建签名串 - $signStr = $method . "\n" - . $url . "\n" - . $timestamp . "\n" - . $nonce . "\n" - . $body . "\n"; - - // 2. 加载私钥 - $privateKeyContent = file_get_contents($privateKeyPath); - $privateKeyResource = openssl_pkey_get_private($privateKeyContent); - - // 3. 使用私钥签名 - openssl_sign($signStr, $signature, $privateKeyResource, 'sha256WithRSAEncryption'); - - // 4. Base64编码 - return base64_encode($signature); -} -``` - -#### 构建Authorization头 - -```php -function buildAuthorization($mchId, $timestamp, $nonce, $signature, $serialNo) { - return sprintf( - 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"', - $mchId, - $nonce, - $signature, - $timestamp, - $serialNo - ); -} -``` - -### 2. 签名验证(回调验签) - -#### 验签串格式 - -``` -时间戳\n -随机字符串\n -请求报文主体\n -``` - -**示例**: - -``` -1769653396 -R0PDA5lOV3IMrBjrvbCH5U4L3Lb0gg8L -{"id":"cb29e425-ca17-59fb-8045-8e5b58917154",...} -``` - -#### PHP实现 - -```php -function verifySignature($timestamp, $nonce, $body, $signature, $publicKeyPath) { - // 1. 构建验签串 - $verifyStr = $timestamp . "\n" - . $nonce . "\n" - . $body . "\n"; - - // 2. Base64解码签名 - $signatureDecode = base64_decode($signature); - - // 3. 加载平台公钥 - $publicKeyContent = file_get_contents($publicKeyPath); - $publicKeyResource = openssl_pkey_get_public($publicKeyContent); - - // 4. 验证签名 - $result = openssl_verify( - $verifyStr, - $signatureDecode, - $publicKeyResource, - 'sha256WithRSAEncryption' - ); - - return $result === 1; // 1表示验证成功 -} -``` - -**重要**:验签使用的是**微信支付平台公钥**,不是商户私钥! - ---- - -## 加密解密 - -### 回调数据解密 - -#### 算法信息 - -- **算法**:AEAD_AES_256_GCM -- **密钥**:APIv3密钥(32字节) -- **密文格式**:实际密文 + 认证标签(16字节) - -#### 解密步骤 - -1. 提取加密数据(ciphertext、nonce、associated_data) -2. Base64解码密文 -3. 分离密文和认证标签(最后16字节) -4. 使用AES-256-GCM解密 -5. 解析JSON数据 - -#### PHP实现 - -```php -function decryptCallbackData($ciphertext, $nonce, $associatedData, $apiV3Key) { - // 1. 检查APIv3密钥长度(必须32字节) - if (strlen($apiV3Key) !== 32) { - throw new Exception('APIv3密钥长度必须为32字节'); - } - - // 2. Base64解码密文 - $ciphertextDecoded = base64_decode($ciphertext); - - // 3. 分离密文和认证标签 - $authTag = substr($ciphertextDecoded, -16); - $ctext = substr($ciphertextDecoded, 0, -16); - - // 4. 使用AES-256-GCM解密 - $decrypted = openssl_decrypt( - $ctext, // 密文 - 'aes-256-gcm', // 算法 - $apiV3Key, // 密钥 - OPENSSL_RAW_DATA, // 选项 - $nonce, // 随机串 - $authTag, // 认证标签 - $associatedData // 附加数据 - ); - - if ($decrypted === false) { - throw new Exception('解密失败'); - } - - // 5. 解析JSON - return json_decode($decrypted, true); -} -``` - -**使用示例**: - -```php -$resource = $callbackData['resource']; -$decrypted = decryptCallbackData( - $resource['ciphertext'], - $resource['nonce'], - $resource['associated_data'], - 'wx3e31b068be59ddc131b068be59ddc2' // APIv3密钥 -); -``` - ---- - -## 代码实现 - -### 完整的微信支付转账类 - -```php -mchId = $config['mch_id']; - $this->appId = $config['app_id']; - $this->apiV3Key = $config['api_v3_key']; - $this->certSerialNo = $config['cert_serial_no']; - - // 加载私钥 - $privateKeyContent = file_get_contents($config['private_key']); - $this->privateKey = openssl_pkey_get_private($privateKeyContent); - } - - /** - * 发起转账 - */ - public function createTransfer($params) - { - $url = '/v3/transfer/batches'; - $method = 'POST'; - - // 构建请求数据 - $data = [ - 'appid' => $this->appId, - 'out_batch_no' => 'BATCH' . date('YmdHis') . mt_rand(1000, 9999), - 'batch_name' => $params['batch_name'] ?? '提现', - 'batch_remark' => $params['batch_remark'] ?? '用户提现', - 'total_amount' => $params['transfer_amount'], - 'total_num' => 1, - 'transfer_detail_list' => [ - [ - 'out_detail_no' => $params['out_detail_no'], - 'transfer_amount' => $params['transfer_amount'], - 'transfer_remark' => $params['transfer_remark'], - 'openid' => $params['openid'], - ] - ], - 'transfer_scene_id' => $params['transfer_scene_id'] ?? '1005', - ]; - - // 添加场景报备信息 - if (!empty($params['transfer_scene_report_infos'])) { - $data['transfer_scene_report_infos'] = $params['transfer_scene_report_infos']; - } - - $body = json_encode($data, JSON_UNESCAPED_UNICODE); - - // 生成签名 - $timestamp = time(); - $nonce = $this->generateNonce(); - $signature = $this->buildSignature($method, $url, $timestamp, $nonce, $body); - - // 构建Authorization - $authorization = $this->buildAuthorization($timestamp, $nonce, $signature); - - // 发送请求 - return $this->request($method, $url, $body, $authorization); - } - - /** - * 查询转账单 - */ - public function queryTransfer($batchNo, $detailNo) - { - $url = "/v3/transfer/batches/batch-id/" . urlencode($batchNo) - . "/details/detail-id/" . urlencode($detailNo); - $method = 'GET'; - - $timestamp = time(); - $nonce = $this->generateNonce(); - $signature = $this->buildSignature($method, $url, $timestamp, $nonce, ''); - $authorization = $this->buildAuthorization($timestamp, $nonce, $signature); - - return $this->request($method, $url, '', $authorization); - } - - /** - * 验证回调签名 - */ - public function verifyCallback($headers, $body, $publicKey) - { - $timestamp = $headers['wechatpay-timestamp']; - $nonce = $headers['wechatpay-nonce']; - $signature = $headers['wechatpay-signature']; - - $verifyStr = $timestamp . "\n" . $nonce . "\n" . $body . "\n"; - $signatureDecode = base64_decode($signature); - - $publicKeyContent = file_get_contents($publicKey); - $publicKeyResource = openssl_pkey_get_public($publicKeyContent); - - $result = openssl_verify($verifyStr, $signatureDecode, $publicKeyResource, 'sha256WithRSAEncryption'); - - return $result === 1; - } - - /** - * 解密回调数据 - */ - public function decryptCallbackResource($resource) - { - $ciphertext = $resource['ciphertext']; - $nonce = $resource['nonce']; - $associatedData = $resource['associated_data']; - - if (strlen($this->apiV3Key) !== 32) { - throw new \Exception('APIv3密钥长度必须为32字节'); - } - - $ciphertextDecoded = base64_decode($ciphertext); - $authTag = substr($ciphertextDecoded, -16); - $ctext = substr($ciphertextDecoded, 0, -16); - - $decrypted = openssl_decrypt( - $ctext, - 'aes-256-gcm', - $this->apiV3Key, - OPENSSL_RAW_DATA, - $nonce, - $authTag, - $associatedData - ); - - if ($decrypted === false) { - throw new \Exception('解密失败'); - } - - return json_decode($decrypted, true); - } - - /** - * 生成签名 - */ - private function buildSignature($method, $url, $timestamp, $nonce, $body) - { - $signStr = $method . "\n" - . $url . "\n" - . $timestamp . "\n" - . $nonce . "\n" - . $body . "\n"; - - openssl_sign($signStr, $signature, $this->privateKey, 'sha256WithRSAEncryption'); - - return base64_encode($signature); - } - - /** - * 构建Authorization头 - */ - private function buildAuthorization($timestamp, $nonce, $signature) - { - return sprintf( - 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%d",serial_no="%s"', - $this->mchId, - $nonce, - $signature, - $timestamp, - $this->certSerialNo - ); - } - - /** - * 生成随机字符串 - */ - private function generateNonce($length = 32) - { - $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - $nonce = ''; - for ($i = 0; $i < $length; $i++) { - $nonce .= $chars[mt_rand(0, strlen($chars) - 1)]; - } - return $nonce; - } - - /** - * 发送HTTP请求 - */ - private function request($method, $url, $body, $authorization) - { - $fullUrl = 'https://api.mch.weixin.qq.com' . $url; - - $headers = [ - 'Authorization: ' . $authorization, - 'Content-Type: application/json', - 'Accept: application/json', - 'User-Agent: YourApp/1.0' - ]; - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $fullUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - - if ($method === 'POST') { - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - $result = json_decode($response, true); - - if ($httpCode >= 200 && $httpCode < 300) { - return ['success' => true, 'data' => $result]; - } else { - return [ - 'success' => false, - 'error_code' => $result['code'] ?? 'UNKNOWN', - 'error_msg' => $result['message'] ?? '未知错误' - ]; - } - } -} -``` - -### 使用示例 - -#### 1. 发起转账 - -```php -// 初始化配置 -$config = [ - 'mch_id' => '1318592501', - 'app_id' => 'wx6489c26045912fe1', - 'api_v3_key' => 'wx3e31b068be59ddc131b068be59ddc2', - 'private_key' => '/path/to/apiclient_key.pem', - 'cert_serial_no' => '4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5', -]; - -$wechatPay = new WechatPayTransfer($config); - -// 发起转账 -$result = $wechatPay->createTransfer([ - 'out_detail_no' => 'TX' . date('YmdHis') . mt_rand(1000, 9999), - 'transfer_amount' => 5000, // 50元 = 5000分 - 'transfer_remark' => '提现', - 'openid' => 'odq3g5IOG-Z1WLpbeG_amUme8EZk', - 'transfer_scene_id' => '1005', - 'transfer_scene_report_infos' => [ - ['info_type' => '岗位类型', 'info_content' => '兼职人员'], - ['info_type' => '报酬说明', 'info_content' => '当日兼职费'], - ], -]); - -if ($result['success']) { - echo "转账成功: " . json_encode($result['data']); -} else { - echo "转账失败: " . $result['error_msg']; -} -``` - -#### 2. 查询转账单 - -```php -$result = $wechatPay->queryTransfer('BATCH202601291234567890', 'TX202601291234567890'); - -if ($result['success']) { - echo "状态: " . $result['data']['detail_status']; -} else { - echo "查询失败: " . $result['error_msg']; -} -``` - -#### 3. 处理回调 - -```php -// 接收回调 -$headers = [ - 'wechatpay-signature' => $_SERVER['HTTP_WECHATPAY_SIGNATURE'], - 'wechatpay-timestamp' => $_SERVER['HTTP_WECHATPAY_TIMESTAMP'], - 'wechatpay-nonce' => $_SERVER['HTTP_WECHATPAY_NONCE'], - 'wechatpay-serial' => $_SERVER['HTTP_WECHATPAY_SERIAL'], -]; - -$body = file_get_contents('php://input'); -$callbackData = json_decode($body, true); - -// 验证签名 -$verified = $wechatPay->verifyCallback($headers, $body, '/path/to/wechat_pay_pub_key.pem'); - -if ($verified) { - // 解密数据 - $decrypted = $wechatPay->decryptCallbackResource($callbackData['resource']); - - // 处理转账结果 - if ($decrypted['state'] === 'SUCCESS') { - echo "转账成功: " . $decrypted['out_bill_no']; - } - - // 返回成功 - echo json_encode(['code' => 'SUCCESS']); -} else { - echo json_encode(['code' => 'FAIL', 'message' => '签名验证失败']); -} -``` - ---- - -## 测试验证 - -### 1. 签名生成测试 - -```php -$method = 'POST'; -$url = '/v3/transfer/batches'; -$timestamp = time(); -$nonce = 'RandomString123456'; -$body = '{"appid":"wx6489c26045912fe1"}'; - -$signature = buildSignature($method, $url, $timestamp, $nonce, $body, 'apiclient_key.pem'); - -echo "签名: " . $signature . "\n"; -``` - -### 2. 小额转账测试 - -```php -// 测试金额:0.01元 = 1分 -$result = $wechatPay->createTransfer([ - 'out_detail_no' => 'TEST' . time(), - 'transfer_amount' => 1, // 1分 - 'transfer_remark' => '测试', - 'openid' => 'test_openid', - 'transfer_scene_id' => '1005', - 'transfer_scene_report_infos' => [ - ['info_type' => '岗位类型', 'info_content' => '测试'], - ['info_type' => '报酬说明', 'info_content' => '测试'], - ], -]); -``` - -### 3. 解密测试 - -```php -$resource = [ - 'ciphertext' => 'xxx', - 'nonce' => 'xxx', - 'associated_data' => 'mch_payment', -]; - -try { - $decrypted = decryptCallbackData( - $resource['ciphertext'], - $resource['nonce'], - $resource['associated_data'], - 'wx3e31b068be59ddc131b068be59ddc2' - ); - print_r($decrypted); -} catch (Exception $e) { - echo "解密失败: " . $e->getMessage(); -} -``` - -### 4. 常见问题 - -| 问题 | 原因 | 解决方法 | -|------|------|---------| -| 签名验证失败 | 证书序列号错误 | 重新提取证书序列号 | -| IP白名单错误 | 服务器IP未配置 | 添加到微信商户平台 | -| 解密失败 | APIv3密钥错误 | 检查密钥长度(32字节) | -| 场景报备错误 | info_type不正确 | 使用固定值 | -| 余额不足 | 商户号余额不足 | 充值商户号 | - ---- - -## 附录 - -### A. 错误码对照表 - -https://pay.weixin.qq.com/doc/v3/merchant/4012070193 - -| 错误码 | 说明 | 处理建议 | -|-------|------|---------| -| PARAM_ERROR | 参数错误 | 检查请求参数格式 | -| NOTENOUGH | 商户余额不足 | 充值商户号 | -| INVALID_REQUEST | 不符合业务规则 | 检查业务逻辑 | -| SYSTEM_ERROR | 系统错误 | 稍后重试 | -| FREQUENCY_LIMITED | 频率限制 | 降低请求频率 | -| APPID_MCHID_NOT_MATCH | appid和mch_id不匹配 | 检查配置 | - -### B. 转账状态说明 - -| 状态 | 说明 | 处理方式 | -|------|------|---------| -| PROCESSING | 转账中 | 等待回调或主动查询 | -| SUCCESS | 转账成功 | 完成流程 | -| FAIL | 转账失败 | 检查失败原因 | -| WAIT_USER_CONFIRM | 待用户确认 | 等待用户操作 | -| TRANSFERING | 正在转账 | 等待处理完成 | - -### C. 开发工具 - -- **Postman**:API测试工具 -- **OpenSSL**:证书和密钥管理 -- **微信支付调试工具**:https://pay.weixin.qq.com/ - ---- - -**文档版本**:v3.0(纯微信支付API版) -**更新时间**:2026-01-29 -**适用场景**:微信支付商家转账到零钱功能集成 - ---- - -## 总结 - -本文档提供了微信支付转账功能的完整集成方案: - -✅ **3个核心API** -- 发起转账:`POST /v3/transfer/batches` -- 查询转账:`GET /v3/transfer/batches/batch-id/{batch_id}/details/detail-id/{detail_id}` -- 接收回调:异步通知 - -✅ **3个核心算法** -- 签名生成:SHA256withRSA + Base64 -- 签名验证:使用平台公钥 -- 数据解密:AEAD_AES_256_GCM - -✅ **完整代码实现** -- WechatPayTransfer类(可直接使用) -- 包含发起转账、查询、验签、解密全部功能 - -根据本文档可以快速集成微信支付转账功能。 diff --git a/soul-api/管理端鉴权设计.md b/soul-api/管理端鉴权设计.md deleted file mode 100644 index 068de0ad..00000000 --- a/soul-api/管理端鉴权设计.md +++ /dev/null @@ -1,116 +0,0 @@ -# soul-api 管理端登录判断与权限校验 - -## 一、有没有登录的依据(JWT) - -**依据:请求中的 JWT。优先从 `Authorization: Bearer ` 读取,兼容从 Cookie `admin_session` 读取。** - -| 项目 | 说明 | -|------|------| -| 推荐方式 | 请求头 `Authorization: Bearer ` | -| 兼容方式 | Cookie 名 `admin_session`,值为 JWT 字符串 | -| JWT 算法 | HS256,密钥为 `ADMIN_SESSION_SECRET` | -| 有效期 | 7 天(exp claim) | -| 载荷 | sub=admin, username, role=admin | -| 校验 | 验签 + 未过期 → 视为已登录 | - -- 配置:`ADMIN_USERNAME` / `ADMIN_PASSWORD` 用于登录校验;`ADMIN_SESSION_SECRET` 用于签发/校验 JWT。 -- 未带有效 JWT → 401。 - ---- - -## 二、权限校验设计(路由分层) - -- **不校验登录**:只做业务逻辑(登录、登出、鉴权检查) - - `GET /api/admin` → 鉴权检查(读 Cookie,有效 200 / 无效 401) - - `POST /api/admin` → 登录(校验账号密码,写 Cookie) - - `POST /api/admin/logout` → 登出(删 Cookie) - -- **必须已登录**:挂 `AdminAuth()` 中间件,从请求读 `admin_session` 并验签+过期,不通过直接 401,不进入 handler - - `/api/admin/*`(如 chapters、content、withdrawals、settings 等) - - `/api/db/*` - -- **其它**:如 `/api/miniprogram/*`、`/api/book/*` 等不加 AdminAuth,按各自接口鉴权(如小程序 token)。 - ---- - -## 三、框图 - -```mermaid -flowchart TB - subgraph 前端["soul-admin 前端"] - A[用户打开后台 / 请求接口] - A --> B{请求类型} - B -->|登录| C[POST /api/admin] - B -->|登出| D[POST /api/admin/logout] - B -->|进后台前检查| E[GET /api/admin] - B -->|业务接口| F[GET/POST /api/admin/xxx] - end - - subgraph 请求["每次请求"] - G[浏览器自动携带 Cookie: admin_session] - G --> H[发往 soul-api] - end - - subgraph soul-api["soul-api 路由"] - I["/api/admin 三条(无中间件)"] - J["/api/admin/* 与 /api/db/*"] - J --> K[AdminAuth 中间件] - end - - subgraph 鉴权["AdminAuth 与 AdminCheck 逻辑"] - K --> L[从请求读 Cookie admin_session] - L --> M{有 Cookie?} - M -->|无| N[401 未授权] - M -->|有| O[解析 exp.signature] - O --> P{未过期 且 验签通过?} - P -->|否| N - P -->|是| Q[放行 / 返回 200] - end - - C --> I - D --> I - E --> I - F --> J - H --> soul-api - I --> E2[GET: 同鉴权逻辑 200/401] - I --> C2[POST: 校验账号密码 写 Cookie] - I --> D2[POST: 清 Cookie] -``` - -**路由与中间件关系(框线):** - -```mermaid -flowchart LR - subgraph 无鉴权["不经过 AdminAuth"] - R1[GET /api/admin] - R2[POST /api/admin] - R3[POST /api/admin/logout] - end - - subgraph 需登录["经过 AdminAuth"] - R4["/api/admin/chapters"] - R5["/api/admin/withdrawals"] - R6["/api/admin/settings"] - R7["/api/db/*"] - end - - subgraph 中间件["AdminAuth()"] - M[读 Cookie → 验 token → 通过/401] - end - - H1[直接进 handler] - H2[通过则进 handler] - 无鉴权 --> H1 - 需登录 --> M --> H2 -``` - ---- - -## 四、相关代码位置 - -| 作用 | 位置 | -|------|------| -| JWT 签发/校验/从请求取 token | `internal/auth/adminjwt.go` | -| 登录、登出、GET 鉴权检查 | `internal/handler/admin.go` | -| 管理端中间件 | `internal/middleware/admin_auth.go` | -| 路由挂载 | `internal/router/router.go`(api.Group + admin.Use(AdminAuth())) | diff --git a/soul-api/订阅消息.md b/soul-api/订阅消息.md deleted file mode 100644 index 8ffa5476..00000000 --- a/soul-api/订阅消息.md +++ /dev/null @@ -1,23 +0,0 @@ -data := &power.HashMap{ - "phrase4": power.StringMap{ - "value": "提现成功",//提现结果:提现成功、提现失败 - }, - "amount5": pwer.StringMap{ - "value": "¥8.6",//提现金额 - }, - "thing8": power.StringMap{ - "value": "微信打款成功,请点击查收",//备注,如果打款失败就提示请联系官方客服 - }, -} -MiniProgramApp.SubscribeMessage.Send(ctx, &request.RequestSubscribeMessageSend{ - ToUser: "OPENID",//需要根据订单号联表查询,提现表的user_id就是opend_id - TemplateID: "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",//这串是正确的 - Page: "/pages/my/my", - // developer为开发版;trial为体验版;formal为正式版 这块最好根据我的域名区分, - // 开发环境是souldev.quwanzhi.com 正式环境是 soulapi.quwanzhi.com - MiniProgramState: "formal", - Lang: "zh_CN", - Data: data, -}) - -{"create_time":"2026-02-10T18:02:54+08:00","out_bill_no":"WD1770691555206100","package_info":"ABBQO+oYAAABAAAAAAAk+yPZGrq+hyjETwKLaRAAAADnGpepZahT9IkJjn90+1qg6ZgBGi0Qjs+Pff8cmSa31vfwaewAXCM6F4nJ9wEZRdwDm4QridPWurNI1lWD7iSS7oX/YzP5XOnpeAlYX3tjHLTDdDQ=","state":"WAIT_USER_CONFIRM","transfer_bill_no":"1330000114850082602100071440076263"} \ No newline at end of file