diff --git a/.gitignore b/.gitignore index 8050feeb..1e3a7638 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ soul-api/wechat/info.log next-project soul-admin/node_modules -soul-api soul-api.exe \ No newline at end of file diff --git a/soul-api/.ArtisanCloud/cache b/soul-api/.ArtisanCloud/cache new file mode 100644 index 00000000..dc7a404b Binary files /dev/null and b/soul-api/.ArtisanCloud/cache differ diff --git a/soul-api/.air.toml b/soul-api/.air.toml new file mode 100644 index 00000000..f530a27d --- /dev/null +++ b/soul-api/.air.toml @@ -0,0 +1,25 @@ +# Air 热重载配置:改 .go 后自动重新编译并重启 +# 默认使用开发/测试环境:env_files 加载 .env.development +root = "." +tmp_dir = "tmp" +env_files = [".env", ".env.development"] + +# Windows 下用 .exe 避免系统弹出「选择应用打开 main」 +[build] + bin = "./tmp/main.exe" + cmd = "go build -o ./tmp/main.exe ./cmd/server" + delay = 800 + exclude_dir = ["tmp", "vendor"] + exclude_regex = ["_test\\.go$"] + include_ext = ["go", "tpl", "tmpl", "html"] + log = "build-errors.log" + stop_on_error = true + +[log] + time = false + +[misc] + clean_on_exit = true + +[screen] + clear_on_rebuild = false diff --git a/soul-api/.env b/soul-api/.env new file mode 100644 index 00000000..d002dfd1 --- /dev/null +++ b/soul-api/.env @@ -0,0 +1,44 @@ +# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效) +PORT=8080 +GIN_MODE=debug + +# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本 +APP_VERSION=0.0.0 + +# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram) +DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True +# DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True + +# 统一 API 域名(支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠) +API_BASE_URL=https://soul.quwanzhi.com + +# 微信小程序配置 +WECHAT_APPID=wxb8bbb2b10dec74aa +WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c +WECHAT_MCH_ID=1318592501 +WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2 +# 支付回调:未设置时由 API_BASE_URL + /api/miniprogram/pay/notify 派生 +# WECHAT_NOTIFY_URL=https://soul.quwanzhi.com/api/miniprogram/pay/notify +# 小程序码/订阅消息跳转版本:formal=正式版(默认) | trial=体验版 | developer=开发版 +WECHAT_MINI_PROGRAM_STATE=formal + +# 微信转账配置(API v3) +WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2 +# 公钥证书(本地或 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/.env.development b/soul-api/.env.development new file mode 100644 index 00000000..46b1ccf9 --- /dev/null +++ b/soul-api/.env.development @@ -0,0 +1,46 @@ +# 测试环境配置(air / make dev 时加载,见 .air.toml env_files) +APP_ENV=development + +# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效) +PORT=8080 +GIN_MODE=debug + +# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本 +APP_VERSION=0.0.0 + +# 数据库(测试环境 souldev) +DB_DSN=souldev:RXW2FeRcRdH2GtXy@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/souldev?charset=utf8mb4&parseTime=True +# DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True +# 统一 API 域名(测试环境) +API_BASE_URL=https://souldev.quwanzhi.com + +# 微信小程序配置 +WECHAT_APPID=wxb8bbb2b10dec74aa +WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c +WECHAT_MCH_ID=1318592501 +WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2 +# 支付回调:未设置时由 API_BASE_URL 派生 +# WECHAT_NOTIFY_URL=https://souldev.quwanzhi.com/api/miniprogram/pay/notify +# 小程序码/订阅消息跳转版本:formal=正式版(默认) | trial=体验版 | developer=开发版 +WECHAT_MINI_PROGRAM_STATE=formal + +# 微信转账配置(API v3) +WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2 +# 公钥证书(本地或 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem +WECHAT_CERT_PATH=certs/apiclient_cert.pem +# 私钥(线上用 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem +WECHAT_KEY_PATH=certs/apiclient_key.pem +WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5 +# 转账回调:未设置时由 API_BASE_URL 派生 +# WECHAT_TRANSFER_URL=https://souldev.quwanzhi.com/api/payment/wechat/transfer/notify + +# 管理端登录(与 next-project 一致,默认 admin / admin123) +# ADMIN_USERNAME=admin +# ADMIN_PASSWORD=admin123 +# ADMIN_SESSION_SECRET=soul-admin-secret-change-in-prod + +# 可选:信任代理 IP(逗号分隔),部署在 Nginx 后时填写 +# TRUSTED_PROXIES=127.0.0.1,::1 + +# 跨域 CORS:允许的源,逗号分隔。未设置时使用默认值(含 localhost、soul.quwanzhi.com) +CORS_ORIGINS=http://localhost:5175,http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com diff --git a/soul-api/.env.production b/soul-api/.env.production new file mode 100644 index 00000000..f4dc0896 --- /dev/null +++ b/soul-api/.env.production @@ -0,0 +1,44 @@ +# 正式环境配置(部署时复制为 .env,devlop.py 打包用) +APP_ENV=production + +# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效) +PORT=8080 +GIN_MODE=debug + +# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本 +APP_VERSION=0.0.0 + +# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram) +DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True + +# 统一 API 域名(支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠) +API_BASE_URL=https://soulapi.quwanzhi.com + +# 微信小程序配置 +WECHAT_APPID=wxb8bbb2b10dec74aa +WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c +WECHAT_MCH_ID=1318592501 +WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2 +# 支付回调:未设置时由 API_BASE_URL 派生 +# WECHAT_NOTIFY_URL=https://soulapi.quwanzhi.com/api/miniprogram/pay/notify + +# 微信转账配置(API v3) +WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2 +# 公钥证书(本地或 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem +WECHAT_CERT_PATH=certs/apiclient_cert.pem +# 私钥(线上用 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem +WECHAT_KEY_PATH=certs/apiclient_key.pem +WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5 +# 转账回调:未设置时由 API_BASE_URL 派生 +# WECHAT_TRANSFER_URL=https://soulapi.quwanzhi.com/api/payment/wechat/transfer/notify + +# 管理端登录(与 next-project 一致,默认 admin / admin123) +# ADMIN_USERNAME=admin +# ADMIN_PASSWORD=admin123 +# ADMIN_SESSION_SECRET=soul-admin-secret-change-in-prod + +# 可选:信任代理 IP(逗号分隔),部署在 Nginx 后时填写 +# TRUSTED_PROXIES=127.0.0.1,::1 + +# 跨域 CORS:允许的源,逗号分隔。未设置时使用默认值(含 localhost、soul.quwanzhi.com) +CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com diff --git a/soul-api/.gitignore b/soul-api/.gitignore new file mode 100644 index 00000000..b2d0c993 --- /dev/null +++ b/soul-api/.gitignore @@ -0,0 +1,5 @@ +tmp/ +log/ +soul-api +server.exe +soul-api.exe diff --git a/soul-api/Makefile b/soul-api/Makefile new file mode 100644 index 00000000..f4970f68 --- /dev/null +++ b/soul-api/Makefile @@ -0,0 +1,9 @@ +# 开发:热重载(需先安装 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 new file mode 100644 index 00000000..f515d814 Binary files /dev/null and b/soul-api/__pycache__/devlop.cpython-311.pyc differ diff --git a/soul-api/certs/apiclient_cert.pem b/soul-api/certs/apiclient_cert.pem new file mode 100644 index 00000000..ef4a885a --- /dev/null +++ b/soul-api/certs/apiclient_cert.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEKzCCAxOgAwIBAgIUSh22LNXJvgtvxRwwYh1vmWhudcUwDQYJKoZIhvcNAQEL +BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT +FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg +Q0EwHhcNMjYwMTIyMDgzMzQ2WhcNMzEwMTIxMDgzMzQ2WjCBhDETMBEGA1UEAwwK +MTMxODU5MjUwMTEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTAwLgYDVQQL +DCfms4nlt57luILljaHoi6XnvZHnu5zmioDmnK/mnInpmZDlhazlj7gxCzAJBgNV +BAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAOn4ggY2z0VowJyd1ml7vlry47+qgqMxgLqHAKzaOuETI/8lDRWd +LDfOgdVBtZNJJWF9Dk313k9UjmospjufthZ9QdTHFdK+76dnHws19ZMEaGIEJC3j +xr5fI9SJqLXq8KmxogHSHss7Nc4e5nAvVb7cgqp8kjvNOPoJxrpKH8KFtfSOKOs1 +BxQdkwyhBZ70O9gbh7vEZM3k/zN3JsZfqssSTcKQm6u4fszPhbVeYPbZvgD6UN8B +H465/PZqS2UwbjrPj6v6SkJgl77xqcXAhHWxISUD6NWgJaU58Idtm2M+5C0vi68u +WcUmosOXeOHxC3IQTTlFYnqjThdvJt+qifsCAwEAAaOBuTCBtjAJBgNVHRMEAjAA +MAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGEaHR0cDovL2V2 +Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0MjIwRTUwREJD +MDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFCNjU0MjJFMTJC +MjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEBCwUAA4IBAQCD +nXigQonbIBZp1EdNqd1zR9alTB0KL3Z7KRxGXogUSSn/F4FGcXxvYKeOIJNg5g89 +EDsopqyzwG999lIG+D34lyabbh/j7M7JegAdCAr06X7cBxIF+ujOecotesF/dtl/ +5hWXEU3yVZSwzjvOkMAL4xnXBwIZeXQJ8fD6vLZRsRTXfm7qi88MSuWWLuB+5X2l +CwS7e6Zu2kgL+U2YeA9cu7/l5zL1wfQqjlk1PTMwKAstvSNzamnpLAzhJ8U5g7lh +lF9Pbbbs5Hq6VblRqCUyMDATqhqKQTAeXn3soQodHqxLw8MeL7QICQGQxBxFmItj +TwZDp4hd2oka3oS1VsV0 +-----END CERTIFICATE----- diff --git a/soul-api/certs/apiclient_key.pem b/soul-api/certs/apiclient_key.pem new file mode 100644 index 00000000..ac63c73d --- /dev/null +++ b/soul-api/certs/apiclient_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDp+IIGNs9FaMCc +ndZpe75a8uO/qoKjMYC6hwCs2jrhEyP/JQ0VnSw3zoHVQbWTSSVhfQ5N9d5PVI5q +LKY7n7YWfUHUxxXSvu+nZx8LNfWTBGhiBCQt48a+XyPUiai16vCpsaIB0h7LOzXO +HuZwL1W+3IKqfJI7zTj6Cca6Sh/ChbX0jijrNQcUHZMMoQWe9DvYG4e7xGTN5P8z +dybGX6rLEk3CkJuruH7Mz4W1XmD22b4A+lDfAR+Oufz2aktlMG46z4+r+kpCYJe+ +8anFwIR1sSElA+jVoCWlOfCHbZtjPuQtL4uvLlnFJqLDl3jh8QtyEE05RWJ6o04X +bybfqon7AgMBAAECggEAbi3WnTKGXPs9aQNzCu148L9cvM+BAXS4WB5nFP8XpxIq +a2Z5SOpg/k7DGTf+V8OkVMpdSB02eUkqX5lzFrTZPLHzpE20WzALD1wiZFcetALp +XO7yUqHm35NR/i5tQm3Gs0KxNgZK9g2GAvDON5oy2NRivAI5ouu7nxOnf+aUGjeS +vAgfuP8O0CADFIyAoUeo9ZpPhMTehfSBUzPWMdXk2UAeoJQR8tp4t8Uh3AMPO/oF +ZLo+l9dEbK3iojCjzkRXvMznx0A8Eo1Zns/2A8jG6g/QIz8ZZLmAP7cgoGGimj+y +lbawi933yLMtGq+UlO4Xydk5LX1B8YWh6U2IsIAsYQKBgQD2cTs5B91Jr544WmKf +dAZRD62spomnGmwC2DSQa807/W7QbwhCUCB/6UmwjX8ev4aw8ypi7Bsj3Fp26QCD +mI75rJozReiCXvOggPi8gy4eaodsfZiplfOV5Eb0SNFkYcrvDMzj6hu/FvQwMTc7 +2X9lTjB6cZgQg2j8H3YX2YIqKQKBgQDzC3RI80u68avfLeAw/6TJV/jBIJLjs64D +aN7vsY1zPWn03i+Wma/Bjbh8JBk69St0t/ILS7jn8ESN1RzizODYd6yFn56345zo +zrTzZoQK3+xjMDnrdEYCww+u47pmhTVGDqxcy4nbHEN8sVw/DX+P4Ho3wd3u+8Kp +TqCAXdQfgwKBgQDQSEDSYYgwB8JERHfH5gqUphiVq6b5WQZinRJH4SRzCC2I8d5c +FVZyZNuH4P7IIP0YPlvbgUsq0siOaTyq+9wSvkMRBIuO6+siAv62bHQk9sn/8mJ9 +KaPWUjl5qrV2DoSx5vKfybOrnB3DQUU6Swc1upCUW782bankND7dx1IQiQKBgQDb +ogY7xmExVyPSU0q9/MeVjAInxJ/5VW5zdlnAkdsZwO33crHejpPdfYyx4o1KUjQr +De+VdaBrOR06btPjwPGPrNYjCtQLqY0qdWHgc0vv59te5z3wIOsDo/KQQQs5ijdS +UABC+0xgzXHPRRfvgus7wcewi2lbhferuHoihqgisQKBgQDJBBIJsqtdbQs8IN3p +2uEIswKgGvUTPScrrcXNH2Jox7XYIZ8GtPhspWqrudKTPdXZwVKR9wXTGc2cBZm8 +mKB5oE+cQ/a+Ub6QTZwL/vj+y8ogUvPKI7hnNaV+AFNMrwXopAmvLiAvPRuD9mIx +RQ27dKDYfWqBlj4ssiBPeVVVWw== +-----END PRIVATE KEY----- diff --git a/soul-api/cmd/server/main.go b/soul-api/cmd/server/main.go new file mode 100644 index 00000000..ea54c78b --- /dev/null +++ b/soul-api/cmd/server/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "soul-api/internal/config" + "soul-api/internal/database" + "soul-api/internal/handler" + "soul-api/internal/router" + "soul-api/internal/wechat" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatal("load config: ", err) + } + config.SetCurrent(cfg) + if err := database.Init(cfg.DBDSN); err != nil { + log.Fatal("database: ", err) + } + if err := wechat.Init(cfg); err != nil { + log.Fatal("wechat: ", err) + } + if err := wechat.InitTransfer(cfg); err != nil { + log.Fatal("wechat transfer: ", err) + } + + r := router.Setup(cfg) + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: r, + } + + go func() { + log.Printf("soul-api listen on :%s (mode=%s)", cfg.Port, cfg.Mode) + log.Printf(" -> 访问地址: http://localhost:%s (健康检查: http://localhost:%s/health)", cfg.Port, cfg.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal("listen: ", err) + } + }() + + // 内置订单对账定时任务(SYNC_ORDERS_INTERVAL_MINUTES > 0 时启动) + if cfg.SyncOrdersIntervalMinutes > 0 { + interval := time.Duration(cfg.SyncOrdersIntervalMinutes) * time.Minute + go func() { + // 启动后延迟 1 分钟执行第一次,避免与启动流程抢资源 + time.Sleep(1 * time.Minute) + ticker := time.NewTicker(interval) + defer ticker.Stop() + handler.SyncOrdersLogf("内置定时任务已启动,间隔 %d 分钟", cfg.SyncOrdersIntervalMinutes) + for { + ctx := context.Background() + synced, total, err := handler.RunSyncOrders(ctx, 7) + if err != nil { + handler.SyncOrdersLogf("对账失败: %v", err) + } else if total > 0 { + handler.SyncOrdersLogf("本轮检查 %d 笔,补齐 %d 笔漏单", total, synced) + } + <-ticker.C + } + }() + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("server shutdown: ", err) + } + log.Println("bye") +} diff --git a/soul-api/dev_dev.py b/soul-api/dev_dev.py new file mode 100644 index 00000000..dcd798e5 --- /dev/null +++ b/soul-api/dev_dev.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +soul-api Go 项目一键部署到宝塔(测试环境),重启的是宝塔里的 soulDev 项目 +- 打包使用 .env.development 作为服务器 .env +- 本地交叉编译 Linux 二进制 +- 上传到 /www/wwwroot/self/soul-dev +- 重启 soulDev:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动 + +宝塔 API 重启(可选):在环境变量或 .env 中设置 + BT_PANEL_URL = https://你的面板地址:9988 + BT_API_KEY = 面板 设置 -> API 接口 中的密钥 + BT_GO_PROJECT_NAME = soulDev (与宝塔 Go 项目列表里名称一致) +并安装 requests: pip install requests +""" + +from __future__ import print_function + +import hashlib +import os +import sys +import tempfile +import argparse +import subprocess +import shutil +import tarfile +import time + +try: + import paramiko +except ImportError: + print("错误: 请先安装 paramiko") + print(" pip install paramiko") + sys.exit(1) + +try: + import requests + try: + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except Exception: + pass +except ImportError: + requests = None + +# ==================== 配置 ==================== + +DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev" +DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022")) + + +# 宝塔 API 密钥(写死,用于部署后重启 Go 项目) +BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT" + + +def get_cfg(): + host = os.environ.get("DEPLOY_HOST", "43.139.27.93") + bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/") + if not bt_url: + bt_url = "https://%s:9988" % host + return { + "host": host, + "user": os.environ.get("DEPLOY_USER", "root"), + "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), + "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), + "project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH), + "bt_panel_url": bt_url, + "bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT), + "bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulDev"), + } + + +# ==================== 本地构建 ==================== + + +def run_build(root): + """交叉编译 Go 二进制(Linux amd64)""" + print("[1/4] 本地交叉编译 Go 二进制 ...") + env = os.environ.copy() + env["GOOS"] = "linux" + env["GOARCH"] = "amd64" + env["CGO_ENABLED"] = "0" + # 必须 shell=False,否则 Windows 下 -ldflags 等参数会被当成包路径导致 "malformed import path" + cmd = ["go", "build", "-o", "soul-api", "./cmd/server"] + try: + r = subprocess.run( + cmd, + cwd=root, + env=env, + shell=False, + timeout=120, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if r.returncode != 0: + print(" [失败] go build 失败,退出码:", r.returncode) + if r.stderr: + for line in (r.stderr or "").strip().split("\n")[-10:]: + print(" " + line) + return None + out_path = os.path.join(root, "soul-api") + if not os.path.isfile(out_path): + print(" [失败] 未找到编译产物 soul-api") + return None + print(" [成功] 编译完成: %s (%.2f MB)" % (out_path, os.path.getsize(out_path) / 1024 / 1024)) + return out_path + except subprocess.TimeoutExpired: + print(" [失败] 编译超时") + return None + except FileNotFoundError: + print(" [失败] 未找到 go 命令,请安装 Go") + return None + except Exception as e: + print(" [失败] 编译异常:", str(e)) + return None + + +# ==================== 打包 ==================== + +DEPLOY_PORT = 8081 + + +def set_env_port(env_path, port=DEPLOY_PORT): + """将 .env 文件中的 PORT 设为指定值(用于部署包)""" + if not os.path.isfile(env_path): + return + with open(env_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + found = False + new_lines = [] + for line in lines: + s = line.strip() + if "=" in s and s.split("=", 1)[0].strip() == "PORT": + new_lines.append("PORT=%s\n" % port) + found = True + else: + new_lines.append(line) + if not found: + new_lines.append("PORT=%s\n" % port) + with open(env_path, "w", encoding="utf-8", newline="\n") as f: + f.writelines(new_lines) + + +def set_env_mini_program_state(env_path, state): + """将 .env 中的 WECHAT_MINI_PROGRAM_STATE 设为 developer/formal(打包前按环境覆盖)""" + if not os.path.isfile(env_path): + return + key = "WECHAT_MINI_PROGRAM_STATE" + with open(env_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + found = False + new_lines = [] + for line in lines: + s = line.strip() + if "=" in s and s.split("=", 1)[0].strip() == key: + new_lines.append("%s=%s\n" % (key, state)) + found = True + else: + new_lines.append(line) + if not found: + new_lines.append("%s=%s\n" % (key, state)) + with open(env_path, "w", encoding="utf-8", newline="\n") as f: + f.writelines(new_lines) + + +def pack_deploy(root, binary_path, include_env=True): + """打包二进制和 .env 为 tar.gz""" + print("[2/4] 打包部署文件 ...") + staging = tempfile.mkdtemp(prefix="soul_api_deploy_") + try: + shutil.copy2(binary_path, os.path.join(staging, "soul-api")) + env_src = os.path.join(root, ".env.development") + staging_env = os.path.join(staging, ".env") + if include_env and os.path.isfile(env_src): + shutil.copy2(env_src, staging_env) + print(" [已包含] .env.development -> .env") + else: + env_example = os.path.join(root, ".env.example") + if os.path.isfile(env_example): + shutil.copy2(env_example, staging_env) + print(" [已包含] .env.example -> .env (请服务器上检查配置)") + if os.path.isfile(staging_env): + set_env_port(staging_env, DEPLOY_PORT) + set_env_mini_program_state(staging_env, "developer") + print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=developer(测试环境)" % DEPLOY_PORT) + tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz") + with tarfile.open(tarball, "w:gz") as tf: + for name in os.listdir(staging): + tf.add(os.path.join(staging, name), arcname=name) + print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024)) + return tarball + except Exception as e: + print(" [失败] 打包异常:", str(e)) + return None + finally: + shutil.rmtree(staging, ignore_errors=True) + + +# ==================== 宝塔 API 重启 ==================== + + +def restart_via_bt_api(cfg): + """通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME)""" + url = cfg.get("bt_panel_url") or "" + key = cfg.get("bt_api_key") or "" + name = cfg.get("bt_go_project_name", "soulDev") + if not url or not key: + return False + if not requests: + print(" [提示] 未安装 requests,无法使用宝塔 API,将用 SSH 重启。pip install requests") + return False + try: + req_time = int(time.time()) + sk_md5 = hashlib.md5(key.encode()).hexdigest() + req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest() + # 宝塔 Go 项目插件:先停止再启动,接口以实际面板版本为准 + base = url.rstrip("/") + params = {"request_time": req_time, "request_token": req_token} + # 常见形式:/plugin?name=go_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 new file mode 100644 index 00000000..557901ab --- /dev/null +++ b/soul-api/devlop.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +soulApp (soul-api) Go 项目一键部署到宝塔(正式环境) +- 打包使用 .env.production 作为服务器 .env +- 本地交叉编译 Linux 二进制 +- 上传到 /www/wwwroot/self/soul-api +- 重启:优先宝塔 API(需配置),否则 SSH 下 setsid nohup 启动 + +宝塔 API 重启(可选):在环境变量或 .env 中设置 + BT_PANEL_URL = https://你的面板地址:9988 + BT_API_KEY = 面板 设置 -> API 接口 中的密钥 + BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致) +并安装 requests: pip install requests +""" + +from __future__ import print_function + +import hashlib +import os +import sys +import tempfile +import argparse +import subprocess +import shutil +import tarfile +import time + +try: + import paramiko +except ImportError: + print("错误: 请先安装 paramiko") + print(" pip install paramiko") + sys.exit(1) + +try: + import requests + try: + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except Exception: + pass +except ImportError: + requests = None + +# ==================== 配置 ==================== + +DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-api" +DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022")) + + +# 宝塔 API 密钥(写死,用于部署后重启 Go 项目) +BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT" + + +def get_cfg(): + host = os.environ.get("DEPLOY_HOST", "43.139.27.93") + bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/") + if not bt_url: + bt_url = "https://%s:9988" % host + return { + "host": host, + "user": os.environ.get("DEPLOY_USER", "root"), + "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), + "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), + "project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH), + "bt_panel_url": bt_url, + "bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT), + "bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"), + } + + +# ==================== 本地构建 ==================== + + +def run_build(root): + """交叉编译 Go 二进制(Linux amd64)""" + print("[1/4] 本地交叉编译 Go 二进制 ...") + env = os.environ.copy() + env["GOOS"] = "linux" + env["GOARCH"] = "amd64" + env["CGO_ENABLED"] = "0" + # 必须 shell=False,否则 Windows 下 -ldflags 等参数会被当成包路径导致 "malformed import path" + cmd = ["go", "build", "-o", "soul-api", "./cmd/server"] + try: + r = subprocess.run( + cmd, + cwd=root, + env=env, + shell=False, + timeout=120, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if r.returncode != 0: + print(" [失败] go build 失败,退出码:", r.returncode) + if r.stderr: + for line in (r.stderr or "").strip().split("\n")[-10:]: + print(" " + line) + return None + out_path = os.path.join(root, "soul-api") + if not os.path.isfile(out_path): + print(" [失败] 未找到编译产物 soul-api") + return None + print(" [成功] 编译完成: %s (%.2f MB)" % (out_path, os.path.getsize(out_path) / 1024 / 1024)) + return out_path + except subprocess.TimeoutExpired: + print(" [失败] 编译超时") + return None + except FileNotFoundError: + print(" [失败] 未找到 go 命令,请安装 Go") + return None + except Exception as e: + print(" [失败] 编译异常:", str(e)) + return None + + +# ==================== 打包 ==================== + +DEPLOY_PORT = 8080 + + +def set_env_port(env_path, port=DEPLOY_PORT): + """将 .env 文件中的 PORT 设为指定值(用于部署包)""" + if not os.path.isfile(env_path): + return + with open(env_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + found = False + new_lines = [] + for line in lines: + s = line.strip() + if "=" in s and s.split("=", 1)[0].strip() == "PORT": + new_lines.append("PORT=%s\n" % port) + found = True + else: + new_lines.append(line) + if not found: + new_lines.append("PORT=%s\n" % port) + with open(env_path, "w", encoding="utf-8", newline="\n") as f: + f.writelines(new_lines) + + +def set_env_mini_program_state(env_path, state): + """将 .env 中的 WECHAT_MINI_PROGRAM_STATE 设为 developer/formal(打包前按环境覆盖)""" + if not os.path.isfile(env_path): + return + key = "WECHAT_MINI_PROGRAM_STATE" + with open(env_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + found = False + new_lines = [] + for line in lines: + s = line.strip() + if "=" in s and s.split("=", 1)[0].strip() == key: + new_lines.append("%s=%s\n" % (key, state)) + found = True + else: + new_lines.append(line) + if not found: + new_lines.append("%s=%s\n" % (key, state)) + with open(env_path, "w", encoding="utf-8", newline="\n") as f: + f.writelines(new_lines) + + +def pack_deploy(root, binary_path, include_env=True): + """打包二进制和 .env 为 tar.gz""" + print("[2/4] 打包部署文件 ...") + staging = tempfile.mkdtemp(prefix="soul_api_deploy_") + try: + shutil.copy2(binary_path, os.path.join(staging, "soul-api")) + env_src = os.path.join(root, ".env.production") + staging_env = os.path.join(staging, ".env") + if include_env and os.path.isfile(env_src): + shutil.copy2(env_src, staging_env) + print(" [已包含] .env.production -> .env") + else: + env_example = os.path.join(root, ".env.example") + if os.path.isfile(env_example): + shutil.copy2(env_example, staging_env) + print(" [已包含] .env.example -> .env (请服务器上检查配置)") + if os.path.isfile(staging_env): + set_env_port(staging_env, DEPLOY_PORT) + set_env_mini_program_state(staging_env, "formal") + print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal(正式环境)" % DEPLOY_PORT) + tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz") + with tarfile.open(tarball, "w:gz") as tf: + for name in os.listdir(staging): + tf.add(os.path.join(staging, name), arcname=name) + print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024)) + return tarball + except Exception as e: + print(" [失败] 打包异常:", str(e)) + return None + finally: + shutil.rmtree(staging, ignore_errors=True) + + +# ==================== 宝塔 API 重启 ==================== + + +def restart_via_bt_api(cfg): + """通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME)""" + url = cfg.get("bt_panel_url") or "" + key = cfg.get("bt_api_key") or "" + name = cfg.get("bt_go_project_name", "soulApi") + if not url or not key: + return False + if not requests: + print(" [提示] 未安装 requests,无法使用宝塔 API,将用 SSH 重启。pip install requests") + return False + try: + req_time = int(time.time()) + sk_md5 = hashlib.md5(key.encode()).hexdigest() + req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest() + # 宝塔 Go 项目插件:先停止再启动,接口以实际面板版本为准 + base = url.rstrip("/") + params = {"request_time": req_time, "request_token": req_token} + # 常见形式:/plugin?name=go_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 new file mode 100644 index 00000000..bb000653 --- /dev/null +++ b/soul-api/go.mod @@ -0,0 +1,59 @@ +module soul-api + +go 1.25 + +require ( + github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 + github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 + github.com/gin-contrib/cors v1.7.2 + github.com/gin-gonic/gin v1.10.0 + github.com/joho/godotenv v1.5.1 + github.com/unrolled/secure v1.17.0 + golang.org/x/time v0.8.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/redis/go-redis/v9 v9.17.3 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/soul-api/go.sum b/soul-api/go.sum new file mode 100644 index 00000000..51573120 --- /dev/null +++ b/soul-api/go.sum @@ -0,0 +1,158 @@ +github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 h1:IInr1YWwkhwOykxDqux1Goym0uFhrYwBjmgLnEwCLqs= +github.com/ArtisanCloud/PowerLibs/v3 v3.3.2/go.mod h1:xFGsskCnzAu+6rFEJbGVAlwhrwZPXAny6m7j71S/B5k= +github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 h1:yu4A7WhPXfs/RSYFL2UdHFRQYAXbrpiBOT3kJ5hjepU= +github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38/go.mod h1:boWl2cwbgXt1AbrYTWMXs9Ebby6ecbJ1CyNVRaNVqUY= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= +github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= +github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.4.0 h1:LJE4SW3jd4lQTESnlpQZcBhQ3oci0U2MLR5uhicfTHQ= +go.opentelemetry.io/otel/sdk v1.4.0/go.mod h1:71GJPNJh4Qju6zJuYl1CrYtXbrgfau/M9UAggqiy1UE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/soul-api/internal/auth/adminjwt.go b/soul-api/internal/auth/adminjwt.go new file mode 100644 index 00000000..49c7e9bf --- /dev/null +++ b/soul-api/internal/auth/adminjwt.go @@ -0,0 +1,71 @@ +// 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 new file mode 100644 index 00000000..6dcb6583 --- /dev/null +++ b/soul-api/internal/auth/adminsession.go @@ -0,0 +1,71 @@ +// Package auth 管理端 session:与 next-project lib/admin-auth.ts 的 token 格式兼容(exp.signature) +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + adminCookieName = "admin_session" + maxAgeSec = 7 * 24 * 3600 // 7 天 +) + +// CreateAdminToken 生成签名 token,格式与 next 一致:exp.base64url(hmac_sha256(exp)) +func CreateAdminToken(secret string) string { + exp := time.Now().Unix() + maxAgeSec + payload := strconv.FormatInt(exp, 10) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(payload)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return payload + "." + sig +} + +// VerifyAdminToken 校验 token:解析 exp、验签、验过期 +func VerifyAdminToken(token, secret string) bool { + if token == "" || secret == "" { + return false + } + dot := strings.Index(token, ".") + if dot <= 0 { + return false + } + payload := token[:dot] + sig := token[dot+1:] + exp, err := strconv.ParseInt(payload, 10, 64) + if err != nil || exp < time.Now().Unix() { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(payload)) + expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(sig), []byte(expected)) +} + +// AdminCookieName 返回 Cookie 名 +func AdminCookieName() string { return adminCookieName } + +// MaxAgeSec 返回 session 有效秒数 +func MaxAgeSec() int { return maxAgeSec } + +// SetCookieHeaderValue 返回完整的 Set-Cookie 头内容(含 SameSite=None; Secure,供跨站时携带 Cookie) +func SetCookieHeaderValue(token string, maxAge int) string { + if maxAge <= 0 { + return adminCookieName + "=; Path=/; Max-Age=0; HttpOnly; SameSite=None; Secure" + } + return adminCookieName + "=" + token + "; Path=/; Max-Age=" + strconv.Itoa(maxAge) + "; HttpOnly; SameSite=None; Secure" +} + +// GetAdminTokenFromRequest 从请求 Cookie 中读取 admin_session +func GetAdminTokenFromRequest(r *http.Request) string { + c, err := r.Cookie(adminCookieName) + if err != nil || c == nil { + return "" + } + return strings.TrimSpace(c.Value) +} diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go new file mode 100644 index 00000000..d3b4f252 --- /dev/null +++ b/soul-api/internal/config/config.go @@ -0,0 +1,256 @@ +package config + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +// Config 应用配置(从环境变量读取,启动时加载 .env) +type Config struct { + Port string + Mode string + DBDSN string + TrustedProxies []string + CORSOrigins []string + Version string // APP_VERSION,打包/部署前写在 .env,/health 返回 + + // 统一 API 域名字段:支付回调、转账回调、apiDomain 等均由 BaseURL 拼接 + BaseURL string // API_BASE_URL,如 https://soulapi.quwanzhi.com(无尾部斜杠) + + // 微信小程序配置 + WechatAppID string + WechatAppSecret string + WechatMchID string + WechatMchKey string + WechatNotifyURL string // 由 BaseURL + /api/miniprogram/pay/notify 派生 + WechatMiniProgramState string // 订阅消息跳转版本:developer/formal,从 .env WECHAT_MINI_PROGRAM_STATE 读取 + + // 微信转账配置(API v3) + WechatAPIv3Key string + WechatCertPath string + WechatKeyPath string + WechatSerialNo string + WechatTransferURL string // 由 BaseURL + /api/payment/wechat/transfer/notify 派生 + + // 管理端登录(与 next-project 一致:ADMIN_USERNAME / ADMIN_PASSWORD / ADMIN_SESSION_SECRET) + AdminUsername string + AdminPassword string + AdminSessionSecret string + + // 订单对账定时任务间隔(分钟),0 表示不启动内置定时任务 + SyncOrdersIntervalMinutes int +} + +// BaseURLJoin 将路径拼接到 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 new file mode 100644 index 00000000..0dfa7bdb --- /dev/null +++ b/soul-api/internal/database/database.go @@ -0,0 +1,58 @@ +package database + +import ( + "log" + + "soul-api/internal/model" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +var db *gorm.DB + +// Init 使用 DSN 连接 MySQL,供 handler 通过 DB() 使用 +func Init(dsn string) error { + var err error + db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + return err + } + if err := db.AutoMigrate(&model.WechatCallbackLog{}); err != nil { + log.Printf("database: wechat_callback_logs migrate warning: %v", err) + } + if err := db.AutoMigrate(&model.Withdrawal{}); err != nil { + log.Printf("database: withdrawals migrate warning: %v", err) + } + if err := db.AutoMigrate(&model.MatchRecord{}); err != nil { + log.Printf("database: match_records migrate warning: %v", err) + } + if err := db.AutoMigrate(&model.UserAddress{}); err != nil { + log.Printf("database: user_addresses migrate warning: %v", err) + } + if err := db.AutoMigrate(&model.VipRole{}); err != nil { + log.Printf("database: vip_roles migrate warning: %v", err) + } + if err := db.AutoMigrate(&model.Order{}); err != nil { + log.Printf("database: orders migrate warning: %v", err) + } + if err := db.AutoMigrate(&model.Mentor{}); err != nil { + log.Printf("database: mentors migrate warning: %v", err) + } + if err := db.AutoMigrate(&model.MentorConsultation{}); err != nil { + log.Printf("database: mentor_consultations migrate warning: %v", err) + } + if err := db.AutoMigrate(&model.AuthorConfig{}); err != nil { + log.Printf("database: author_config migrate warning: %v", err) + } + if err := db.AutoMigrate(&model.AdminUser{}); err != nil { + log.Printf("database: admin_users migrate warning: %v", err) + } + log.Println("database: connected") + return nil +} + +// DB 返回全局 *gorm.DB,仅在 Init 成功后调用 +func DB() *gorm.DB { + return db +} diff --git a/soul-api/internal/handler/admin.go b/soul-api/internal/handler/admin.go new file mode 100644 index 00000000..793fc365 --- /dev/null +++ b/soul-api/internal/handler/admin.go @@ -0,0 +1,152 @@ +package handler + +import ( + "net/http" + + "soul-api/internal/auth" + "soul-api/internal/config" + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// AdminCheck GET /api/admin 鉴权检查(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 new file mode 100644 index 00000000..ea044d86 --- /dev/null +++ b/soul-api/internal/handler/admin_chapters.go @@ -0,0 +1,160 @@ +package handler + +import ( + "net/http" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" +) + +// AdminChaptersList GET /api/admin/chapters 从 chapters 表组树:part -> chapters -> sections +func AdminChaptersList(c *gin.Context) { + var list []model.Chapter + if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"structure": []interface{}{}, "stats": nil}}) + return + } + type section struct { + ID string `json:"id"` + Title string `json:"title"` + Price float64 `json:"price"` + IsFree bool `json:"isFree"` + Status string `json:"status"` + EditionStandard *bool `json:"editionStandard,omitempty"` + EditionPremium *bool `json:"editionPremium,omitempty"` + } + type chapter struct { + ID string `json:"id"` + Title string `json:"title"` + Sections []section `json:"sections"` + } + type part struct { + ID string `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Chapters []chapter `json:"chapters"` + } + partMap := make(map[string]*part) + chapterMap := make(map[string]map[string]*chapter) + for _, row := range list { + if partMap[row.PartID] == nil { + partMap[row.PartID] = &part{ID: row.PartID, Title: row.PartTitle, Type: "part", Chapters: []chapter{}} + chapterMap[row.PartID] = make(map[string]*chapter) + } + p := partMap[row.PartID] + if chapterMap[row.PartID][row.ChapterID] == nil { + ch := chapter{ID: row.ChapterID, Title: row.ChapterTitle, Sections: []section{}} + p.Chapters = append(p.Chapters, ch) + chapterMap[row.PartID][row.ChapterID] = &p.Chapters[len(p.Chapters)-1] + } + ch := chapterMap[row.PartID][row.ChapterID] + price := 1.0 + if row.Price != nil { + price = *row.Price + } + isFree := false + if row.IsFree != nil { + isFree = *row.IsFree + } + st := "published" + if row.Status != nil { + st = *row.Status + } + ch.Sections = append(ch.Sections, section{ + ID: row.ID, Title: row.SectionTitle, Price: price, IsFree: isFree, Status: st, + EditionStandard: row.EditionStandard, EditionPremium: row.EditionPremium, + }) + } + structure := make([]part, 0, len(partMap)) + for _, p := range partMap { + structure = append(structure, *p) + } + var total int64 + database.DB().Model(&model.Chapter{}).Count(&total) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{"structure": structure, "stats": gin.H{"totalSections": total}}, + }) +} + +// AdminChaptersAction POST/PUT/DELETE /api/admin/chapters +func AdminChaptersAction(c *gin.Context) { + var body struct { + Action string `json:"action"` + ID string `json:"id"` + ChapterID string `json:"chapterId"` // 前端兼容:section id + SectionTitle string `json:"sectionTitle"` + Ids []string `json:"ids"` // reorder:新顺序的 section id 列表 + Price *float64 `json:"price"` + IsFree *bool `json:"isFree"` + Status *string `json:"status"` + EditionStandard *bool `json:"editionStandard"` + EditionPremium *bool `json:"editionPremium"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) + return + } + resolveID := func() string { + if body.ID != "" { + return body.ID + } + return body.ChapterID + } + db := database.DB() + if body.Action == "updatePrice" { + id := resolveID() + if id != "" && body.Price != nil { + db.Model(&model.Chapter{}).Where("id = ?", id).Update("price", *body.Price) + } + } + if body.Action == "toggleFree" { + id := resolveID() + if id != "" && body.IsFree != nil { + db.Model(&model.Chapter{}).Where("id = ?", id).Update("is_free", *body.IsFree) + } + } + if body.Action == "updateStatus" { + id := resolveID() + if id != "" && body.Status != nil { + db.Model(&model.Chapter{}).Where("id = ?", id).Update("status", *body.Status) + } + } + if body.Action == "rename" { + id := resolveID() + if id != "" && body.SectionTitle != "" { + db.Model(&model.Chapter{}).Where("id = ?", id).Update("section_title", body.SectionTitle) + } + } + if body.Action == "delete" { + id := resolveID() + if id != "" { + db.Where("id = ?", id).Delete(&model.Chapter{}) + } + } + if body.Action == "reorder" && len(body.Ids) > 0 { + for i, id := range body.Ids { + if id != "" { + db.Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i) + } + } + } + if body.Action == "updateEdition" { + id := resolveID() + if id != "" { + updates := make(map[string]interface{}) + if body.EditionStandard != nil { + updates["edition_standard"] = *body.EditionStandard + } + if body.EditionPremium != nil { + updates["edition_premium"] = *body.EditionPremium + } + if len(updates) > 0 { + db.Model(&model.Chapter{}).Where("id = ?", id).Updates(updates) + } + } + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/soul-api/internal/handler/admin_distribution.go b/soul-api/internal/handler/admin_distribution.go new file mode 100644 index 00000000..2fb89d1a --- /dev/null +++ b/soul-api/internal/handler/admin_distribution.go @@ -0,0 +1,99 @@ +package handler + +import ( + "fmt" + "net/http" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" +) + +// AdminDistributionOverview GET /api/admin/distribution/overview(全部使用 GORM,无 Raw SQL) +func AdminDistributionOverview(c *gin.Context) { + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + todayEnd := todayStart.Add(24 * time.Hour) + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + db := database.DB() + overview := gin.H{ + "todayClicks": 0, "todayBindings": 0, "todayConversions": 0, "todayEarnings": 0, + "monthClicks": 0, "monthBindings": 0, "monthConversions": 0, "monthEarnings": 0, + "totalClicks": 0, "totalBindings": 0, "totalConversions": 0, "totalEarnings": 0, + "expiringBindings": 0, "pendingWithdrawals": 0, "pendingWithdrawAmount": 0, + "conversionRate": "0.00", "totalDistributors": 0, "activeDistributors": 0, + } + + // 订单:仅用 Where + Count / Select(Sum) 参数化 + var totalOrders int64 + db.Model(&model.Order{}).Where("status = ?", "paid").Count(&totalOrders) + var totalAmount float64 + db.Model(&model.Order{}).Where("status = ?", "paid").Select("COALESCE(SUM(amount),0)").Scan(&totalAmount) + var todayOrders int64 + db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Count(&todayOrders) + var todayAmount float64 + db.Model(&model.Order{}).Where("status = ? AND created_at >= ? AND created_at < ?", "paid", todayStart, todayEnd).Select("COALESCE(SUM(amount),0)").Scan(&todayAmount) + var monthOrders int64 + db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Count(&monthOrders) + var monthAmount float64 + db.Model(&model.Order{}).Where("status = ? AND created_at >= ?", "paid", monthStart).Select("COALESCE(SUM(amount),0)").Scan(&monthAmount) + overview["totalEarnings"] = totalAmount + overview["todayEarnings"] = todayAmount + overview["monthEarnings"] = monthAmount + + // 绑定:全部 GORM Where + var totalBindings int64 + db.Model(&model.ReferralBinding{}).Count(&totalBindings) + var converted int64 + db.Model(&model.ReferralBinding{}).Where("status = ?", "converted").Count(&converted) + var todayBindings int64 + db.Model(&model.ReferralBinding{}).Where("binding_date >= ? AND binding_date < ?", todayStart, todayEnd).Count(&todayBindings) + var todayConv int64 + db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ? AND binding_date < ?", "converted", todayStart, todayEnd).Count(&todayConv) + var monthBindings int64 + db.Model(&model.ReferralBinding{}).Where("binding_date >= ?", monthStart).Count(&monthBindings) + var monthConv int64 + db.Model(&model.ReferralBinding{}).Where("status = ? AND binding_date >= ?", "converted", monthStart).Count(&monthConv) + expiringEnd := now.Add(7 * 24 * time.Hour) + var expiring int64 + db.Model(&model.ReferralBinding{}).Where("status = ? AND expiry_date > ? AND expiry_date <= ?", "active", now, expiringEnd).Count(&expiring) + overview["totalBindings"] = totalBindings + overview["totalConversions"] = converted + overview["todayBindings"] = todayBindings + overview["todayConversions"] = todayConv + overview["monthBindings"] = monthBindings + overview["monthConversions"] = monthConv + overview["expiringBindings"] = expiring + + // 访问数 + var visitTotal int64 + db.Model(&model.ReferralVisit{}).Count(&visitTotal) + overview["totalClicks"] = visitTotal + if visitTotal > 0 && converted > 0 { + overview["conversionRate"] = formatPercent(float64(converted)/float64(visitTotal)*100) + } + + // 提现待处理 + var pendCount int64 + db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Count(&pendCount) + var pendSum float64 + db.Model(&model.Withdrawal{}).Where("status = ?", "pending").Select("COALESCE(SUM(amount),0)").Scan(&pendSum) + overview["pendingWithdrawals"] = pendCount + overview["pendingWithdrawAmount"] = pendSum + + // 分销商 + var distTotal int64 + db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ?", "").Count(&distTotal) + var distActive int64 + db.Model(&model.User{}).Where("referral_code IS NOT NULL AND referral_code != ? AND earnings > ?", "", 0).Count(&distActive) + overview["totalDistributors"] = distTotal + overview["activeDistributors"] = distActive + + c.JSON(http.StatusOK, gin.H{"success": true, "overview": overview}) +} + +func formatPercent(v float64) string { + return fmt.Sprintf("%.2f", v) + "%" +} diff --git a/soul-api/internal/handler/admin_extra.go b/soul-api/internal/handler/admin_extra.go new file mode 100644 index 00000000..caa50181 --- /dev/null +++ b/soul-api/internal/handler/admin_extra.go @@ -0,0 +1,22 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// AdminContent GET/POST/PUT/DELETE /api/admin/content +func AdminContent(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// AdminPayment GET/POST/PUT/DELETE /api/admin/payment +func AdminPayment(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// AdminReferral GET/POST/PUT/DELETE /api/admin/referral +func AdminReferral(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/soul-api/internal/handler/admin_users.go b/soul-api/internal/handler/admin_users.go new file mode 100644 index 00000000..c3121492 --- /dev/null +++ b/soul-api/internal/handler/admin_users.go @@ -0,0 +1,240 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "soul-api/internal/database" + "soul-api/internal/middleware" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// AdminUsersList GET /api/admin/users 管理员用户列表(仅 super_admin) +func AdminUsersList(c *gin.Context) { + claims := middleware.GetAdminClaims(c) + if claims == nil || claims.Role != "super_admin" { + c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限"}) + return + } + db := database.DB() + page, _ := strconv.Atoi(c.Query("page")) + if page < 1 { + page = 1 + } + pageSize, _ := strconv.Atoi(c.Query("pageSize")) + if pageSize < 1 { + pageSize = 10 + } + if pageSize > 100 { + pageSize = 100 + } + search := strings.TrimSpace(c.Query("search")) + var total int64 + q := db.Model(&model.AdminUser{}) + if search != "" { + q = q.Where("username LIKE ? OR name LIKE ?", "%"+search+"%", "%"+search+"%") + } + if err := q.Count(&total).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + var list []model.AdminUser + offset := (page - 1) * pageSize + if err := q.Order("id ASC").Offset(offset).Limit(pageSize).Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + records := make([]gin.H, 0, len(list)) + for _, u := range list { + records = append(records, gin.H{ + "id": u.ID, + "username": u.Username, + "role": u.Role, + "name": u.Name, + "status": u.Status, + "createdAt": u.CreatedAt, + "updatedAt": u.UpdatedAt, + }) + } + totalPages := (int(total) + pageSize - 1) / pageSize + c.JSON(http.StatusOK, gin.H{ + "success": true, + "records": records, + "total": total, + "page": page, + "pageSize": pageSize, + "totalPages": totalPages, + }) +} + +// AdminUsersAction POST/PUT/DELETE /api/admin/users 管理员用户增删改(仅 super_admin) +func AdminUsersAction(c *gin.Context) { + claims := middleware.GetAdminClaims(c) + if claims == nil || claims.Role != "super_admin" { + c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限"}) + return + } + db := database.DB() + switch c.Request.Method { + case http.MethodPost: + var body struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Name string `json:"name"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"}) + return + } + username := trimSpace(body.Username) + if len(username) < 2 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名至少 2 个字符"}) + return + } + if len(body.Password) < 6 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码至少 6 位"}) + return + } + role := trimSpace(body.Role) + if role != "super_admin" && role != "admin" { + role = "admin" + } + hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "密码加密失败"}) + return + } + u := model.AdminUser{ + Username: username, + PasswordHash: string(hash), + Role: role, + Name: trimSpace(body.Name), + Status: "active", + } + if err := db.Create(&u).Error; err != nil { + if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名已存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "id": u.ID, + "username": u.Username, + "role": u.Role, + "name": u.Name, + "status": u.Status, + "createdAt": u.CreatedAt, + }, + }) + case http.MethodPut: + var body struct { + ID uint `json:"id" binding:"required"` + Password string `json:"password"` + Name string `json:"name"` + Role string `json:"role"` + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"}) + return + } + var u model.AdminUser + if err := db.First(&u, body.ID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + updates := make(map[string]interface{}) + updates["name"] = trimSpace(body.Name) + if body.Role == "super_admin" || body.Role == "admin" { + updates["role"] = body.Role + } + if body.Status == "active" || body.Status == "disabled" { + updates["status"] = body.Status + } + if body.Password != "" { + if len(body.Password) < 6 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "新密码至少 6 位"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "密码加密失败"}) + return + } + updates["password_hash"] = string(hash) + } + if len(updates) > 0 { + if err := db.Model(&u).Updates(updates).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + } + var updated model.AdminUser + _ = db.First(&updated, u.ID) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "id": updated.ID, + "username": updated.Username, + "role": updated.Role, + "name": updated.Name, + "status": updated.Status, + "updatedAt": updated.UpdatedAt, + }, + }) + case http.MethodDelete: + id := c.Query("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"}) + return + } + uid64, err := strconv.ParseUint(strings.TrimSpace(id), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "id 无效"}) + return + } + uid := uint(uid64) + var u model.AdminUser + if err := db.First(&u, uid).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusOK, gin.H{"success": true}) + return + } + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + if u.Username == claims.Username { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能删除当前登录账号"}) + return + } + if u.Role == "super_admin" { + var cnt int64 + db.Model(&model.AdminUser{}).Where("role = ?", "super_admin").Count(&cnt) + if cnt <= 1 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "至少保留一个超级管理员"}) + return + } + } + if err := db.Delete(&u).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) + default: + c.JSON(http.StatusMethodNotAllowed, gin.H{"success": false, "error": "方法不支持"}) + } +} diff --git a/soul-api/internal/handler/admin_withdrawals.go b/soul-api/internal/handler/admin_withdrawals.go new file mode 100644 index 00000000..627f55fd --- /dev/null +++ b/soul-api/internal/handler/admin_withdrawals.go @@ -0,0 +1,421 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + "soul-api/internal/wechat" + + "github.com/gin-gonic/gin" +) + +// AdminWithdrawalsList GET /api/admin/withdrawals(支持分页 page、pageSize,筛选 status) +func AdminWithdrawalsList(c *gin.Context) { + statusFilter := c.Query("status") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + db := database.DB() + q := db.Model(&model.Withdrawal{}) + if statusFilter != "" && statusFilter != "all" { + q = q.Where("status = ?", statusFilter) + } + var total int64 + q.Count(&total) + + var list []model.Withdrawal + query := db.Order("created_at DESC") + if statusFilter != "" && statusFilter != "all" { + query = query.Where("status = ?", statusFilter) + } + if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "withdrawals": []interface{}{}, "stats": gin.H{"total": 0}}) + return + } + userIds := make([]string, 0, len(list)) + seen := make(map[string]bool) + for _, w := range list { + if !seen[w.UserID] { + seen[w.UserID] = true + userIds = append(userIds, w.UserID) + } + } + var users []model.User + if len(userIds) > 0 { + database.DB().Where("id IN ?", userIds).Find(&users) + } + userMap := make(map[string]*model.User) + for i := range users { + userMap[users[i].ID] = &users[i] + } + withdrawals := make([]gin.H, 0, len(list)) + for _, w := range list { + u := userMap[w.UserID] + userName := "未知用户" + var userAvatar *string + account := "未绑定微信号" + if w.WechatID != nil && *w.WechatID != "" { + account = *w.WechatID + } + if u != nil { + if u.Nickname != nil { + userName = *u.Nickname + } + userAvatar = u.Avatar + if u.WechatID != nil && *u.WechatID != "" { + account = *u.WechatID + } + } + st := "pending" + if w.Status != nil { + st = *w.Status + if st == "success" { + st = "completed" + } else if st == "failed" { + st = "rejected" + } else if st == "pending_confirm" { + st = "pending_confirm" + } + } + userConfirmedAt := interface{}(nil) + if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() { + userConfirmedAt = w.UserConfirmedAt.Format("2006-01-02 15:04:05") + } + withdrawals = append(withdrawals, gin.H{ + "id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": userAvatar, + "amount": w.Amount, "status": st, "createdAt": w.CreatedAt, + "method": "wechat", "account": account, + "userConfirmedAt": userConfirmedAt, + }) + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + var pendingCount, successCount, failedCount int64 + var pendingAmount, successAmount float64 + db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Count(&pendingCount) + db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"pending", "pending_confirm", "processing"}).Select("COALESCE(SUM(amount), 0)").Scan(&pendingAmount) + db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Count(&successCount) + db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"success", "completed"}).Select("COALESCE(SUM(amount), 0)").Scan(&successAmount) + db.Model(&model.Withdrawal{}).Where("status IN ?", []string{"failed", "rejected"}).Count(&failedCount) + + c.JSON(http.StatusOK, gin.H{ + "success": true, "withdrawals": withdrawals, + "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, + "stats": gin.H{ + "total": total, "pendingCount": pendingCount, "pendingAmount": pendingAmount, + "successCount": successCount, "successAmount": successAmount, "failedCount": failedCount, + }, + }) +} + +// AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款 +// approve:先调微信转账接口打款,成功则标为 processing,失败则标为 failed 并返回错误。 +// 若未初始化微信转账客户端,则仅将状态标为 success(线下打款后批准)。 +// reject:直接标为 failed。 +func AdminWithdrawalsAction(c *gin.Context) { + var body struct { + ID string `json:"id"` + Action string `json:"action"` + ErrorMessage string `json:"errorMessage"` + Reason string `json:"reason"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"}) + return + } + reason := body.ErrorMessage + if reason == "" { + reason = body.Reason + } + if reason == "" && body.Action == "reject" { + reason = "管理员拒绝" + } + + db := database.DB() + now := time.Now() + + switch body.Action { + case "reject": + err := db.Model(&model.Withdrawal{}).Where("id = ?", body.ID).Updates(map[string]interface{}{ + "status": "failed", + "error_message": reason, + "fail_reason": reason, + "processed_at": now, + }).Error + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "已拒绝"}) + return + + case "approve": + var w model.Withdrawal + if err := db.Where("id = ?", body.ID).First(&w).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现记录不存在"}) + return + } + st := "" + if w.Status != nil { + st = *w.Status + } + if st != "pending" && st != "processing" && st != "pending_confirm" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "当前状态不允许批准"}) + return + } + + openID := "" + if w.WechatOpenid != nil && *w.WechatOpenid != "" { + openID = *w.WechatOpenid + } + if openID == "" { + var u model.User + if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil { + openID = *u.OpenID + } + } + if openID == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户未绑定微信 openid,无法打款"}) + return + } + + // 批准前二次校验可提现金额,与申请时口径一致,防止退款/冲正后超额打款 + available, _, _, _, _ := computeAvailableWithdraw(db, w.UserID) + if w.Amount > available { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "error": "用户当前可提现不足,无法批准", + "message": fmt.Sprintf("用户当前可提现 ¥%.2f,本笔申请 ¥%.2f,可能因退款/冲正导致。请核对后再批或联系用户。", available, w.Amount), + }) + return + } + + // 调用微信转账接口:按提现手续费扣除后打款,例如申请100元、手续费5%则实际打款95元 + remark := "提现" + if w.Remark != nil && *w.Remark != "" { + remark = *w.Remark + } + withdrawFee := 0.0 + var refCfg model.SystemConfig + if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil { + var refVal map[string]interface{} + if err := json.Unmarshal(refCfg.ConfigValue, &refVal); err == nil { + if v, ok := refVal["withdrawFee"].(float64); ok { + withdrawFee = v / 100 + } + } + } + actualAmount := w.Amount * (1 - withdrawFee) + if actualAmount < 0.01 { + actualAmount = 0.01 + } + amountFen := int(actualAmount * 100) + if amountFen < 1 { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现金额异常"}) + return + } + outBillNo := w.ID // 商户单号,回调时 out_bill_no 即此值,用于更新该条提现 + params := wechat.FundAppTransferParams{ + OutBillNo: outBillNo, + OpenID: openID, + Amount: amountFen, + Remark: remark, + NotifyURL: "", // 由 wechat 包从配置读取 WechatTransferURL + TransferSceneId: "1005", + } + + result, err := wechat.InitiateTransferByFundApp(params) + if err != nil { + errMsg := err.Error() + fmt.Printf("[AdminWithdrawals] 发起转账失败 id=%s: %s\n", body.ID, errMsg) + // 未初始化或未配置转账:仅标记为已打款并提示线下处理 + if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" { + _ = db.Model(&w).Updates(map[string]interface{}{ + "status": "success", + "processed_at": now, + }).Error + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "已标记为已打款。当前未接入微信转账,请线下打款。", + }) + return + } + // 微信接口报错或其它失败:把微信/具体原因返回给管理端展示,不返回「微信处理中」 + failMsg := errMsg + _ = db.Model(&w).Updates(map[string]interface{}{ + "status": "failed", + "fail_reason": failMsg, + "error_message": failMsg, + "processed_at": now, + }).Error + c.JSON(http.StatusOK, gin.H{ + "success": false, + "error": "发起打款失败", + "message": failMsg, // 管理端直接展示微信报错信息(如 IP 白名单、参数错误等) + }) + return + } + + // 防护:微信未返回商户单号时也按失败返回,避免管理端显示「已发起打款」却无单号 + if result.OutBillNo == "" { + failMsg := "微信未返回商户单号,请检查商户平台(如 IP 白名单)或查看服务端日志" + _ = db.Model(&w).Updates(map[string]interface{}{ + "status": "failed", + "fail_reason": failMsg, + "error_message": failMsg, + "processed_at": now, + }).Error + c.JSON(http.StatusOK, gin.H{ + "success": false, + "error": "发起打款失败", + "message": failMsg, + }) + return + } + + // 打款已受理(微信同步返回),立即落库:商户单号、微信单号、package_info、按 state 设 status(不依赖回调) + fmt.Printf("[AdminWithdrawals] 微信已受理 id=%s out_bill_no=%s transfer_bill_no=%s state=%s\n", body.ID, result.OutBillNo, result.TransferBillNo, result.State) + rowStatus := "processing" + if result.State == "WAIT_USER_CONFIRM" { + rowStatus = "pending_confirm" // 待用户在小程序点击确认收款,回调在用户确认后才触发 + } + upd := map[string]interface{}{ + "status": rowStatus, + "detail_no": result.OutBillNo, + "batch_no": result.OutBillNo, + "batch_id": result.TransferBillNo, + "processed_at": now, + } + if result.PackageInfo != "" { + upd["package_info"] = result.PackageInfo + } + if err := db.Model(&w).Updates(upd).Error; err != nil { + fmt.Printf("[AdminWithdrawals] 更新提现状态失败 id=%s: %v\n", body.ID, err) + c.JSON(http.StatusOK, gin.H{"success": false, "error": "更新状态失败: " + err.Error()}) + return + } + // 发起转账成功后发订阅消息(异步,失败不影响接口返回) + if openID != "" { + go func() { + ctx := context.Background() + if err := wechat.SendWithdrawSubscribeMessage(ctx, openID, w.Amount, true); err != nil { + fmt.Printf("[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n", body.ID, err) + } + }() + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "已发起打款,微信处理中", + "data": gin.H{ + "out_bill_no": result.OutBillNo, + "transfer_bill_no": result.TransferBillNo, + }, + }) + return + + default: + c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"}) + } +} + +// AdminWithdrawalsSync POST /api/admin/withdrawals/sync 主动向微信查询转账结果并更新状态(无回调时的备选) +// body: { "id": "提现记录id" } 同步单条;不传 id 或 id 为空则同步所有 processing/pending_confirm +func AdminWithdrawalsSync(c *gin.Context) { + var body struct { + ID string `json:"id"` + } + _ = c.ShouldBindJSON(&body) + + db := database.DB() + var list []model.Withdrawal + if body.ID != "" { + var w model.Withdrawal + if err := db.Where("id = ?", body.ID).First(&w).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现记录不存在"}) + return + } + list = []model.Withdrawal{w} + } else { + if err := db.Where("status IN ?", []string{"processing", "pending_confirm"}). + Find(&list).Error; err != nil || len(list) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "暂无待同步记录", "synced": 0}) + return + } + } + + now := time.Now() + synced := 0 + for _, w := range list { + batchNo := "" + detailNo := "" + if w.BatchNo != nil { + batchNo = *w.BatchNo + } + if w.DetailNo != nil { + detailNo = *w.DetailNo + } + if detailNo == "" { + continue + } + var status, failReason string + // FundApp 单笔:batch_no == detail_no 时用商户单号查询 + if batchNo == detailNo { + state, _, fail, err := wechat.QueryTransferByOutBill(detailNo) + if err != nil { + continue + } + status = state + failReason = fail + } else { + res, err := wechat.QueryTransfer(batchNo, detailNo) + if err != nil { + continue + } + if s, ok := res["detail_status"].(string); ok { + status = s + } + if s, ok := res["fail_reason"].(string); ok { + failReason = s + } + } + up := map[string]interface{}{"processed_at": now} + switch status { + case "SUCCESS": + up["status"] = "success" + case "FAIL": + up["status"] = "failed" + if failReason != "" { + up["fail_reason"] = failReason + } + default: + continue + } + if err := db.Model(&model.Withdrawal{}).Where("id = ?", w.ID).Updates(up).Error; err != nil { + continue + } + synced++ + fmt.Printf("[AdminWithdrawals] 同步状态 id=%s -> %s\n", w.ID, up["status"]) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "已向微信查询并更新", + "synced": synced, + "total": len(list), + }) +} diff --git a/soul-api/internal/handler/auth.go b/soul-api/internal/handler/auth.go new file mode 100644 index 00000000..9c72e9eb --- /dev/null +++ b/soul-api/internal/handler/auth.go @@ -0,0 +1,17 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// AuthLogin POST /api/auth/login +func AuthLogin(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// AuthResetPassword POST /api/auth/reset-password +func AuthResetPassword(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/soul-api/internal/handler/book.go b/soul-api/internal/handler/book.go new file mode 100644 index 00000000..186795af --- /dev/null +++ b/soul-api/internal/handler/book.go @@ -0,0 +1,343 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序) +var excludeParts = []string{"序言", "尾声", "附录"} + +// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表) +// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id +// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位 +// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章) +func BookAllChapters(c *gin.Context) { + q := database.DB().Model(&model.Chapter{}) + if c.Query("excludeFixed") == "1" { + for _, p := range excludeParts { + q = q.Where("part_title NOT LIKE ?", "%"+p+"%") + } + } + var list []model.Chapter + if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) +} + +// BookChapterByID GET /api/book/chapter/:id 按业务 id 查询(兼容旧链接) +func BookChapterByID(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"}) + return + } + findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB { + return db.Where("id = ?", id) + }) +} + +// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐) +func BookChapterByMID(c *gin.Context) { + midStr := c.Param("mid") + if midStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 mid"}) + return + } + mid, err := strconv.Atoi(midStr) + if err != nil || mid < 1 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "mid 必须为正整数"}) + return + } + findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB { + return db.Where("mid = ?", mid) + }) +} + +// findChapterAndRespond 按条件查章节并返回统一格式 +func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) { + var ch model.Chapter + db := database.DB() + if err := whereFn(db).First(&ch).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + out := gin.H{ + "success": true, + "data": ch, + "content": ch.Content, + "chapterTitle": ch.ChapterTitle, + "partTitle": ch.PartTitle, + "id": ch.ID, + "mid": ch.MID, + "sectionTitle": ch.SectionTitle, + } + if ch.IsFree != nil { + out["isFree"] = *ch.IsFree + } + if ch.Price != nil { + out["price"] = *ch.Price + // 价格为 0 元则自动视为免费 + if *ch.Price == 0 { + out["isFree"] = true + } + } + c.JSON(http.StatusOK, out) +} + +// BookChapters GET/POST/PUT/DELETE /api/book/chapters(与 app/api/book/chapters 一致,用 GORM) +func BookChapters(c *gin.Context) { + db := database.DB() + switch c.Request.Method { + case http.MethodGet: + partId := c.Query("partId") + status := c.Query("status") + if status == "" { + status = "published" + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "100")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 500 { + pageSize = 100 + } + q := db.Model(&model.Chapter{}) + if partId != "" { + q = q.Where("part_id = ?", partId) + } + if status != "" && status != "all" { + q = q.Where("status = ?", status) + } + var total int64 + q.Count(&total) + var list []model.Chapter + q.Order("sort_order ASC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list) + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "list": list, "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, + }, + }) + return + case http.MethodPost: + var body model.Chapter + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"}) + return + } + if body.ID == "" || body.PartID == "" || body.ChapterID == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要字段 id/partId/chapterId"}) + return + } + if err := db.Create(&body).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": body}) + return + case http.MethodPut: + var body model.Chapter + if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"}) + return + } + updates := map[string]interface{}{ + "part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle, + "content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price, + "sort_order": body.SortOrder, "status": body.Status, + } + if body.EditionStandard != nil { + updates["edition_standard"] = body.EditionStandard + } + if body.EditionPremium != nil { + updates["edition_premium"] = body.EditionPremium + } + if err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) + return + case http.MethodDelete: + id := c.Query("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"}) + return + } + if err := db.Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) + return + } + c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"}) +} + +// bookHotChaptersSorted 按精选推荐算法排序:阅读量优先,同量按更新时间;排除序言/尾声/附录 +func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter { + q := db.Model(&model.Chapter{}) + for _, p := range excludeParts { + q = q.Where("part_title NOT LIKE ?", "%"+p+"%") + } + var all []model.Chapter + if err := q.Order("sort_order ASC, id ASC").Find(&all).Error; err != nil || len(all) == 0 { + return nil + } + // 从 reading_progress 统计阅读量 + ids := make([]string, 0, len(all)) + for _, c := range all { + ids = append(ids, c.ID) + } + var counts []struct { + SectionID string `gorm:"column:section_id"` + Cnt int64 `gorm:"column:cnt"` + } + db.Table("reading_progress").Select("section_id, COUNT(*) as cnt"). + Where("section_id IN ?", ids).Group("section_id").Scan(&counts) + countMap := make(map[string]int64) + for _, r := range counts { + countMap[r.SectionID] = r.Cnt + } + // 按阅读量降序、同量按 updated_at 降序 + type withSort struct { + ch model.Chapter + cnt int64 + } + withCnt := make([]withSort, 0, len(all)) + for _, c := range all { + withCnt = append(withCnt, withSort{ch: c, cnt: countMap[c.ID]}) + } + for i := 0; i < len(withCnt)-1; i++ { + for j := i + 1; j < len(withCnt); j++ { + if withCnt[j].cnt > withCnt[i].cnt || + (withCnt[j].cnt == withCnt[i].cnt && withCnt[j].ch.UpdatedAt.After(withCnt[i].ch.UpdatedAt)) { + withCnt[i], withCnt[j] = withCnt[j], withCnt[i] + } + } + } + out := make([]model.Chapter, 0, limit) + for i := 0; i < limit && i < len(withCnt); i++ { + out = append(out, withCnt[i].ch) + } + return out +} + +// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录) +func BookHot(c *gin.Context) { + list := bookHotChaptersSorted(database.DB(), 10) + if len(list) == 0 { + // 兜底:按 sort_order 取前 10,同样排除序言/尾声/附录 + q := database.DB().Model(&model.Chapter{}) + for _, p := range excludeParts { + q = q.Where("part_title NOT LIKE ?", "%"+p+"%") + } + q.Order("sort_order ASC, id ASC").Limit(10).Find(&list) + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) +} + +// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章,带 热门/推荐/精选 标签) +func BookRecommended(c *gin.Context) { + list := bookHotChaptersSorted(database.DB(), 3) + if len(list) == 0 { + // 兜底:按 updated_at 取前 3,同样排除序言/尾声/附录 + q := database.DB().Model(&model.Chapter{}) + for _, p := range excludeParts { + q = q.Where("part_title NOT LIKE ?", "%"+p+"%") + } + q.Order("updated_at DESC, id ASC").Limit(3).Find(&list) + } + tags := []string{"热门", "推荐", "精选"} + out := make([]gin.H, 0, len(list)) + for i, ch := range list { + tag := "精选" + if i < len(tags) { + tag = tags[i] + } + out = append(out, gin.H{ + "id": ch.ID, "mid": ch.MID, "sectionTitle": ch.SectionTitle, "partTitle": ch.PartTitle, + "chapterTitle": ch.ChapterTitle, "tag": tag, + "isFree": ch.IsFree, "price": ch.Price, "isNew": ch.IsNew, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": out}) +} + +// BookLatestChapters GET /api/book/latest-chapters +func BookLatestChapters(c *gin.Context) { + var list []model.Chapter + database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list) + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) +} + +func escapeLikeBook(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "%", "\\%") + s = strings.ReplaceAll(s, "_", "\\_") + return s +} + +// BookSearch GET /api/book/search?q= 章节搜索(与 /api/search 逻辑一致) +func BookSearch(c *gin.Context) { + q := strings.TrimSpace(c.Query("q")) + if q == "" { + c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": ""}) + return + } + pattern := "%" + escapeLikeBook(q) + "%" + var list []model.Chapter + err := database.DB().Model(&model.Chapter{}). + Where("section_title LIKE ? OR content LIKE ?", pattern, pattern). + Order("sort_order ASC, id ASC"). + Limit(20). + Find(&list).Error + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q}) + return + } + lowerQ := strings.ToLower(q) + results := make([]gin.H, 0, len(list)) + for _, ch := range list { + matchType := "content" + if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) { + matchType = "title" + } + results = append(results, gin.H{ + "id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle, + "isFree": ch.IsFree, "matchType": matchType, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "results": results, "total": len(results), "keyword": q}) +} + +// BookStats GET /api/book/stats +func BookStats(c *gin.Context) { + var total int64 + database.DB().Model(&model.Chapter{}).Count(&total) + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}}) +} + +// BookSync GET/POST /api/book/sync +func BookSync(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步由 DB 维护"}) +} diff --git a/soul-api/internal/handler/ckb.go b/soul-api/internal/handler/ckb.go new file mode 100644 index 00000000..2fff6778 --- /dev/null +++ b/soul-api/internal/handler/ckb.go @@ -0,0 +1,324 @@ +package handler + +import ( + "bytes" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "soul-api/internal/database" + "soul-api/internal/model" +) + +const ckbAPIKey = "fyngh-ecy9h-qkdae-epwd5-rz6kd" +const ckbAPIURL = "https://ckbapi.quwanzhi.com/v1/api/scenarios" + +var ckbSourceMap = map[string]string{"team": "团队招募", "investor": "资源对接", "mentor": "导师顾问", "partner": "创业合伙"} +var ckbTagsMap = map[string]string{"team": "切片团队,团队招募", "investor": "资源对接,资源群", "mentor": "导师顾问,咨询服务", "partner": "创业合伙,创业伙伴"} + +// ckbSign 与 next-project app/api/ckb/join 一致:排除 sign/apiKey/portrait,空值跳过,按键升序拼接值,MD5(拼接串) 再 MD5(结果+apiKey) +func ckbSign(params map[string]interface{}, apiKey string) string { + keys := make([]string, 0, len(params)) + for k := range params { + if k == "sign" || k == "apiKey" || k == "portrait" { + continue + } + v := params[k] + if v == nil || v == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var concat string + for _, k := range keys { + v := params[k] + switch val := v.(type) { + case string: + concat += val + case float64: + concat += strconv.FormatFloat(val, 'f', -1, 64) + case int: + concat += strconv.Itoa(val) + case int64: + concat += strconv.FormatInt(val, 10) + default: + concat += "" + } + } + h := md5.Sum([]byte(concat)) + first := hex.EncodeToString(h[:]) + h2 := md5.Sum([]byte(first + apiKey)) + return hex.EncodeToString(h2[:]) +} + +// CKBJoin POST /api/ckb/join +func CKBJoin(c *gin.Context) { + var body struct { + Type string `json:"type" binding:"required"` + Phone string `json:"phone"` + Wechat string `json:"wechat"` + Name string `json:"name"` + UserID string `json:"userId"` + Remark string `json:"remark"` + CanHelp string `json:"canHelp"` // 资源对接:我能帮到你什么 + NeedHelp string `json:"needHelp"` // 资源对接:我需要什么帮助 + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"}) + return + } + if body.Phone == "" && body.Wechat == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"}) + return + } + if body.Type != "team" && body.Type != "investor" && body.Type != "mentor" && body.Type != "partner" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"}) + return + } + ts := time.Now().Unix() + params := map[string]interface{}{ + "timestamp": ts, + "source": "创业实验-" + ckbSourceMap[body.Type], + "tags": ckbTagsMap[body.Type], + "siteTags": "创业实验APP", + "remark": body.Remark, + } + if body.Remark == "" { + remark := "用户通过创业实验APP申请" + ckbSourceMap[body.Type] + if body.Type == "investor" && (body.CanHelp != "" || body.NeedHelp != "") { + remark = fmt.Sprintf("能帮:%s 需要:%s", body.CanHelp, body.NeedHelp) + } + params["remark"] = remark + } + if body.Phone != "" { + params["phone"] = body.Phone + } + if body.Wechat != "" { + params["wechatId"] = body.Wechat + } + if body.Name != "" { + params["name"] = body.Name + } + params["apiKey"] = ckbAPIKey + params["sign"] = ckbSign(params, ckbAPIKey) + sourceData := map[string]interface{}{ + "joinType": body.Type, "joinLabel": ckbSourceMap[body.Type], "userId": body.UserID, + "device": "webapp", "timestamp": time.Now().Format(time.RFC3339), + } + if body.Type == "investor" { + if body.CanHelp != "" { + sourceData["canHelp"] = body.CanHelp + } + if body.NeedHelp != "" { + sourceData["needHelp"] = body.NeedHelp + } + } + params["portrait"] = map[string]interface{}{ + "type": 4, "source": 0, + "sourceData": sourceData, + "remark": ckbSourceMap[body.Type] + "申请", + "uniqueId": "soul_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10), + } + raw, _ := json.Marshal(params) + resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "服务器错误,请稍后重试"}) + return + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + var result struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` + } + _ = json.Unmarshal(b, &result) + if result.Code == 200 { + // 资源对接:同步更新用户资料中的 help_offer、help_need、phone、wechat_id + if body.Type == "investor" && body.UserID != "" { + updates := map[string]interface{}{} + if body.CanHelp != "" { + updates["help_offer"] = body.CanHelp + } + if body.NeedHelp != "" { + updates["help_need"] = body.NeedHelp + } + if body.Phone != "" { + updates["phone"] = body.Phone + } + if body.Wechat != "" { + updates["wechat_id"] = body.Wechat + } + if len(updates) > 0 { + database.DB().Model(&model.User{}).Where("id = ?", body.UserID).Updates(updates) + } + } + msg := "成功加入" + ckbSourceMap[body.Type] + if result.Message == "已存在" { + msg = "您已加入,我们会尽快联系您" + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data}) + return + } + errMsg := result.Message + if errMsg == "" { + errMsg = "加入失败,请稍后重试" + } + // 打印 CKB 原始响应便于排查 + fmt.Printf("[CKBJoin] 失败 type=%s wechat=%s code=%d message=%s raw=%s\n", + body.Type, body.Wechat, result.Code, result.Message, string(b)) + c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg}) +} + +// CKBMatch POST /api/ckb/match +func CKBMatch(c *gin.Context) { + var body struct { + MatchType string `json:"matchType"` + Phone string `json:"phone"` + Wechat string `json:"wechat"` + UserID string `json:"userId"` + Nickname string `json:"nickname"` + MatchedUser interface{} `json:"matchedUser"` + } + _ = c.ShouldBindJSON(&body) + if body.Phone == "" && body.Wechat == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"}) + return + } + ts := time.Now().Unix() + label := ckbSourceMap[body.MatchType] + if label == "" { + label = "创业合伙" + } + params := map[string]interface{}{ + "timestamp": ts, + "source": "创业实验-找伙伴匹配", + "tags": "找伙伴," + label, + "siteTags": "创业实验APP,匹配用户", + "remark": "用户发起" + label + "匹配", + } + if body.Phone != "" { + params["phone"] = body.Phone + } + if body.Wechat != "" { + params["wechatId"] = body.Wechat + } + if body.Nickname != "" { + params["name"] = body.Nickname + } + params["apiKey"] = ckbAPIKey + params["sign"] = ckbSign(params, ckbAPIKey) + params["portrait"] = map[string]interface{}{ + "type": 4, "source": 0, + "sourceData": map[string]interface{}{ + "action": "match", "matchType": body.MatchType, "matchLabel": label, + "userId": body.UserID, "device": "webapp", "timestamp": time.Now().Format(time.RFC3339), + }, + "remark": "找伙伴匹配-" + label, + "uniqueId": "soul_match_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10), + } + raw, _ := json.Marshal(params) + resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw)) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"}) + return + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + var result struct { + Code int `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal(b, &result) + if result.Code == 200 { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配记录已上报", "data": nil}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"}) +} + +// CKBSync GET/POST /api/ckb/sync +func CKBSync(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// CKBLead POST /api/miniprogram/ckb/lead 小程序-链接卡若:上报线索到存客宝,便于卡若添加好友 +// 请求体:phone(可选)、wechatId(可选)、name(可选)、userId(可选,用于补全昵称) +// 至少传 phone 或 wechatId 之一;签名规则同 api_v1.md +func CKBLead(c *gin.Context) { + var body struct { + UserID string `json:"userId"` + Phone string `json:"phone"` + WechatID string `json:"wechatId"` + Name string `json:"name"` + } + _ = c.ShouldBindJSON(&body) + phone := strings.TrimSpace(body.Phone) + wechatId := strings.TrimSpace(body.WechatID) + if phone == "" && wechatId == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "请提供手机号或微信号"}) + return + } + name := strings.TrimSpace(body.Name) + if name == "" && body.UserID != "" { + var u model.User + if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" { + name = *u.Nickname + } + } + if name == "" { + name = "小程序用户" + } + ts := time.Now().Unix() + params := map[string]interface{}{ + "timestamp": ts, + "source": "小程序-链接卡若", + "remark": "首页点击「链接卡若」留资", + "name": name, + } + if phone != "" { + params["phone"] = phone + } + if wechatId != "" { + params["wechatId"] = wechatId + } + params["apiKey"] = ckbAPIKey + params["sign"] = ckbSign(params, ckbAPIKey) + raw, _ := json.Marshal(params) + resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw)) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"}) + return + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + var result struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` + } + _ = json.Unmarshal(b, &result) + if result.Code == 200 { + msg := "提交成功,卡若会尽快联系您" + if result.Message == "已存在" { + msg = "您已留资,我们会尽快联系您" + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data}) + return + } + errMsg := result.Message + if errMsg == "" { + errMsg = "提交失败,请稍后重试" + } + c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg}) +} diff --git a/soul-api/internal/handler/config.go b/soul-api/internal/handler/config.go new file mode 100644 index 00000000..d9347cb9 --- /dev/null +++ b/soul-api/internal/handler/config.go @@ -0,0 +1,63 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" +) + +// GetConfig GET /api/config 从 system_config 读取并合并(与 app/api/config 结构一致) +func GetConfig(c *gin.Context) { + var list []model.SystemConfig + if err := database.DB().Order("config_key ASC").Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{}, + "siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{}, + }) + return + } + out := gin.H{ + "success": true, "paymentMethods": gin.H{}, "liveQRCodes": []interface{}{}, + "siteConfig": gin.H{}, "menuConfig": gin.H{}, "pageConfig": gin.H{}, + "authorInfo": gin.H{}, "marketing": gin.H{}, "system": gin.H{}, + } + for _, row := range list { + var val interface{} + _ = json.Unmarshal(row.ConfigValue, &val) + switch row.ConfigKey { + case "site_config", "siteConfig": + if m, ok := val.(map[string]interface{}); ok { + out["siteConfig"] = m + } + case "menu_config", "menuConfig": + out["menuConfig"] = val + case "page_config", "pageConfig": + if m, ok := val.(map[string]interface{}); ok { + out["pageConfig"] = m + } + case "payment_methods", "paymentMethods": + if m, ok := val.(map[string]interface{}); ok { + out["paymentMethods"] = m + } + case "live_qr_codes", "liveQRCodes": + out["liveQRCodes"] = val + case "author_info", "authorInfo": + if m, ok := val.(map[string]interface{}); ok { + out["authorInfo"] = m + } + case "marketing": + if m, ok := val.(map[string]interface{}); ok { + out["marketing"] = m + } + case "system": + if m, ok := val.(map[string]interface{}); ok { + out["system"] = m + } + } + } + c.JSON(http.StatusOK, out) +} diff --git a/soul-api/internal/handler/content.go b/soul-api/internal/handler/content.go new file mode 100644 index 00000000..1a617739 --- /dev/null +++ b/soul-api/internal/handler/content.go @@ -0,0 +1,12 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// ContentGet GET /api/content +func ContentGet(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/soul-api/internal/handler/cron.go b/soul-api/internal/handler/cron.go new file mode 100644 index 00000000..71593834 --- /dev/null +++ b/soul-api/internal/handler/cron.go @@ -0,0 +1,161 @@ +package handler + +import ( + "context" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + "soul-api/internal/wechat" + + "github.com/gin-gonic/gin" +) + +var ( + syncOrdersLogger *log.Logger + syncOrdersLoggerOnce sync.Once +) + +// syncOrdersLogf 将订单同步日志写入 log/sync-orders.log,不输出到控制台 +func syncOrdersLogf(format string, args ...interface{}) { + syncOrdersLoggerOnce.Do(func() { + _ = os.MkdirAll("log", 0755) + f, err := os.OpenFile(filepath.Join("log", "sync-orders.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + syncOrdersLogger = log.New(io.Discard, "", 0) + return + } + syncOrdersLogger = log.New(f, "[SyncOrders] ", log.Ldate|log.Ltime) + }) + if syncOrdersLogger != nil { + syncOrdersLogger.Printf(format, args...) + } +} + +// SyncOrdersLogf 供 main 等调用,将订单同步相关日志写入 log/sync-orders.log +func SyncOrdersLogf(format string, args ...interface{}) { + syncOrdersLogf(format, args...) +} + +// RunSyncOrders 订单对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单) +// 可被 HTTP 接口和内置定时任务调用。days 为查询范围(天),建议 7。 +func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error) { + if days < 1 { + days = 7 + } + if days > 30 { + days = 30 + } + db := database.DB() + cutoff := time.Now().AddDate(0, 0, -days) + var createdOrders []model.Order + if err := db.Where("status = ? AND created_at > ?", "created", cutoff).Find(&createdOrders).Error; err != nil { + return 0, 0, err + } + total = len(createdOrders) + + for _, o := range createdOrders { + select { + case <-ctx.Done(): + return synced, total, ctx.Err() + default: + } + tradeState, transactionID, totalFee, qerr := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN) + if qerr != nil { + syncOrdersLogf("查询订单 %s 失败: %v", o.OrderSN, qerr) + continue + } + if tradeState != "SUCCESS" { + continue + } + // 微信已支付,本地未更新 → 补齐 + totalAmount := float64(totalFee) / 100 + now := time.Now() + if err := db.Model(&o).Updates(map[string]interface{}{ + "status": "paid", + "transaction_id": transactionID, + "pay_time": now, + "updated_at": now, + }).Error; err != nil { + syncOrdersLogf("更新订单 %s 失败: %v", o.OrderSN, err) + continue + } + synced++ + syncOrdersLogf("补齐漏单: %s, amount=%.2f", o.OrderSN, totalAmount) + + // 同步后续逻辑(全书、VIP、分销等,与 PayNotify 一致) + pt := "fullbook" + if o.ProductType != "" { + pt = o.ProductType + } + productID := "" + if o.ProductID != nil { + productID = *o.ProductID + } + if productID == "" { + productID = "fullbook" + } + + switch pt { + case "fullbook": + db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true) + syncOrdersLogf("用户已购全书: %s", o.UserID) + case "vip": + expireDate := now.AddDate(0, 0, 365) + db.Model(&model.User{}).Where("id = ?", o.UserID).Updates(map[string]interface{}{ + "is_vip": true, + "vip_expire_date": expireDate, + "vip_activated_at": now, + }) + syncOrdersLogf("用户 VIP 已激活: %s, 过期日=%s", o.UserID, expireDate.Format("2006-01-02")) + case "match": + syncOrdersLogf("用户购买匹配次数: %s", o.UserID) + case "section": + syncOrdersLogf("用户购买章节: %s - %s", o.UserID, productID) + } + + // 取消同商品未支付订单(与 PayNotify 一致) + db.Where( + "user_id = ? AND product_type = ? AND product_id = ? AND status = ? AND order_sn != ?", + o.UserID, pt, productID, "created", o.OrderSN, + ).Delete(&model.Order{}) + + processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, &o) + } + return synced, total, nil +} + +// CronSyncOrders GET/POST /api/cron/sync-orders +// 对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单) +// 支持 ?days=7 扩展时间范围,默认 7 天 +func CronSyncOrders(c *gin.Context) { + days := 7 + if d := c.Query("days"); d != "" { + if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 30 { + days = n + } + } + synced, total, err := RunSyncOrders(c.Request.Context(), days) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "synced": synced, + "total": total, + "days": days, + }) +} + +// CronUnbindExpired GET/POST /api/cron/unbind-expired +func CronUnbindExpired(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go new file mode 100644 index 00000000..121a5fd9 --- /dev/null +++ b/soul-api/internal/handler/db.go @@ -0,0 +1,1144 @@ +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 new file mode 100644 index 00000000..f9f83c90 --- /dev/null +++ b/soul-api/internal/handler/db_book.go @@ -0,0 +1,330 @@ +package handler + +import ( + "context" + "net/http" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// listSelectCols 列表/导出不加载 content,大幅加速 +var listSelectCols = []string{ + "id", "section_title", "price", "is_free", "is_new", + "part_id", "part_title", "chapter_id", "chapter_title", "sort_order", +} + +// sectionListItem 与前端 SectionListItem 一致(小写驼峰) +type sectionListItem struct { + ID string `json:"id"` + Title string `json:"title"` + Price float64 `json:"price"` + IsFree *bool `json:"isFree,omitempty"` + IsNew *bool `json:"isNew,omitempty"` // stitch_soul:标记最新新增 + PartID string `json:"partId"` + PartTitle string `json:"partTitle"` + ChapterID string `json:"chapterId"` + ChapterTitle string `json:"chapterTitle"` + FilePath *string `json:"filePath,omitempty"` +} + +// DBBookAction GET/POST/PUT /api/db/book +func DBBookAction(c *gin.Context) { + db := database.DB() + switch c.Request.Method { + case http.MethodGet: + action := c.Query("action") + id := c.Query("id") + switch action { + case "list": + var rows []model.Chapter + if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}}) + return + } + sections := make([]sectionListItem, 0, len(rows)) + for _, r := range rows { + price := 1.0 + if r.Price != nil { + price = *r.Price + } + sections = append(sections, sectionListItem{ + ID: r.ID, + Title: r.SectionTitle, + Price: price, + IsFree: r.IsFree, + IsNew: r.IsNew, + PartID: r.PartID, + PartTitle: r.PartTitle, + ChapterID: r.ChapterID, + ChapterTitle: r.ChapterTitle, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)}) + return + case "read": + if id == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"}) + return + } + var ch model.Chapter + if err := db.Where("id = ?", id).First(&ch).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + price := 1.0 + if ch.Price != nil { + price = *ch.Price + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "section": gin.H{ + "id": ch.ID, + "title": ch.SectionTitle, + "price": price, + "content": ch.Content, + "isNew": ch.IsNew, + "partId": ch.PartID, + "partTitle": ch.PartTitle, + "chapterId": ch.ChapterID, + "chapterTitle": ch.ChapterTitle, + }, + }) + return + case "export": + var rows []model.Chapter + if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + sections := make([]sectionListItem, 0, len(rows)) + for _, r := range rows { + price := 1.0 + if r.Price != nil { + price = *r.Price + } + sections = append(sections, sectionListItem{ + ID: r.ID, Title: r.SectionTitle, Price: price, IsFree: r.IsFree, IsNew: r.IsNew, + PartID: r.PartID, PartTitle: r.PartTitle, ChapterID: r.ChapterID, ChapterTitle: r.ChapterTitle, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections}) + return + default: + c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"}) + return + } + case http.MethodPost: + var body struct { + Action string `json:"action"` + Data []importItem `json:"data"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) + return + } + switch body.Action { + case "sync": + c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成(Gin 无文件源时可从 DB 已存在数据视为已同步)"}) + return + case "import": + imported, failed := 0, 0 + for _, item := range body.Data { + price := 1.0 + if item.Price != nil { + price = *item.Price + } + isFree := false + if item.IsFree != nil { + isFree = *item.IsFree + } + wordCount := len(item.Content) + status := "published" + ch := model.Chapter{ + ID: item.ID, + PartID: strPtr(item.PartID, "part-1"), + PartTitle: strPtr(item.PartTitle, "未分类"), + ChapterID: strPtr(item.ChapterID, "chapter-1"), + ChapterTitle: strPtr(item.ChapterTitle, "未分类"), + SectionTitle: item.Title, + Content: item.Content, + WordCount: &wordCount, + IsFree: &isFree, + Price: &price, + Status: &status, + } + err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error + if err == gorm.ErrRecordNotFound { + err = db.Create(&ch).Error + } else if err == nil { + err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{ + "section_title": ch.SectionTitle, + "content": ch.Content, + "word_count": ch.WordCount, + "is_free": ch.IsFree, + "price": ch.Price, + }).Error + } + if err != nil { + failed++ + continue + } + imported++ + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed}) + return + default: + c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的 action"}) + return + } + case http.MethodPut: + var body struct { + Action string `json:"action"` + // reorder:新顺序,支持跨篇跨章时附带 partId/chapterId + IDs []string `json:"ids"` + Items []reorderItem `json:"items"` + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Price *float64 `json:"price"` + IsFree *bool `json:"isFree"` + IsNew *bool `json:"isNew"` // stitch_soul:标记最新新增 + EditionStandard *bool `json:"editionStandard"` // 是否属于普通版 + EditionPremium *bool `json:"editionPremium"` // 是否属于增值版 + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) + return + } + if body.Action == "reorder" { + // 立即返回成功,后台异步执行排序更新 + if len(body.Items) > 0 { + items := make([]reorderItem, len(body.Items)) + copy(items, body.Items) + c.JSON(http.StatusOK, gin.H{"success": true}) + go func() { + db := database.DB() + for i, it := range items { + if it.ID == "" { + continue + } + up := map[string]interface{}{"sort_order": i} + if it.PartID != "" { + up["part_id"] = it.PartID + } + if it.PartTitle != "" { + up["part_title"] = it.PartTitle + } + if it.ChapterID != "" { + up["chapter_id"] = it.ChapterID + } + if it.ChapterTitle != "" { + up["chapter_title"] = it.ChapterTitle + } + _ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error + } + }() + return + } + if len(body.IDs) > 0 { + ids := make([]string, len(body.IDs)) + copy(ids, body.IDs) + c.JSON(http.StatusOK, gin.H{"success": true}) + go func() { + db := database.DB() + for i, id := range ids { + if id != "" { + _ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error + } + } + }() + return + } + } + if body.ID == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"}) + return + } + price := 1.0 + if body.Price != nil { + price = *body.Price + } + isFree := false + if body.IsFree != nil { + isFree = *body.IsFree + } + wordCount := len(body.Content) + updates := map[string]interface{}{ + "section_title": body.Title, + "content": body.Content, + "word_count": wordCount, + "price": price, + "is_free": isFree, + } + if body.IsNew != nil { + updates["is_new"] = *body.IsNew + } + if body.EditionStandard != nil { + updates["edition_standard"] = *body.EditionStandard + } + if body.EditionPremium != nil { + updates["edition_premium"] = *body.EditionPremium + } + err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) + return + } + c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"}) +} + +type reorderItem struct { + ID string `json:"id"` + PartID string `json:"partId"` + PartTitle string `json:"partTitle"` + ChapterID string `json:"chapterId"` + ChapterTitle string `json:"chapterTitle"` +} + +type importItem struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Price *float64 `json:"price"` + IsFree *bool `json:"isFree"` + PartID *string `json:"partId"` + PartTitle *string `json:"partTitle"` + ChapterID *string `json:"chapterId"` + ChapterTitle *string `json:"chapterTitle"` +} + +func strPtr(s *string, def string) string { + if s != nil && *s != "" { + return *s + } + return def +} + +// DBBookDelete DELETE /api/db/book +func DBBookDelete(c *gin.Context) { + id := c.Query("id") + if id == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"}) + return + } + if err := database.DB().Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/soul-api/internal/handler/distribution.go b/soul-api/internal/handler/distribution.go new file mode 100644 index 00000000..f0c44ede --- /dev/null +++ b/soul-api/internal/handler/distribution.go @@ -0,0 +1,22 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// DistributionGet POST /api/distribution GET/POST/PUT +func DistributionGet(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// DistributionAutoWithdrawConfig GET/POST/DELETE /api/distribution/auto-withdraw-config +func DistributionAutoWithdrawConfig(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// DistributionMessages GET/POST /api/distribution/messages +func DistributionMessages(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/soul-api/internal/handler/documentation.go b/soul-api/internal/handler/documentation.go new file mode 100644 index 00000000..00699750 --- /dev/null +++ b/soul-api/internal/handler/documentation.go @@ -0,0 +1,12 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// DocGenerate POST /api/documentation/generate +func DocGenerate(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/soul-api/internal/handler/match.go b/soul-api/internal/handler/match.go new file mode 100644 index 00000000..e933ef99 --- /dev/null +++ b/soul-api/internal/handler/match.go @@ -0,0 +1,253 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const defaultFreeMatchLimit = 3 + +// MatchQuota 匹配次数配额(纯计算:订单 + match_records) +type MatchQuota struct { + PurchasedTotal int64 `json:"purchasedTotal"` + PurchasedUsed int64 `json:"purchasedUsed"` + MatchesUsedToday int64 `json:"matchesUsedToday"` + FreeRemainToday int64 `json:"freeRemainToday"` + PurchasedRemain int64 `json:"purchasedRemain"` + RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数 +} + +func getFreeMatchLimit(db *gorm.DB) int { + var cfg model.SystemConfig + if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err != nil { + return defaultFreeMatchLimit + } + var config map[string]interface{} + if err := json.Unmarshal(cfg.ConfigValue, &config); err != nil { + return defaultFreeMatchLimit + } + if v, ok := config["freeMatchLimit"].(float64); ok && v > 0 { + return int(v) + } + return defaultFreeMatchLimit +} + +// GetMatchQuota 根据订单和 match_records 纯计算用户匹配配额 +func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota { + if freeLimit <= 0 { + freeLimit = defaultFreeMatchLimit + } + var purchasedTotal int64 + db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userID, "match", "paid").Count(&purchasedTotal) + var matchesToday int64 + db.Model(&model.MatchRecord{}).Where("user_id = ? AND created_at >= CURDATE()", userID).Count(&matchesToday) + // 历史每日超出免费部分之和 = 已消耗的购买次数 + var purchasedUsed int64 + db.Raw(` + SELECT COALESCE(SUM(cnt - ?), 0) FROM ( + SELECT DATE(created_at) AS d, COUNT(*) AS cnt + FROM match_records WHERE user_id = ? + GROUP BY DATE(created_at) + HAVING cnt > ? + ) t + `, freeLimit, userID, freeLimit).Scan(&purchasedUsed) + freeUsed := matchesToday + if freeUsed > int64(freeLimit) { + freeUsed = int64(freeLimit) + } + freeRemain := int64(freeLimit) - freeUsed + if freeRemain < 0 { + freeRemain = 0 + } + purchasedRemain := purchasedTotal - purchasedUsed + if purchasedRemain < 0 { + purchasedRemain = 0 + } + remainToday := freeRemain + purchasedRemain + return MatchQuota{ + PurchasedTotal: purchasedTotal, + PurchasedUsed: purchasedUsed, + MatchesUsedToday: matchesToday, + FreeRemainToday: freeRemain, + PurchasedRemain: purchasedRemain, + RemainToday: remainToday, + } +} + +var defaultMatchTypes = []gin.H{ + gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true}, + gin.H{"id": "investor", "label": "资源对接", "matchLabel": "资源对接", "icon": "👥", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true}, + gin.H{"id": "mentor", "label": "导师顾问", "matchLabel": "导师顾问", "icon": "❤️", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true}, + gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true}, +} + +// MatchConfigGet GET /api/match/config +func MatchConfigGet(c *gin.Context) { + db := database.DB() + var cfg model.SystemConfig + if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "matchTypes": defaultMatchTypes, + "freeMatchLimit": 3, + "matchPrice": 1, + "settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10}, + }, + "source": "default", + }) + return + } + var config map[string]interface{} + _ = json.Unmarshal(cfg.ConfigValue, &config) + matchTypes := defaultMatchTypes + if v, ok := config["matchTypes"].([]interface{}); ok && len(v) > 0 { + matchTypes = make([]gin.H, 0, len(v)) + for _, t := range v { + if m, ok := t.(map[string]interface{}); ok { + enabled := true + if e, ok := m["enabled"].(bool); ok && !e { + enabled = false + } + if enabled { + matchTypes = append(matchTypes, gin.H(m)) + } + } + } + if len(matchTypes) == 0 { + matchTypes = defaultMatchTypes + } + } + freeMatchLimit := 3 + if v, ok := config["freeMatchLimit"].(float64); ok { + freeMatchLimit = int(v) + } + matchPrice := 1 + if v, ok := config["matchPrice"].(float64); ok { + matchPrice = int(v) + } + settings := gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10} + if s, ok := config["settings"].(map[string]interface{}); ok { + for k, v := range s { + settings[k] = v + } + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ + "matchTypes": matchTypes, "freeMatchLimit": freeMatchLimit, "matchPrice": matchPrice, "settings": settings, + }, "source": "database"}) +} + +// MatchConfigPost POST /api/match/config +func MatchConfigPost(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// MatchUsers POST /api/match/users +func MatchUsers(c *gin.Context) { + var body struct { + UserID string `json:"userId" binding:"required"` + MatchType string `json:"matchType"` + Phone string `json:"phone"` + WechatID string `json:"wechatId"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"}) + return + } + db := database.DB() + // 全书用户无限制,否则校验今日剩余次数 + var user model.User + skipQuota := false + if err := db.Where("id = ?", body.UserID).First(&user).Error; err == nil { + skipQuota = user.HasFullBook != nil && *user.HasFullBook + } + if !skipQuota { + freeLimit := getFreeMatchLimit(db) + quota := GetMatchQuota(db, body.UserID, freeLimit) + if quota.RemainToday <= 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "今日匹配次数已用完,请购买更多次数", + "code": "QUOTA_EXCEEDED", + }) + return + } + } + // 只匹配已绑定微信或手机号的用户 + var users []model.User + q := db.Where("id != ?", body.UserID). + Where("((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))") + if err := q.Order("created_at DESC").Limit(20).Find(&users).Error; err != nil || len(users) == 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "暂无匹配用户", "data": nil, "code": "NO_USERS"}) + return + } + // 随机选一个 + idx := 0 + if len(users) > 1 { + idx = int(users[0].CreatedAt.Unix() % int64(len(users))) + } + r := users[idx] + nickname := "微信用户" + if r.Nickname != nil { + nickname = *r.Nickname + } + avatar := "" + if r.Avatar != nil { + avatar = *r.Avatar + } + wechat := "" + if r.WechatID != nil { + wechat = *r.WechatID + } + phone := "" + if r.Phone != nil { + phone = *r.Phone + } + intro := "来自Soul创业派对的伙伴" + matchLabels := map[string]string{"partner": "找伙伴", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"} + tag := matchLabels[body.MatchType] + if tag == "" { + tag = "找伙伴" + } + // 写入匹配记录(含发起者的 phone/wechat_id 便于后续联系) + rec := model.MatchRecord{ + ID: fmt.Sprintf("mr_%d", time.Now().UnixNano()), + UserID: body.UserID, + MatchedUserID: r.ID, + MatchType: body.MatchType, + } + if body.MatchType == "" { + rec.MatchType = "partner" + } + if body.Phone != "" { + rec.Phone = &body.Phone + } + if body.WechatID != "" { + rec.WechatID = &body.WechatID + } + if err := db.Create(&rec).Error; err != nil { + fmt.Printf("[MatchUsers] 写入 match_records 失败: %v\n", err) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "id": r.ID, "nickname": nickname, "avatar": avatar, "wechat": wechat, "phone": phone, + "introduction": intro, "tags": []string{"创业者", tag}, + "matchScore": 80 + (r.CreatedAt.Unix() % 20), + "commonInterests": []gin.H{ + gin.H{"icon": "📚", "text": "都在读《创业派对》"}, + gin.H{"icon": "💼", "text": "对创业感兴趣"}, + gin.H{"icon": "🎯", "text": "相似的发展方向"}, + }, + }, + "totalUsers": len(users), + }) +} diff --git a/soul-api/internal/handler/match_records.go b/soul-api/internal/handler/match_records.go new file mode 100644 index 00000000..e236e5cd --- /dev/null +++ b/soul-api/internal/handler/match_records.go @@ -0,0 +1,96 @@ +package handler + +import ( + "net/http" + "strconv" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" +) + +// DBMatchRecordsList GET /api/db/match-records 管理端-匹配记录列表(分页、按类型筛选) +func DBMatchRecordsList(c *gin.Context) { + db := database.DB() + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + matchType := c.Query("matchType") + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + q := db.Model(&model.MatchRecord{}) + if matchType != "" { + q = q.Where("match_type = ?", matchType) + } + var total int64 + q.Count(&total) + + var records []model.MatchRecord + if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "records": []interface{}{}}) + return + } + + userIDs := make(map[string]bool) + for _, r := range records { + userIDs[r.UserID] = true + userIDs[r.MatchedUserID] = true + } + ids := make([]string, 0, len(userIDs)) + for id := range userIDs { + ids = append(ids, id) + } + var users []model.User + if len(ids) > 0 { + database.DB().Where("id IN ?", ids).Find(&users) + } + userMap := make(map[string]*model.User) + for i := range users { + userMap[users[i].ID] = &users[i] + } + + getStr := func(s *string) string { + if s == nil { + return "" + } + return *s + } + + out := make([]gin.H, 0, len(records)) + for _, r := range records { + u := userMap[r.UserID] + mu := userMap[r.MatchedUserID] + userAvatar := "" + matchedUserAvatar := "" + if u != nil && u.Avatar != nil { + userAvatar = *u.Avatar + } + if mu != nil && mu.Avatar != nil { + matchedUserAvatar = *mu.Avatar + } + out = append(out, gin.H{ + "id": r.ID, "userId": r.UserID, "matchedUserId": r.MatchedUserID, + "matchType": r.MatchType, "phone": getStr(r.Phone), "wechatId": getStr(r.WechatID), + "userNickname": getStr(u.Nickname), + "matchedNickname": getStr(mu.Nickname), + "userAvatar": userAvatar, + "matchedUserAvatar": matchedUserAvatar, + "matchScore": r.MatchScore, + "createdAt": r.CreatedAt, + }) + } + + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + c.JSON(http.StatusOK, gin.H{ + "success": true, "records": out, + "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, + }) +} diff --git a/soul-api/internal/handler/mentor.go b/soul-api/internal/handler/mentor.go new file mode 100644 index 00000000..d28aecb4 --- /dev/null +++ b/soul-api/internal/handler/mentor.go @@ -0,0 +1,340 @@ +// Soul创业派对 - 导师模块(stitch_soul) +// 小程序:GET /api/miniprogram/mentors 列表、GET /api/miniprogram/mentors/:id 详情、POST /api/miniprogram/mentors/:id/book 预约 +// 管理端:db 组 CRUD + 预约列表 + +package handler + +import ( + "net/http" + "strconv" + "strings" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" +) + +// MiniprogramMentorsList GET /api/miniprogram/mentors 导师列表(支持 q 搜索、skill 筛选) +func MiniprogramMentorsList(c *gin.Context) { + db := database.DB() + q := c.Query("q") + skill := c.Query("skill") + + query := db.Model(&model.Mentor{}).Where("(enabled IS NULL OR enabled = 1)") + if q != "" { + query = query.Where("name LIKE ? OR intro LIKE ? OR tags LIKE ?", "%"+q+"%", "%"+q+"%", "%"+q+"%") + } + if skill != "" { + query = query.Where("tags LIKE ?", "%"+skill+"%") + } + + var list []model.Mentor + if err := query.Order("sort ASC, id ASC").Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + + // 转为前端友好格式(tags 拆成数组) + type mentorItem struct { + model.Mentor + TagsArr []string `json:"tagsArr"` + } + result := make([]mentorItem, len(list)) + for i, m := range list { + result[i] = mentorItem{Mentor: m} + if m.Tags != "" { + result[i].TagsArr = strings.Split(m.Tags, ",") + for j := range result[i].TagsArr { + result[i].TagsArr[j] = strings.TrimSpace(result[i].TagsArr[j]) + } + } + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": result}) +} + +// MiniprogramMentorsDetail GET /api/miniprogram/mentors/:id 导师详情 +func MiniprogramMentorsDetail(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id <= 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的导师ID"}) + return + } + db := database.DB() + var m model.Mentor + if err := db.Where("id = ? AND (enabled IS NULL OR enabled = 1)", id).First(&m).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "导师不存在"}) + return + } + tagsArr := []string{} + if m.Tags != "" { + for _, s := range strings.Split(m.Tags, ",") { + if t := strings.TrimSpace(s); t != "" { + tagsArr = append(tagsArr, t) + } + } + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "id": m.ID, + "avatar": m.Avatar, + "name": m.Name, + "intro": m.Intro, + "tags": m.Tags, + "tagsArr": tagsArr, + "priceSingle": m.PriceSingle, + "priceHalfYear": m.PriceHalfYear, + "priceYear": m.PriceYear, + "quote": m.Quote, + "whyFind": m.WhyFind, + "offering": m.Offering, + "judgmentStyle": m.JudgmentStyle, + }, + }) +} + +// MiniprogramMentorsBook POST /api/miniprogram/mentors/:id/book 创建预约(选择咨询类型后创建,后续走支付) +func MiniprogramMentorsBook(c *gin.Context) { + idStr := c.Param("id") + mentorID, err := strconv.Atoi(idStr) + if err != nil || mentorID <= 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "无效的导师ID"}) + return + } + var body struct { + UserID int `json:"userId" binding:"required"` + ConsultationType string `json:"consultationType" binding:"required"` // single, half_year, year + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数不完整"}) + return + } + if body.ConsultationType != "single" && body.ConsultationType != "half_year" && body.ConsultationType != "year" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "consultationType 需为 single/half_year/year"}) + return + } + + db := database.DB() + var mentor model.Mentor + if err := db.Where("id = ? AND (enabled IS NULL OR enabled = 1)", mentorID).First(&mentor).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "导师不存在"}) + return + } + + var amount float64 + switch body.ConsultationType { + case "single": + if mentor.PriceSingle != nil { + amount = *mentor.PriceSingle + } + case "half_year": + if mentor.PriceHalfYear != nil { + amount = *mentor.PriceHalfYear + } + case "year": + if mentor.PriceYear != nil { + amount = *mentor.PriceYear + } + } + if amount <= 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "该咨询类型暂未配置价格"}) + return + } + + consult := model.MentorConsultation{ + UserID: body.UserID, + MentorID: mentorID, + ConsultationType: body.ConsultationType, + Amount: amount, + Status: "created", + } + if err := db.Create(&consult).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + + // 返回预约单,前端可据此调 pay 接口(productType: mentor_consultation, productId: consult.ID) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "id": consult.ID, + "mentorId": mentorID, + "consultationType": body.ConsultationType, + "amount": amount, + "status": "created", + }, + }) +} + +// DBMentorsList GET /api/db/mentors 管理端导师列表 +func DBMentorsList(c *gin.Context) { + db := database.DB() + var list []model.Mentor + if err := db.Order("sort ASC, id ASC").Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) +} + +// DBMentorsAction POST 新增 / PUT 更新 / DELETE 删除 导师 +func DBMentorsAction(c *gin.Context) { + db := database.DB() + method := c.Request.Method + + if method == "POST" { + var body struct { + Name string `json:"name" binding:"required"` + Avatar string `json:"avatar"` + Intro string `json:"intro"` + Tags string `json:"tags"` + PriceSingle *float64 `json:"priceSingle"` + PriceHalfYear *float64 `json:"priceHalfYear"` + PriceYear *float64 `json:"priceYear"` + Quote string `json:"quote"` + WhyFind string `json:"whyFind"` + Offering string `json:"offering"` + JudgmentStyle string `json:"judgmentStyle"` + Sort int `json:"sort"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 不能为空"}) + return + } + m := model.Mentor{ + Name: body.Name, + Avatar: body.Avatar, + Intro: body.Intro, + Tags: body.Tags, + PriceSingle: body.PriceSingle, + PriceHalfYear: body.PriceHalfYear, + PriceYear: body.PriceYear, + Quote: body.Quote, + WhyFind: body.WhyFind, + Offering: body.Offering, + JudgmentStyle: body.JudgmentStyle, + Sort: body.Sort, + Enabled: body.Enabled, + } + if m.Enabled == nil { + trueVal := true + m.Enabled = &trueVal + } + if err := db.Create(&m).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": m}) + return + } + + if method == "PUT" { + var body struct { + ID int `json:"id" binding:"required"` + Name *string `json:"name"` + Avatar *string `json:"avatar"` + Intro *string `json:"intro"` + Tags *string `json:"tags"` + PriceSingle *float64 `json:"priceSingle"` + PriceHalfYear *float64 `json:"priceHalfYear"` + PriceYear *float64 `json:"priceYear"` + Quote *string `json:"quote"` + WhyFind *string `json:"whyFind"` + Offering *string `json:"offering"` + JudgmentStyle *string `json:"judgmentStyle"` + Sort *int `json:"sort"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"}) + return + } + updates := map[string]interface{}{} + if body.Name != nil { + updates["name"] = *body.Name + } + if body.Avatar != nil { + updates["avatar"] = *body.Avatar + } + if body.Intro != nil { + updates["intro"] = *body.Intro + } + if body.Tags != nil { + updates["tags"] = *body.Tags + } + if body.PriceSingle != nil { + updates["price_single"] = *body.PriceSingle + } + if body.PriceHalfYear != nil { + updates["price_half_year"] = *body.PriceHalfYear + } + if body.PriceYear != nil { + updates["price_year"] = *body.PriceYear + } + if body.Quote != nil { + updates["quote"] = *body.Quote + } + if body.WhyFind != nil { + updates["why_find"] = *body.WhyFind + } + if body.Offering != nil { + updates["offering"] = *body.Offering + } + if body.JudgmentStyle != nil { + updates["judgment_style"] = *body.JudgmentStyle + } + if body.Sort != nil { + updates["sort"] = *body.Sort + } + if body.Enabled != nil { + updates["enabled"] = *body.Enabled + } + if len(updates) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "无更新"}) + return + } + if err := db.Model(&model.Mentor{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"}) + return + } + + if method == "DELETE" { + id := c.Query("id") + if id == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"}) + return + } + if err := db.Where("id = ?", id).Delete(&model.Mentor{}).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"}) +} + +// DBMentorConsultationsList GET /api/db/mentor-consultations 预约列表(支持 status 筛选) +func DBMentorConsultationsList(c *gin.Context) { + db := database.DB() + status := c.Query("status") + + query := db.Model(&model.MentorConsultation{}) + if status != "" { + query = query.Where("status = ?", status) + } + + var list []model.MentorConsultation + if err := query.Order("created_at DESC").Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) +} diff --git a/soul-api/internal/handler/menu.go b/soul-api/internal/handler/menu.go new file mode 100644 index 00000000..43f6c32e --- /dev/null +++ b/soul-api/internal/handler/menu.go @@ -0,0 +1,12 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// MenuGet GET /api/menu +func MenuGet(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) +} diff --git a/soul-api/internal/handler/miniprogram.go b/soul-api/internal/handler/miniprogram.go new file mode 100644 index 00000000..f0c5558b --- /dev/null +++ b/soul-api/internal/handler/miniprogram.go @@ -0,0 +1,831 @@ +package handler + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + "soul-api/internal/wechat" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var ( + orderPollLogger *log.Logger + orderPollLoggerOnce sync.Once +) + +// orderPollLogf 将订单轮询检测日志写入 log/order-poll.log,不输出到控制台 +func orderPollLogf(format string, args ...interface{}) { + orderPollLoggerOnce.Do(func() { + _ = os.MkdirAll("log", 0755) + f, err := os.OpenFile(filepath.Join("log", "order-poll.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + orderPollLogger = log.New(io.Discard, "", 0) + return + } + orderPollLogger = log.New(f, "[OrderPoll] ", log.Ldate|log.Ltime) + }) + if orderPollLogger != nil { + orderPollLogger.Printf(format, args...) + } +} + +// MiniprogramLogin POST /api/miniprogram/login +func MiniprogramLogin(c *gin.Context) { + var req struct { + Code string `json:"code" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少登录code"}) + return + } + + // 调用微信接口获取 openid 和 session_key + openID, sessionKey, _, err := wechat.Code2Session(req.Code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)}) + return + } + + db := database.DB() + + // 查询用户是否存在 + var user model.User + result := db.Where("open_id = ?", openID).First(&user) + + isNewUser := result.Error != nil + + if isNewUser { + // 创建新用户 + userID := openID // 直接使用 openid 作为用户 ID + referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:]) + nickname := "微信用户" + openID[len(openID)-4:] + avatar := "" + hasFullBook := false + earnings := 0.0 + pendingEarnings := 0.0 + referralCount := 0 + purchasedSections := "[]" + + user = model.User{ + ID: userID, + OpenID: &openID, + SessionKey: &sessionKey, + Nickname: &nickname, + Avatar: &avatar, + ReferralCode: &referralCode, + HasFullBook: &hasFullBook, + PurchasedSections: &purchasedSections, + Earnings: &earnings, + PendingEarnings: &pendingEarnings, + ReferralCount: &referralCount, + } + + if err := db.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"}) + return + } + } else { + // 更新 session_key + db.Model(&user).Update("session_key", sessionKey) + } + + // 从 orders 表查询真实购买记录 + var purchasedSections []string + var orderRows []struct { + ProductID string `gorm:"column:product_id"` + } + + db.Raw(` + SELECT DISTINCT product_id + FROM orders + WHERE user_id = ? + AND status = 'paid' + AND product_type = 'section' + `, user.ID).Scan(&orderRows) + + for _, row := range orderRows { + if row.ProductID != "" { + purchasedSections = append(purchasedSections, row.ProductID) + } + } + + if purchasedSections == nil { + purchasedSections = []string{} + } + + // 构建返回的用户对象 + responseUser := map[string]interface{}{ + "id": user.ID, + "openId": getStringValue(user.OpenID), + "nickname": getStringValue(user.Nickname), + "avatar": getStringValue(user.Avatar), + "phone": getStringValue(user.Phone), + "wechatId": getStringValue(user.WechatID), + "referralCode": getStringValue(user.ReferralCode), + "hasFullBook": getBoolValue(user.HasFullBook), + "purchasedSections": purchasedSections, + "earnings": getFloatValue(user.Earnings), + "pendingEarnings": getFloatValue(user.PendingEarnings), + "referralCount": getIntValue(user.ReferralCount), + "createdAt": user.CreatedAt, + } + + // 生成 token + token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix()) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": map[string]interface{}{ + "openId": openID, + "user": responseUser, + "token": token, + }, + "isNewUser": isNewUser, + }) +} + +// 辅助函数 +func getStringValue(ptr *string) string { + if ptr == nil { + return "" + } + return *ptr +} + +func getBoolValue(ptr *bool) bool { + if ptr == nil { + return false + } + return *ptr +} + +func getFloatValue(ptr *float64) float64 { + if ptr == nil { + return 0.0 + } + return *ptr +} + +func getIntValue(ptr *int) int { + if ptr == nil { + return 0 + } + return *ptr +} + +// MiniprogramPay GET/POST /api/miniprogram/pay +func MiniprogramPay(c *gin.Context) { + if c.Request.Method == "POST" { + miniprogramPayPost(c) + } else { + miniprogramPayGet(c) + } +} + +// POST - 创建小程序支付订单 +func miniprogramPayPost(c *gin.Context) { + var req struct { + OpenID string `json:"openId" binding:"required"` + ProductType string `json:"productType" binding:"required"` + ProductID string `json:"productId"` + Amount float64 `json:"amount" binding:"required"` + Description string `json:"description"` + UserID string `json:"userId"` + ReferralCode string `json:"referralCode"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少openId参数,请先登录"}) + return + } + + if req.Amount <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "支付金额无效"}) + return + } + + db := database.DB() + + // 查询用户的有效推荐人(先查 binding,再查 referralCode) + var referrerID *string + if req.UserID != "" { + var binding struct { + ReferrerID string `gorm:"column:referrer_id"` + } + err := db.Raw(` + SELECT referrer_id + FROM referral_bindings + WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW() + ORDER BY binding_date DESC + LIMIT 1 + `, req.UserID).Scan(&binding).Error + if err == nil && binding.ReferrerID != "" { + referrerID = &binding.ReferrerID + } + } + if referrerID == nil && req.ReferralCode != "" { + var refUser model.User + if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil { + referrerID = &refUser.ID + } + } + + // 有推荐人时应用好友优惠(无论是 binding 还是 referralCode) + finalAmount := req.Amount + if referrerID != nil { + var cfg model.SystemConfig + if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil { + var config map[string]interface{} + if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil { + if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 { + discountRate := userDiscount / 100 + finalAmount = req.Amount * (1 - discountRate) + if finalAmount < 0.01 { + finalAmount = 0.01 + } + } + } + } + } + + // 生成订单号 + orderSn := wechat.GenerateOrderSn() + totalFee := int(finalAmount * 100) // 转为分 + description := req.Description + if description == "" { + if req.ProductType == "fullbook" { + description = "《一场Soul的创业实验》全书" + } else if req.ProductType == "vip" { + description = "卡若创业派对VIP年度会员(365天)" + } else if req.ProductType == "match" { + description = "购买匹配次数" + } else { + description = fmt.Sprintf("章节购买-%s", req.ProductID) + } + } + + // 获取客户端 IP + clientIP := c.ClientIP() + if clientIP == "" { + clientIP = "127.0.0.1" + } + + // 插入订单到数据库 + userID := req.UserID + if userID == "" { + userID = req.OpenID + } + + productID := req.ProductID + if productID == "" { + switch req.ProductType { + case "vip": + productID = "vip_annual" + case "match": + productID = "match" + default: + productID = "fullbook" + } + } + + status := "created" + order := model.Order{ + ID: orderSn, + OrderSN: orderSn, + UserID: userID, + OpenID: req.OpenID, + ProductType: req.ProductType, + ProductID: &productID, + Amount: finalAmount, + Description: &description, + Status: &status, + ReferrerID: referrerID, + ReferralCode: &req.ReferralCode, + } + + if err := db.Create(&order).Error; err != nil { + // 订单创建失败,但不中断支付流程 + fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err) + } + + attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID) + ctx := c.Request.Context() + prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, description, attach) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("微信支付请求失败: %v", err)}) + return + } + payParams, err := wechat.GetJSAPIPayParams(prepayID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("生成支付参数失败: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": map[string]interface{}{ + "orderSn": orderSn, + "prepayId": prepayID, + "payParams": payParams, + }, + }) +} + +// GET - 查询订单状态(并主动同步:若微信已支付但本地未标记,则更新本地订单,便于配额即时生效) +func miniprogramPayGet(c *gin.Context) { + orderSn := c.Query("orderSn") + if orderSn == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少订单号"}) + return + } + + ctx := c.Request.Context() + tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, orderSn) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": map[string]interface{}{ + "status": "unknown", + "orderSn": orderSn, + }, + }) + return + } + + status := "paying" + switch tradeState { + case "SUCCESS": + status = "paid" + // 若微信已支付,主动同步到本地 orders(不等 PayNotify),便于购买次数即时生效 + db := database.DB() + var order model.Order + if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err == nil && order.Status != nil && *order.Status != "paid" { + now := time.Now() + db.Model(&order).Updates(map[string]interface{}{ + "status": "paid", + "transaction_id": transactionID, + "pay_time": now, + }) + orderPollLogf("主动同步订单已支付: %s", orderSn) + } + case "CLOSED", "REVOKED", "PAYERROR": + status = "failed" + case "REFUND": + status = "refunded" + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": map[string]interface{}{ + "status": status, + "orderSn": orderSn, + "transactionId": transactionID, + "totalFee": totalFee, + }, + }) +} + +// MiniprogramPayNotify POST /api/miniprogram/pay/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 new file mode 100644 index 00000000..fb47a71c --- /dev/null +++ b/soul-api/internal/handler/orders.go @@ -0,0 +1,267 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + "soul-api/internal/wechat" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// OrdersList GET /api/orders(带用户昵称/头像/手机号,分销佣金按配置比例计算;支持分页 page、pageSize,筛选 status,搜索 search) +func OrdersList(c *gin.Context) { + db := database.DB() + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + statusFilter := c.Query("status") + search := strings.TrimSpace(c.Query("search")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + // 预加载 referral_config,避免订单循环内 N+1 查询 + var refCfgRow model.SystemConfig + refCfg := (*model.SystemConfig)(nil) + if err := db.Where("config_key = ?", "referral_config").First(&refCfgRow).Error; err == nil { + refCfg = &refCfgRow + } + + // 构建带筛选的查询(count 与 list 共用条件) + applyOrdersFilter := func(q *gorm.DB) *gorm.DB { + if statusFilter != "" && statusFilter != "all" { + if statusFilter == "completed" { + q = q.Where("status IN ?", []string{"paid", "completed"}) + } else { + q = q.Where("status = ?", statusFilter) + } + } + if search != "" { + pattern := "%" + search + "%" + q = q.Where("order_sn LIKE ? OR id LIKE ? OR user_id IN (SELECT id FROM users WHERE COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?)", + pattern, pattern, pattern, pattern, pattern) + } + return q + } + + var total int64 + var totalRevenue, todayRevenue float64 + var orders []model.Order + var ordersErr error + var wg sync.WaitGroup + + // 并行:count、营收统计、订单列表 + wg.Add(3) + go func() { + defer wg.Done() + applyOrdersFilter(db.Model(&model.Order{})).Count(&total) + }() + go func() { + defer wg.Done() + db.Model(&model.Order{}).Select("COALESCE(SUM(amount), 0)"). + Where("status IN ?", []string{"paid", "completed"}).Scan(&totalRevenue) + todayStart := time.Now().Truncate(24 * time.Hour) + todayEnd := todayStart.Add(24 * time.Hour) + db.Model(&model.Order{}).Select("COALESCE(SUM(amount), 0)"). + Where("status IN ? AND created_at >= ? AND created_at < ?", []string{"paid", "completed"}, todayStart, todayEnd). + Scan(&todayRevenue) + }() + go func() { + defer wg.Done() + query := applyOrdersFilter(db.Model(&model.Order{})) + ordersErr = query.Order("created_at DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&orders).Error + }() + wg.Wait() + + if ordersErr != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": ordersErr.Error(), "orders": []interface{}{}, "total": 0}) + return + } + totalPages := int(total) / pageSize + if int(total)%pageSize > 0 { + totalPages++ + } + if len(orders) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": true, "orders": []interface{}{}, + "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, + }) + return + } + + // 收集订单中的 user_id、referrer_id,查用户信息 + userIDs := make(map[string]bool) + for _, o := range orders { + if o.UserID != "" { + userIDs[o.UserID] = true + } + if o.ReferrerID != nil && *o.ReferrerID != "" { + userIDs[*o.ReferrerID] = true + } + } + ids := make([]string, 0, len(userIDs)) + for id := range userIDs { + ids = append(ids, id) + } + var users []model.User + if len(ids) > 0 { + db.Where("id IN ?", ids).Find(&users) + } + userMap := make(map[string]*model.User) + for i := range users { + userMap[users[i].ID] = &users[i] + } + + getStr := func(s *string) string { + if s == nil || *s == "" { + return "" + } + return *s + } + + out := make([]gin.H, 0, len(orders)) + for _, o := range orders { + // 序列化订单为基础字段 + b, _ := json.Marshal(o) + var m map[string]interface{} + _ = json.Unmarshal(b, &m) + // 用户信息 + if u := userMap[o.UserID]; u != nil { + m["userNickname"] = getStr(u.Nickname) + m["userPhone"] = getStr(u.Phone) + m["userAvatar"] = getStr(u.Avatar) + } else { + m["userNickname"] = "" + m["userPhone"] = "" + m["userAvatar"] = "" + } + // 推荐人信息 + if o.ReferrerID != nil && *o.ReferrerID != "" { + if u := userMap[*o.ReferrerID]; u != nil { + m["referrerNickname"] = getStr(u.Nickname) + m["referrerCode"] = getStr(u.ReferralCode) + } + } + // 分销佣金:仅对已支付且存在推荐人的订单,按 computeOrderCommission(会员 20%/10%,内容 90%) + status := getStr(o.Status) + if status == "paid" && o.ReferrerID != nil && *o.ReferrerID != "" { + var refUser *model.User + if u := userMap[*o.ReferrerID]; u != nil { + refUser = u + } + m["referrerEarnings"] = computeOrderCommission(db, &o, refUser, refCfg) + } else { + m["referrerEarnings"] = nil + } + out = append(out, m) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, "orders": out, + "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages, + "totalRevenue": totalRevenue, "todayRevenue": todayRevenue, + }) +} + +// MiniprogramOrders GET /api/miniprogram/orders 小程序-当前用户订单列表(按 userId 过滤,返回 data) +func MiniprogramOrders(c *gin.Context) { + userID := c.Query("userId") + if userID == "" { + c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) + return + } + db := database.DB() + var orders []model.Order + if err := db.Where("user_id = ?", userID).Order("created_at DESC").Find(&orders).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) + return + } + out := make([]gin.H, 0, len(orders)) + for _, o := range orders { + desc := "" + if o.Description != nil { + desc = *o.Description + } + productID := "" + if o.ProductID != nil { + productID = *o.ProductID + } + status := "created" + if o.Status != nil { + status = *o.Status + } + out = append(out, gin.H{ + "id": o.ID, "order_sn": o.OrderSN, "user_id": o.UserID, + "product_id": productID, "product_type": o.ProductType, + "product_name": desc, "section_id": productID, + "amount": o.Amount, "status": status, + "created_at": o.CreatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": out}) +} + +// AdminOrderRefund PUT /api/admin/orders/refund 管理端-订单退款(仅支持已支付订单,调用微信支付退款) +func AdminOrderRefund(c *gin.Context) { + var req struct { + OrderSn string `json:"orderSn" binding:"required"` + Reason string `json:"reason"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少订单号"}) + return + } + db := database.DB() + var order model.Order + if err := db.Where("order_sn = ?", req.OrderSn).First(&order).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "订单不存在"}) + return + } + status := "" + if order.Status != nil { + status = *order.Status + } + if status != "paid" && status != "completed" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅支持已支付订单退款"}) + return + } + transactionID := "" + if order.TransactionID != nil { + transactionID = *order.TransactionID + } + if transactionID == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "订单缺少微信支付单号,无法退款"}) + return + } + totalCents := int(order.Amount * 100) + if totalCents < 1 { + totalCents = 1 + } + if err := wechat.RefundOrder(context.Background(), order.OrderSN, transactionID, totalCents, req.Reason); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "微信退款失败: " + err.Error()}) + return + } + refunded := "refunded" + updates := map[string]interface{}{"status": refunded} + if req.Reason != "" { + updates["refund_reason"] = req.Reason + } + if err := db.Model(&order).Updates(updates).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "退款成功但更新订单状态失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "退款成功"}) +} diff --git a/soul-api/internal/handler/payment.go b/soul-api/internal/handler/payment.go new file mode 100644 index 00000000..c49453d0 --- /dev/null +++ b/soul-api/internal/handler/payment.go @@ -0,0 +1,141 @@ +package handler + +import ( + "fmt" + "io" + "net/http" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + "soul-api/internal/wechat" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// PaymentAlipayNotify POST /api/payment/alipay/notify +func PaymentAlipayNotify(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// PaymentCallback POST /api/payment/callback +func PaymentCallback(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// PaymentCreateOrder POST /api/payment/create-order +func PaymentCreateOrder(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// PaymentMethods GET /api/payment/methods +func PaymentMethods(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) +} + +// PaymentQuery GET /api/payment/query +func PaymentQuery(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// PaymentStatusOrderSn GET /api/payment/status/:orderSn +func PaymentStatusOrderSn(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// PaymentVerify POST /api/payment/verify +func PaymentVerify(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// PaymentWechatNotify POST /api/payment/wechat/notify +func PaymentWechatNotify(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// logWechatTransferCallback 写入微信转账回调日志到 wechat_callback_logs +func logWechatTransferCallback(db *gorm.DB, outBillNo, transferBillNo, state, failReason, outBatchNo, handlerResult, handlerError string) { + entry := model.WechatCallbackLog{ + CallbackType: "transfer", + OutDetailNo: outBillNo, + TransferBillNo: transferBillNo, + State: state, + FailReason: failReason, + OutBatchNo: outBatchNo, + HandlerResult: handlerResult, + HandlerError: handlerError, + } + if err := db.Create(&entry).Error; err != nil { + fmt.Printf("[TransferNotify] 写回调日志失败: %v\n", err) + } +} + +// PaymentWechatTransferNotify POST /api/payment/wechat/transfer/notify +// 使用 PowerWeChat 验签、解密密文后更新提现状态,并返回微信要求的应答;同时写入 wechat_callback_logs +// GET 同一路径时仅返回 200 与说明(便于探活或浏览器访问,不写库) +func PaymentWechatTransferNotify(c *gin.Context) { + if c.Request.Method == "GET" { + c.String(http.StatusOK, "转账结果通知请使用 POST") + return + } + fmt.Printf("[TransferNotify] 收到微信转账回调请求 method=%s path=%s\n", c.Request.Method, c.Request.URL.Path) + resp, err := wechat.HandleTransferNotify(c.Request, func(outBillNo, transferBillNo, state, failReason string) error { + fmt.Printf("[TransferNotify] 解密成功: out_bill_no=%s, transfer_bill_no=%s, state=%s\n", outBillNo, transferBillNo, state) + db := database.DB() + var w model.Withdrawal + if err := db.Where("detail_no = ?", outBillNo).First(&w).Error; err != nil { + fmt.Printf("[TransferNotify] 未找到 detail_no=%s 的提现记录: %v\n", outBillNo, err) + logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, "", "success", "未找到提现记录") + return nil + } + outBatchNo := "" + if w.BatchNo != nil { + outBatchNo = *w.BatchNo + } + cur := "" + if w.Status != nil { + cur = *w.Status + } + if cur != "processing" && cur != "pending_confirm" { + logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "success", "状态已变更跳过") + return nil + } + now := time.Now() + up := map[string]interface{}{"processed_at": now} + switch state { + case "SUCCESS": + up["status"] = "success" + case "FAIL", "CANCELLED": + up["status"] = "failed" + if failReason != "" { + up["fail_reason"] = failReason + } + default: + logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "success", "") + return nil + } + if err := db.Model(&w).Updates(up).Error; err != nil { + logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "fail", err.Error()) + return fmt.Errorf("更新提现状态失败: %w", err) + } + fmt.Printf("[TransferNotify] 已更新提现 id=%s -> status=%s\n", w.ID, up["status"]) + logWechatTransferCallback(db, outBillNo, transferBillNo, state, failReason, outBatchNo, "success", "") + return nil + }) + if err != nil { + fmt.Printf("[TransferNotify] 验签/解密/处理失败: %v\n", err) + db := database.DB() + logWechatTransferCallback(db, "", "", "", "", "", "fail", err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": err.Error()}) + return + } + defer resp.Body.Close() + for k, v := range resp.Header { + if len(v) > 0 { + c.Header(k, v[0]) + } + } + c.Status(resp.StatusCode) + io.Copy(c.Writer, resp.Body) +} diff --git a/soul-api/internal/handler/referral.go b/soul-api/internal/handler/referral.go new file mode 100644 index 00000000..38adb087 --- /dev/null +++ b/soul-api/internal/handler/referral.go @@ -0,0 +1,525 @@ +package handler + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const defaultBindingDays = 30 + +// ReferralBind POST /api/referral/bind 推荐码绑定(新绑定/续期/切换) +func ReferralBind(c *gin.Context) { + var req struct { + UserID string `json:"userId"` + ReferralCode string `json:"referralCode" binding:"required"` + OpenID string `json:"openId"` + Source string `json:"source"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"}) + return + } + effectiveUserID := req.UserID + if effectiveUserID == "" && req.OpenID != "" { + effectiveUserID = "user_" + req.OpenID[len(req.OpenID)-8:] + } + if effectiveUserID == "" || req.ReferralCode == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID和推荐码不能为空"}) + return + } + + db := database.DB() + bindingDays := defaultBindingDays + var cfg model.SystemConfig + if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil { + var config map[string]interface{} + if _ = json.Unmarshal(cfg.ConfigValue, &config); config["bindingDays"] != nil { + if v, ok := config["bindingDays"].(float64); ok { + bindingDays = int(v) + } + } + } + + var referrer model.User + if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"}) + return + } + if referrer.ID == effectiveUserID { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能使用自己的推荐码"}) + return + } + + var user model.User + if err := db.Where("id = ?", effectiveUserID).First(&user).Error; err != nil { + if req.OpenID != "" { + if err := db.Where("open_id = ?", req.OpenID).First(&user).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"}) + return + } + } else { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户不存在"}) + return + } + } + + expiryDate := time.Now().AddDate(0, 0, bindingDays) + var existing model.ReferralBinding + err := db.Where("referee_id = ? AND status = ?", user.ID, "active").Order("binding_date DESC").First(&existing).Error + action := "new" + var oldReferrerID interface{} + + if err == nil { + if existing.ReferrerID == referrer.ID { + action = "renew" + db.Model(&existing).Updates(map[string]interface{}{ + "expiry_date": expiryDate, + "binding_date": time.Now(), + }) + } else { + action = "switch" + oldReferrerID = existing.ReferrerID + db.Model(&existing).Update("status", "cancelled") + bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6)) + db.Create(&model.ReferralBinding{ + ID: bindID, + ReferrerID: referrer.ID, + RefereeID: user.ID, + ReferralCode: req.ReferralCode, + Status: refString("active"), + ExpiryDate: expiryDate, + BindingDate: time.Now(), + }) + } + } else { + bindID := fmt.Sprintf("bind_%d_%s", time.Now().UnixNano(), randomStr(6)) + db.Create(&model.ReferralBinding{ + ID: bindID, + ReferrerID: referrer.ID, + RefereeID: user.ID, + ReferralCode: req.ReferralCode, + Status: refString("active"), + ExpiryDate: expiryDate, + BindingDate: time.Now(), + }) + db.Model(&model.User{}).Where("id = ?", referrer.ID).UpdateColumn("referral_count", gorm.Expr("COALESCE(referral_count, 0) + 1")) + } + + msg := "绑定成功" + if action == "renew" { + msg = "绑定已续期" + } else if action == "switch" { + msg = "推荐人已切换" + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": msg, + "data": gin.H{ + "action": action, + "referrer": gin.H{"id": referrer.ID, "nickname": getStringValue(referrer.Nickname)}, + "expiryDate": expiryDate, + "bindingDays": bindingDays, + "oldReferrerId": oldReferrerID, + }, + }) +} + +func refString(s string) *string { return &s } +func randomStr(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, n) + for i := range b { + b[i] = letters[time.Now().UnixNano()%int64(len(letters))] + } + return string(b) +} + +// ReferralData GET /api/referral/data 获取分销数据统计 +func ReferralData(c *gin.Context) { + userId := c.Query("userId") + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"}) + return + } + + db := database.DB() + + // 获取分销配置(与 soul-admin 推广设置一致) + distributorShare := 0.9 + minWithdrawAmount := 10.0 + bindingDays := defaultBindingDays + userDiscount := 5 + withdrawFee := 5.0 + + var cfg model.SystemConfig + if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil { + var config map[string]interface{} + if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil { + if share, ok := config["distributorShare"].(float64); ok { + distributorShare = share / 100 + } + if minAmount, ok := config["minWithdrawAmount"].(float64); ok { + minWithdrawAmount = minAmount + } + if days, ok := config["bindingDays"].(float64); ok && days > 0 { + bindingDays = int(days) + } + if discount, ok := config["userDiscount"].(float64); ok { + userDiscount = int(discount) + } + if fee, ok := config["withdrawFee"].(float64); ok { + withdrawFee = fee + } + } + } + + // 1. 查询用户基本信息 + var user model.User + if err := db.Where("id = ?", userId).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) + return + } + + // 2. 绑定统计 + var totalBindings int64 + db.Model(&model.ReferralBinding{}).Where("referrer_id = ?", userId).Count(&totalBindings) + + var activeBindings int64 + db.Model(&model.ReferralBinding{}).Where( + "referrer_id = ? AND status = 'active' AND expiry_date > ?", + userId, time.Now(), + ).Count(&activeBindings) + + var convertedBindings int64 + db.Model(&model.ReferralBinding{}).Where( + "referrer_id = ? AND status = 'active' AND purchase_count > 0", + userId, + ).Count(&convertedBindings) + + var expiredBindings int64 + db.Model(&model.ReferralBinding{}).Where( + "referrer_id = ? AND (status IN ('expired', 'cancelled') OR (status = 'active' AND expiry_date <= ?))", + userId, time.Now(), + ).Count(&expiredBindings) + + // 3. 付款统计 + var paidOrders []model.Order + db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders) + + totalAmount := 0.0 + totalCommission := 0.0 + uniqueUsers := make(map[string]bool) + for i := range paidOrders { + totalAmount += paidOrders[i].Amount + totalCommission += computeOrderCommission(db, &paidOrders[i], nil) + uniqueUsers[paidOrders[i].UserID] = true + } + uniquePaidCount := len(uniqueUsers) + + // 4. 访问统计 + totalVisits := int(totalBindings) + var visitCount int64 + if err := db.Model(&model.ReferralVisit{}). + Select("COUNT(DISTINCT visitor_id) as count"). + Where("referrer_id = ?", userId). + Count(&visitCount).Error; err == nil { + totalVisits = int(visitCount) + } + + // 5. 提现统计(与小程序可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核) + // 待审核 = pending + processing + pending_confirm,与 /api/withdraw/pending-confirm 口径一致 + var pendingWithdraw struct{ Total float64 } + db.Model(&model.Withdrawal{}). + Select("COALESCE(SUM(amount), 0) as total"). + Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}). + Scan(&pendingWithdraw) + + var successWithdraw struct{ Total float64 } + db.Model(&model.Withdrawal{}). + Select("COALESCE(SUM(amount), 0) as total"). + Where("user_id = ? AND status = ?", userId, "success"). + Scan(&successWithdraw) + + pendingWithdrawAmount := pendingWithdraw.Total + withdrawnFromTable := successWithdraw.Total + + // 6. 获取活跃绑定用户列表 + var activeBindingsList []model.ReferralBinding + db.Where("referrer_id = ? AND status = 'active' AND expiry_date > ?", userId, time.Now()). + Order("binding_date DESC"). + Limit(20). + Find(&activeBindingsList) + + activeUsers := []gin.H{} + for _, b := range activeBindingsList { + var referee model.User + db.Where("id = ?", b.RefereeID).First(&referee) + + daysRemaining := int(time.Until(b.ExpiryDate).Hours() / 24) + if daysRemaining < 0 { + daysRemaining = 0 + } + + activeUsers = append(activeUsers, gin.H{ + "id": b.RefereeID, + "nickname": getStringValue(referee.Nickname), + "avatar": getStringValue(referee.Avatar), + "daysRemaining": daysRemaining, + "hasFullBook": getBoolValue(referee.HasFullBook), + "bindingDate": b.BindingDate, + "status": "active", + }) + } + + // 7. 获取已转化用户列表 + var convertedBindingsList []model.ReferralBinding + db.Where("referrer_id = ? AND status = 'active' AND purchase_count > 0", userId). + Order("last_purchase_date DESC"). + Limit(20). + Find(&convertedBindingsList) + + convertedUsers := []gin.H{} + for _, b := range convertedBindingsList { + var referee model.User + db.Where("id = ?", b.RefereeID).First(&referee) + + commission := 0.0 + if b.TotalCommission != nil { + commission = *b.TotalCommission + } + orderAmount := commission / distributorShare + + convertedUsers = append(convertedUsers, gin.H{ + "id": b.RefereeID, + "nickname": getStringValue(referee.Nickname), + "avatar": getStringValue(referee.Avatar), + "commission": commission, + "orderAmount": orderAmount, + "purchaseCount": getIntValue(b.PurchaseCount), + "conversionDate": b.LastPurchaseDate, + "status": "converted", + }) + } + + // 8. 获取已过期用户列表 + var expiredBindingsList []model.ReferralBinding + db.Where( + "referrer_id = ? AND (status = 'expired' OR (status = 'active' AND expiry_date <= ?))", + userId, time.Now(), + ).Order("expiry_date DESC").Limit(20).Find(&expiredBindingsList) + + expiredUsers := []gin.H{} + for _, b := range expiredBindingsList { + var referee model.User + db.Where("id = ?", b.RefereeID).First(&referee) + + expiredUsers = append(expiredUsers, gin.H{ + "id": b.RefereeID, + "nickname": getStringValue(referee.Nickname), + "avatar": getStringValue(referee.Avatar), + "bindingDate": b.BindingDate, + "expiryDate": b.ExpiryDate, + "status": "expired", + }) + } + + // 9. 获取收益明细 + var earningsDetailsList []model.Order + db.Where("referrer_id = ? AND status = 'paid'", userId). + Order("pay_time DESC"). + Limit(20). + Find(&earningsDetailsList) + + earningsDetails := []gin.H{} + for i := range earningsDetailsList { + e := &earningsDetailsList[i] + var buyer model.User + db.Where("id = ?", e.UserID).First(&buyer) + + commission := computeOrderCommission(db, e, nil) + earningsDetails = append(earningsDetails, gin.H{ + "id": e.ID, + "orderSn": e.OrderSN, + "amount": e.Amount, + "commission": commission, + "productType": e.ProductType, + "productId": getStringValue(e.ProductID), + "description": getStringValue(e.Description), + "buyerNickname": getStringValue(buyer.Nickname), + "buyerAvatar": getStringValue(buyer.Avatar), + "payTime": e.PayTime, + }) + } + + // 计算收益(totalCommission 已按订单逐条计算) + estimatedEarnings := totalCommission + availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount + if availableEarnings < 0 { + availableEarnings = 0 + } + + // 计算即将过期用户数(7天内) + sevenDaysLater := time.Now().Add(7 * 24 * time.Hour) + expiringCount := 0 + for _, b := range activeBindingsList { + if b.ExpiryDate.After(time.Now()) && b.ExpiryDate.Before(sevenDaysLater) { + expiringCount++ + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + // 核心可见数据 + "bindingCount": activeBindings, + "visitCount": totalVisits, + "paidCount": uniquePaidCount, + "expiredCount": expiredBindings, + + // 收益数据 + "totalCommission": round(totalCommission, 2), + "availableEarnings": round(availableEarnings, 2), + "pendingWithdrawAmount": round(pendingWithdrawAmount, 2), + "withdrawnEarnings": withdrawnFromTable, + "earnings": getFloatValue(user.Earnings), + "pendingEarnings": getFloatValue(user.PendingEarnings), + "estimatedEarnings": round(estimatedEarnings, 2), + "shareRate": int(distributorShare * 100), + "minWithdrawAmount": minWithdrawAmount, + "bindingDays": bindingDays, + "userDiscount": userDiscount, + "withdrawFee": withdrawFee, + + // 推荐码 + "referralCode": getStringValue(user.ReferralCode), + "referralCount": getIntValue(user.ReferralCount), + + // 详细统计 + "stats": gin.H{ + "totalBindings": totalBindings, + "activeBindings": activeBindings, + "convertedBindings": convertedBindings, + "expiredBindings": expiredBindings, + "expiringCount": expiringCount, + "totalPaymentAmount": totalAmount, + }, + + // 用户列表 + "activeUsers": activeUsers, + "convertedUsers": convertedUsers, + "expiredUsers": expiredUsers, + + // 收益明细 + "earningsDetails": earningsDetails, + }, + }) +} + +// round 四舍五入保留小数 +func round(val float64, precision int) float64 { + ratio := math.Pow(10, float64(precision)) + return math.Round(val*ratio) / ratio +} + +// MyEarnings GET /api/miniprogram/earnings 仅返回「我的收益」卡片所需数据(累计、可提现、推荐人数),用于我的页展示与刷新 +func MyEarnings(c *gin.Context) { + userId := c.Query("userId") + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"}) + return + } + db := database.DB() + var user model.User + if err := db.Where("id = ?", userId).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) + return + } + var paidOrders []model.Order + db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&paidOrders) + totalCommission := 0.0 + for i := range paidOrders { + totalCommission += computeOrderCommission(db, &paidOrders[i], nil) + } + var pendingWithdraw struct{ Total float64 } + db.Model(&model.Withdrawal{}). + Select("COALESCE(SUM(amount), 0) as total"). + Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}). + Scan(&pendingWithdraw) + var successWithdraw struct{ Total float64 } + db.Model(&model.Withdrawal{}). + Select("COALESCE(SUM(amount), 0) as total"). + Where("user_id = ? AND status = ?", userId, "success"). + Scan(&successWithdraw) + pendingWithdrawAmount := pendingWithdraw.Total + withdrawnFromTable := successWithdraw.Total + availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount + if availableEarnings < 0 { + availableEarnings = 0 + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "totalCommission": round(totalCommission, 2), + "availableEarnings": round(availableEarnings, 2), + "referralCount": getIntValue(user.ReferralCount), + }, + }) +} + +// ReferralVisit POST /api/referral/visit 记录推荐访问(不需登录) +func ReferralVisit(c *gin.Context) { + var req struct { + ReferralCode string `json:"referralCode" binding:"required"` + VisitorOpenID string `json:"visitorOpenId"` + VisitorID string `json:"visitorId"` + Source string `json:"source"` + Page string `json:"page"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码不能为空"}) + return + } + db := database.DB() + var referrer model.User + if err := db.Where("referral_code = ?", req.ReferralCode).First(&referrer).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"}) + return + } + source := req.Source + if source == "" { + source = "miniprogram" + } + visitorID := req.VisitorID + if visitorID == "" { + visitorID = "" + } + vOpenID := req.VisitorOpenID + vPage := req.Page + err := db.Create(&model.ReferralVisit{ + ReferrerID: referrer.ID, + VisitorID: strPtrOrNil(visitorID), + VisitorOpenID: strPtrOrNil(vOpenID), + Source: strPtrOrNil(source), + Page: strPtrOrNil(vPage), + }).Error + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "已处理"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "访问已记录"}) +} +func strPtrOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/soul-api/internal/handler/referral_commission.go b/soul-api/internal/handler/referral_commission.go new file mode 100644 index 00000000..5011da4a --- /dev/null +++ b/soul-api/internal/handler/referral_commission.go @@ -0,0 +1,79 @@ +package handler + +import ( + "encoding/json" + "time" + + "soul-api/internal/model" + + "gorm.io/gorm" +) + +// computeOrderCommission 按订单计算应付给推广者的佣金 +// 会员订单:推广者会员 20%、非会员 10%;内容订单:90%(好友优惠 5% 仅针对内容) +// order: 已支付订单,需有 product_type、amount、referrer_id +// referrerUser: 推广者用户信息,用于判断 is_vip(可为 nil,会查库) +// preloadConfig: 可选,预加载的 referral_config,避免 N+1 查询 +func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model.User, preloadConfig ...*model.SystemConfig) float64 { + if order == nil || order.ReferrerID == nil || *order.ReferrerID == "" { + return 0 + } + // 读取推广配置 + distributorShare := 0.9 + userDiscount := 0.0 + vipOrderShareVip := 20.0 + vipOrderShareNonVip := 10.0 + var cfg *model.SystemConfig + if len(preloadConfig) > 0 && preloadConfig[0] != nil { + cfg = preloadConfig[0] + } else if row, err := (func() (*model.SystemConfig, error) { + var r model.SystemConfig + e := db.Where("config_key = ?", "referral_config").First(&r).Error + return &r, e + })(); err == nil { + cfg = row + } + if cfg != nil { + var config map[string]interface{} + if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil { + if share, ok := config["distributorShare"].(float64); ok { + distributorShare = share / 100 + } + if disc, ok := config["userDiscount"].(float64); ok { + userDiscount = disc / 100 + } + if v, ok := config["vipOrderShareVip"].(float64); ok { + vipOrderShareVip = v / 100 + } + if v, ok := config["vipOrderShareNonVip"].(float64); ok { + vipOrderShareNonVip = v / 100 + } + } + } + // 会员订单:无好友优惠,按推广者是否会员分 20%/10% + if order.ProductType == "vip" { + base := order.Amount + var referrer model.User + if referrerUser != nil { + referrer = *referrerUser + } else if err := db.Where("id = ?", *order.ReferrerID).First(&referrer).Error; err != nil { + return 0 + } + isVip := referrer.IsVip != nil && *referrer.IsVip + if referrer.VipExpireDate != nil && referrer.VipExpireDate.Before(time.Now()) { + isVip = false + } + if isVip { + return base * vipOrderShareVip + } + return base * vipOrderShareNonVip + } + // 内容订单:若有推荐人且 userDiscount>0,反推原价;否则按实付 + commissionBase := order.Amount + if userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || (order.ReferralCode != nil && *order.ReferralCode != "")) { + if (1 - userDiscount) > 0 { + commissionBase = order.Amount / (1 - userDiscount) + } + } + return commissionBase * distributorShare +} diff --git a/soul-api/internal/handler/search.go b/soul-api/internal/handler/search.go new file mode 100644 index 00000000..f8dd7b83 --- /dev/null +++ b/soul-api/internal/handler/search.go @@ -0,0 +1,81 @@ +package handler + +import ( + "net/http" + "strings" + "unicode/utf8" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" +) + +// escapeLike 转义 LIKE 中的 % _ \,防止注入与通配符滥用 +func escapeLike(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "%", "\\%") + s = strings.ReplaceAll(s, "_", "\\_") + return s +} + +// SearchGet GET /api/search?q= 从 chapters 表搜索(GORM,参数化) +func SearchGet(c *gin.Context) { + q := strings.TrimSpace(c.Query("q")) + if q == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入搜索关键词"}) + return + } + pattern := "%" + escapeLike(q) + "%" + var list []model.Chapter + err := database.DB().Model(&model.Chapter{}). + Where("section_title LIKE ? OR content LIKE ?", pattern, pattern). + Order("sort_order ASC, id ASC"). + Limit(50). + Find(&list).Error + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}}) + return + } + lowerQ := strings.ToLower(q) + results := make([]gin.H, 0, len(list)) + for _, ch := range list { + matchType := "content" + score := 5 + if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) { + matchType = "title" + score = 10 + } + snippet := "" + pos := strings.Index(strings.ToLower(ch.Content), lowerQ) + if pos >= 0 && len(ch.Content) > 0 { + start := pos - 50 + if start < 0 { + start = 0 + } + end := pos + utf8.RuneCountInString(q) + 50 + if end > len(ch.Content) { + end = len(ch.Content) + } + snippet = ch.Content[start:end] + if start > 0 { + snippet = "..." + snippet + } + if end < len(ch.Content) { + snippet = snippet + "..." + } + } + price := 1.0 + if ch.Price != nil { + price = *ch.Price + } + results = append(results, gin.H{ + "id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle, + "price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet, + }) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{"keyword": q, "total": len(results), "results": results}, + }) +} diff --git a/soul-api/internal/handler/sync.go b/soul-api/internal/handler/sync.go new file mode 100644 index 00000000..a5bf15eb --- /dev/null +++ b/soul-api/internal/handler/sync.go @@ -0,0 +1,22 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// SyncGet GET /api/sync +func SyncGet(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// SyncPost POST /api/sync +func SyncPost(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// SyncPut PUT /api/sync +func SyncPut(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/soul-api/internal/handler/upload.go b/soul-api/internal/handler/upload.go new file mode 100644 index 00000000..b8ff1fa7 --- /dev/null +++ b/soul-api/internal/handler/upload.go @@ -0,0 +1,81 @@ +package handler + +import ( + "fmt" + "math/rand" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const uploadDir = "uploads" +const maxUploadBytes = 5 * 1024 * 1024 // 5MB +var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true} + +// UploadPost POST /api/upload 上传图片(表单 file) +func UploadPost(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的文件"}) + return + } + if file.Size > maxUploadBytes { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "文件大小不能超过5MB"}) + return + } + ct := file.Header.Get("Content-Type") + if !allowedTypes[ct] && !strings.HasPrefix(ct, "image/") { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持图片格式"}) + return + } + ext := filepath.Ext(file.Filename) + if ext == "" { + ext = ".jpg" + } + folder := c.PostForm("folder") + if folder == "" { + folder = "avatars" + } + dir := filepath.Join(uploadDir, folder) + _ = os.MkdirAll(dir, 0755) + name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext) + dst := filepath.Join(dir, name) + if err := c.SaveUploadedFile(file, dst); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"}) + return + } + url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name)) + c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}}) +} + +func randomStrUpload(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +// UploadDelete DELETE /api/upload +func UploadDelete(c *gin.Context) { + path := c.Query("path") + if path == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请指定 path"}) + return + } + if !strings.HasPrefix(path, "/uploads/") && !strings.HasPrefix(path, "uploads/") { + c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"}) + return + } + fullPath := strings.TrimPrefix(path, "/") + if err := os.Remove(fullPath); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "文件不存在或删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"}) +} diff --git a/soul-api/internal/handler/user.go b/soul-api/internal/handler/user.go new file mode 100644 index 00000000..8410f4d9 --- /dev/null +++ b/soul-api/internal/handler/user.go @@ -0,0 +1,609 @@ +package handler + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" +) + +// UserAddressesGet GET /api/user/addresses?userId= +func UserAddressesGet(c *gin.Context) { + userId := c.Query("userId") + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"}) + return + } + var list []model.UserAddress + if err := database.DB().Where("user_id = ?", userId).Order("is_default DESC, updated_at DESC").Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "list": []interface{}{}}) + return + } + out := make([]gin.H, 0, len(list)) + for _, r := range list { + full := r.Province + r.City + r.District + r.Detail + out = append(out, gin.H{ + "id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone, + "province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail, + "isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "list": out}) +} + +// UserAddressesPost POST /api/user/addresses +func UserAddressesPost(c *gin.Context) { + var body struct { + UserID string `json:"userId" binding:"required"` + Name string `json:"name" binding:"required"` + Phone string `json:"phone" binding:"required"` + Province string `json:"province"` + City string `json:"city"` + District string `json:"district"` + Detail string `json:"detail" binding:"required"` + IsDefault bool `json:"isDefault"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少必填项:userId, name, phone, detail"}) + return + } + id := fmt.Sprintf("addr_%d", time.Now().UnixNano()%100000000000) + db := database.DB() + if body.IsDefault { + db.Model(&model.UserAddress{}).Where("user_id = ?", body.UserID).Update("is_default", false) + } + addr := model.UserAddress{ + ID: id, UserID: body.UserID, Name: body.Name, Phone: body.Phone, + Province: body.Province, City: body.City, District: body.District, Detail: body.Detail, + IsDefault: body.IsDefault, + } + if err := db.Create(&addr).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "添加地址失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "id": id, "message": "添加成功"}) +} + +// UserAddressesByID GET/PUT/DELETE /api/user/addresses/:id +func UserAddressesByID(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少地址 id"}) + return + } + db := database.DB() + switch c.Request.Method { + case "GET": + var r model.UserAddress + if err := db.Where("id = ?", id).First(&r).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"}) + return + } + full := r.Province + r.City + r.District + r.Detail + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ + "id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone, + "province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail, + "isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt, + }}) + case "PUT": + var r model.UserAddress + if err := db.Where("id = ?", id).First(&r).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"}) + return + } + var body struct { + Name *string `json:"name"` + Phone *string `json:"phone"` + Province *string `json:"province"` + City *string `json:"city"` + District *string `json:"district"` + Detail *string `json:"detail"` + IsDefault *bool `json:"isDefault"` + } + _ = c.ShouldBindJSON(&body) + updates := make(map[string]interface{}) + if body.Name != nil { + updates["name"] = *body.Name + } + if body.Phone != nil { + updates["phone"] = *body.Phone + } + if body.Province != nil { + updates["province"] = *body.Province + } + if body.City != nil { + updates["city"] = *body.City + } + if body.District != nil { + updates["district"] = *body.District + } + if body.Detail != nil { + updates["detail"] = *body.Detail + } + if body.IsDefault != nil { + updates["is_default"] = *body.IsDefault + if *body.IsDefault { + db.Model(&model.UserAddress{}).Where("user_id = ?", r.UserID).Update("is_default", false) + } + } + if len(updates) > 0 { + updates["updated_at"] = time.Now() + db.Model(&r).Updates(updates) + } + db.Where("id = ?", id).First(&r) + full := r.Province + r.City + r.District + r.Detail + c.JSON(http.StatusOK, gin.H{"success": true, "item": gin.H{ + "id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone, + "province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail, + "isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt, + }, "message": "更新成功"}) + case "DELETE": + if err := db.Where("id = ?", id).Delete(&model.UserAddress{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"}) + } +} + +// UserCheckPurchased GET /api/user/check-purchased?userId=&type=section|fullbook&productId= +func UserCheckPurchased(c *gin.Context) { + userId := c.Query("userId") + type_ := c.Query("type") + productId := c.Query("productId") + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"}) + return + } + db := database.DB() + var user model.User + if err := db.Where("id = ?", userId).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) + return + } + hasFullBook := user.HasFullBook != nil && *user.HasFullBook + if hasFullBook { + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}}) + return + } + if type_ == "fullbook" { + var count int64 + db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userId, "fullbook", "paid").Count(&count) + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "fullbook_order_exists"}[count > 0]}}) + return + } + if type_ == "section" && productId != "" { + var count int64 + db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status = ?", userId, "section", productId, "paid").Count(&count) + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "section_order_exists"}[count > 0]}}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": false, "reason": nil}}) +} + +// UserProfileGet GET /api/user/profile?userId= 或 openId= +func UserProfileGet(c *gin.Context) { + userId := c.Query("userId") + openId := c.Query("openId") + if userId == "" && openId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"}) + return + } + db := database.DB() + var user model.User + q := db.Select("id", "open_id", "nickname", "avatar", "phone", "wechat_id", "referral_code", + "has_full_book", "earnings", "pending_earnings", "referral_count", "created_at", + "mbti", "region", "industry", "position", "business_scale", "skills", + "story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro") + if userId != "" { + q = q.Where("id = ?", userId) + } else { + q = q.Where("open_id = ?", openId) + } + if err := q.First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) + return + } + profileComplete := (user.Phone != nil && *user.Phone != "") || (user.WechatID != nil && *user.WechatID != "") + hasAvatar := user.Avatar != nil && *user.Avatar != "" && len(*user.Avatar) > 0 + str := func(p *string) interface{} { if p != nil { return *p }; return "" } + resp := gin.H{ + "id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar), + "phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode, + "hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings, + "referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar, + "createdAt": user.CreatedAt, + // P3 资料扩展:统一返回所有表单字段,空值用 "" 便于前端回显 + "mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry), + "position": str(user.Position), "businessScale": str(user.BusinessScale), "skills": str(user.Skills), + "storyBestMonth": str(user.StoryBestMonth), "storyAchievement": str(user.StoryAchievement), + "storyTurning": str(user.StoryTurning), "helpOffer": str(user.HelpOffer), "helpNeed": str(user.HelpNeed), + "projectIntro": str(user.ProjectIntro), + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": resp}) +} + +// UserProfilePost POST /api/user/profile 更新用户资料 +func UserProfilePost(c *gin.Context) { + var body struct { + UserID string `json:"userId"` + OpenID string `json:"openId"` + Nickname *string `json:"nickname"` + Avatar *string `json:"avatar"` + Phone *string `json:"phone"` + WechatID *string `json:"wechatId"` + Mbti *string `json:"mbti"` + Region *string `json:"region"` + Industry *string `json:"industry"` + Position *string `json:"position"` + BusinessScale *string `json:"businessScale"` + Skills *string `json:"skills"` + StoryBestMonth *string `json:"storyBestMonth"` + StoryAchievement *string `json:"storyAchievement"` + StoryTurning *string `json:"storyTurning"` + HelpOffer *string `json:"helpOffer"` + HelpNeed *string `json:"helpNeed"` + ProjectIntro *string `json:"projectIntro"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"}) + return + } + identifier := body.UserID + byID := true + if identifier == "" { + identifier = body.OpenID + byID = false + } + if identifier == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"}) + return + } + db := database.DB() + var user model.User + if byID { + db = db.Where("id = ?", identifier) + } else { + db = db.Where("open_id = ?", identifier) + } + if err := db.First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) + return + } + updates := make(map[string]interface{}) + if body.Nickname != nil { + updates["nickname"] = *body.Nickname + } + if body.Avatar != nil { + updates["avatar"] = *body.Avatar + } + if body.Phone != nil { + updates["phone"] = *body.Phone + } + if body.WechatID != nil { + updates["wechat_id"] = *body.WechatID + } + if body.Mbti != nil { updates["mbti"] = *body.Mbti } + if body.Region != nil { updates["region"] = *body.Region } + if body.Industry != nil { updates["industry"] = *body.Industry } + if body.Position != nil { updates["position"] = *body.Position } + if body.BusinessScale != nil { updates["business_scale"] = *body.BusinessScale } + if body.Skills != nil { updates["skills"] = *body.Skills } + if body.StoryBestMonth != nil { updates["story_best_month"] = *body.StoryBestMonth } + if body.StoryAchievement != nil { updates["story_achievement"] = *body.StoryAchievement } + if body.StoryTurning != nil { updates["story_turning"] = *body.StoryTurning } + if body.HelpOffer != nil { updates["help_offer"] = *body.HelpOffer } + if body.HelpNeed != nil { updates["help_need"] = *body.HelpNeed } + if body.ProjectIntro != nil { updates["project_intro"] = *body.ProjectIntro } + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "没有需要更新的字段"}) + return + } + updates["updated_at"] = time.Now() + db.Model(&user).Updates(updates) + // 重新查询并返回与 GET 一致的完整资料结构,空值统一为 "" + profileCols := []string{"id", "open_id", "nickname", "avatar", "phone", "wechat_id", "referral_code", "created_at", + "mbti", "region", "industry", "position", "business_scale", "skills", + "story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro"} + if err := database.DB().Select(profileCols).Where("id = ?", user.ID).First(&user).Error; err == nil { + str := func(p *string) interface{} { if p != nil { return *p }; return "" } + resp := gin.H{ + "id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar), + "phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode, + "createdAt": user.CreatedAt, + "mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry), + "position": str(user.Position), "businessScale": str(user.BusinessScale), "skills": str(user.Skills), + "storyBestMonth": str(user.StoryBestMonth), "storyAchievement": str(user.StoryAchievement), + "storyTurning": str(user.StoryTurning), "helpOffer": str(user.HelpOffer), "helpNeed": str(user.HelpNeed), + "projectIntro": str(user.ProjectIntro), + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": resp}) + } else { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": gin.H{ + "id": user.ID, "nickname": body.Nickname, "avatar": body.Avatar, "phone": body.Phone, "wechatId": body.WechatID, "referralCode": user.ReferralCode, + }}) + } +} + +// UserPurchaseStatus GET /api/user/purchase-status?userId= +func UserPurchaseStatus(c *gin.Context) { + userId := c.Query("userId") + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"}) + return + } + db := database.DB() + var user model.User + if err := db.Where("id = ?", userId).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) + return + } + var orderRows []struct { + ProductID string + MID int + } + db.Raw(`SELECT DISTINCT o.product_id, c.mid FROM orders o + LEFT JOIN chapters c ON c.id = o.product_id + WHERE o.user_id = ? AND o.status = ? AND o.product_type = ?`, userId, "paid", "section").Scan(&orderRows) + purchasedSections := make([]string, 0, len(orderRows)) + sectionMidMap := make(map[string]int) + for _, r := range orderRows { + if r.ProductID != "" { + purchasedSections = append(purchasedSections, r.ProductID) + if r.MID > 0 { + sectionMidMap[r.ProductID] = r.MID + } + } + } + // 是否有推荐人(被推荐绑定,可享好友优惠) + var refCount int64 + db.Model(&model.ReferralBinding{}).Where("referee_id = ? AND status = ?", userId, "active"). + Where("expiry_date > ?", time.Now()).Count(&refCount) + hasReferrer := refCount > 0 + + // 匹配次数配额:纯计算(订单 + match_records) + freeLimit := getFreeMatchLimit(db) + matchQuota := GetMatchQuota(db, userId, freeLimit) + earnings := 0.0 + if user.Earnings != nil { + earnings = *user.Earnings + } + pendingEarnings := 0.0 + if user.PendingEarnings != nil { + pendingEarnings = *user.PendingEarnings + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ + "hasFullBook": user.HasFullBook != nil && *user.HasFullBook, + "purchasedSections": purchasedSections, + "sectionMidMap": sectionMidMap, + "purchasedCount": len(purchasedSections), + "hasReferrer": hasReferrer, + "matchCount": matchQuota.PurchasedTotal, + "matchQuota": gin.H{ + "purchasedTotal": matchQuota.PurchasedTotal, + "purchasedUsed": matchQuota.PurchasedUsed, + "matchesUsedToday": matchQuota.MatchesUsedToday, + "freeRemainToday": matchQuota.FreeRemainToday, + "purchasedRemain": matchQuota.PurchasedRemain, + "remainToday": matchQuota.RemainToday, + }, + "earnings": earnings, + "pendingEarnings": pendingEarnings, + }}) +} + +// UserReadingProgressGet GET /api/user/reading-progress?userId= +func UserReadingProgressGet(c *gin.Context) { + userId := c.Query("userId") + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"}) + return + } + var list []model.ReadingProgress + if err := database.DB().Where("user_id = ?", userId).Order("last_open_at DESC").Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) + return + } + out := make([]gin.H, 0, len(list)) + for _, r := range list { + out = append(out, gin.H{ + "section_id": r.SectionID, "progress": r.Progress, "duration": r.Duration, "status": r.Status, + "completed_at": r.CompletedAt, "first_open_at": r.FirstOpenAt, "last_open_at": r.LastOpenAt, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": out}) +} + +// UserReadingProgressPost POST /api/user/reading-progress +func UserReadingProgressPost(c *gin.Context) { + var body struct { + UserID string `json:"userId" binding:"required"` + SectionID string `json:"sectionId" binding:"required"` + Progress int `json:"progress"` + Duration int `json:"duration"` + Status string `json:"status"` + CompletedAt *string `json:"completedAt"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"}) + return + } + db := database.DB() + now := time.Now() + var existing model.ReadingProgress + err := db.Where("user_id = ? AND section_id = ?", body.UserID, body.SectionID).First(&existing).Error + if err == nil { + newProgress := existing.Progress + if body.Progress > newProgress { + newProgress = body.Progress + } + newDuration := existing.Duration + body.Duration + newStatus := body.Status + if newStatus == "" { + newStatus = "reading" + } + var completedAt *time.Time + if body.CompletedAt != nil && *body.CompletedAt != "" { + t, _ := time.Parse(time.RFC3339, *body.CompletedAt) + completedAt = &t + } else if existing.CompletedAt != nil { + completedAt = existing.CompletedAt + } + db.Model(&existing).Updates(map[string]interface{}{ + "progress": newProgress, "duration": newDuration, "status": newStatus, + "completed_at": completedAt, "last_open_at": now, "updated_at": now, + }) + } else { + status := body.Status + if status == "" { + status = "reading" + } + var completedAt *time.Time + if body.CompletedAt != nil && *body.CompletedAt != "" { + t, _ := time.Parse(time.RFC3339, *body.CompletedAt) + completedAt = &t + } + db.Create(&model.ReadingProgress{ + UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: body.Duration, + Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "进度已保存"}) +} + +// UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查(GORM) +func UserTrackGet(c *gin.Context) { + userId := c.Query("userId") + phone := c.Query("phone") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit < 1 || limit > 100 { + limit = 50 + } + if userId == "" && phone == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"}) + return + } + db := database.DB() + if userId == "" && phone != "" { + var u model.User + if err := db.Where("phone = ?", phone).First(&u).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"}) + return + } + userId = u.ID + } + var tracks []model.UserTrack + if err := db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "tracks": []interface{}{}, "stats": gin.H{}, "total": 0}) + return + } + stats := make(map[string]int) + formatted := make([]gin.H, 0, len(tracks)) + for _, t := range tracks { + stats[t.Action]++ + target := "" + if t.Target != nil { + target = *t.Target + } + if t.ChapterID != nil && target == "" { + target = *t.ChapterID + } + formatted = append(formatted, gin.H{ + "id": t.ID, "action": t.Action, "target": target, "chapterTitle": t.ChapterID, + "createdAt": t.CreatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "tracks": formatted, "stats": stats, "total": len(formatted)}) +} + +// UserTrackPost POST /api/user/track 记录行为(GORM) +func UserTrackPost(c *gin.Context) { + var body struct { + UserID string `json:"userId"` + Phone string `json:"phone"` + Action string `json:"action"` + Target string `json:"target"` + ExtraData interface{} `json:"extraData"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"}) + return + } + if body.UserID == "" && body.Phone == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"}) + return + } + if body.Action == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"}) + return + } + db := database.DB() + userId := body.UserID + if userId == "" { + var u model.User + if err := db.Where("phone = ?", body.Phone).First(&u).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"}) + return + } + userId = u.ID + } + trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000) + chID := body.Target + if body.Action == "view_chapter" { + chID = body.Target + } + t := model.UserTrack{ + ID: trackID, UserID: userId, Action: body.Action, Target: &body.Target, + } + if body.Target != "" { + t.ChapterID = &chID + } + if err := db.Create(&t).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"}) +} + +// UserUpdate POST /api/user/update 更新昵称、头像、手机、微信号等 +func UserUpdate(c *gin.Context) { + var body struct { + UserID string `json:"userId" binding:"required"` + Nickname *string `json:"nickname"` + Avatar *string `json:"avatar"` + Phone *string `json:"phone"` + Wechat *string `json:"wechat"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"}) + return + } + updates := make(map[string]interface{}) + if body.Nickname != nil { + updates["nickname"] = *body.Nickname + } + if body.Avatar != nil { + updates["avatar"] = *body.Avatar + } + if body.Phone != nil { + updates["phone"] = *body.Phone + } + if body.Wechat != nil { + updates["wechat_id"] = *body.Wechat + } + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "没有需要更新的字段"}) + return + } + updates["updated_at"] = time.Now() + if err := database.DB().Model(&model.User{}).Where("id = ?", body.UserID).Updates(updates).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"}) +} diff --git a/soul-api/internal/handler/vip.go b/soul-api/internal/handler/vip.go new file mode 100644 index 00000000..5e103ebd --- /dev/null +++ b/soul-api/internal/handler/vip.go @@ -0,0 +1,356 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// 默认 VIP 价格与权益(与 next-project 一致) +const defaultVipPrice = 1980 + +var defaultVipRights = []string{ + "智能纪要 - 每天推送派对精华", + "会议纪要库 - 所有场次会议纪要", + "案例库 - 30-100个创业项目案例", + "链接资源 - 进群聊天链接资源", + "解锁全部章节内容(365天)", + "匹配所有创业伙伴", + "创业老板排行榜展示", + "专属VIP标识", +} + +// isVipFromUsers 从 users 表判断是否 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 构建会员展示数据;超级个体头像和昵称用用户资料(随「我的」修改实时生效) +// 优先 nickname/avatar,无则回退 vip_name/vip_avatar;用于首页超级个体、会员详情页等 +func formatVipMember(u *model.User, isVip bool) gin.H { + name := "" + if u.Nickname != nil && *u.Nickname != "" { + name = *u.Nickname + } + if name == "" && u.VipName != nil && *u.VipName != "" { + name = *u.VipName + } + if name == "" { + name = "创业者" + } + avatar := "" + if u.Avatar != nil && *u.Avatar != "" { + avatar = *u.Avatar + } + if avatar == "" && u.VipAvatar != nil && *u.VipAvatar != "" { + avatar = *u.VipAvatar + } + project := getStringValue(u.VipProject) + if project == "" { + project = getStringValue(u.ProjectIntro) + } + bio := "" + if u.VipBio != nil { + bio = *u.VipBio + } + contact := "" + if u.VipContact != nil { + contact = *u.VipContact + } + if contact == "" { + contact = getStringValue(u.Phone) + } + vipRole := "" + if u.VipRole != nil { + vipRole = *u.VipRole + } + return gin.H{ + "id": u.ID, + "name": name, + "nickname": name, + "avatar": avatar, + "vip_name": name, + "vipName": name, + "vipRole": vipRole, + "vip_avatar": avatar, + "vipAvatar": avatar, + "vipProject": project, + "vip_project": project, + "vipContact": contact, + "vip_contact": contact, + "vipBio": bio, + "wechatId": getStringValue(u.WechatID), + "wechat_id": getStringValue(u.WechatID), + "phone": getStringValue(u.Phone), + "mbti": getStringValue(u.Mbti), + "region": getStringValue(u.Region), + "industry": getStringValue(u.Industry), + "position": getStringValue(u.Position), + "businessScale": getStringValue(u.BusinessScale), + "business_scale": getStringValue(u.BusinessScale), + "skills": getStringValue(u.Skills), + "storyBestMonth": getStringValue(u.StoryBestMonth), + "story_best_month": getStringValue(u.StoryBestMonth), + "storyAchievement": getStringValue(u.StoryAchievement), + "story_achievement": getStringValue(u.StoryAchievement), + "storyTurning": getStringValue(u.StoryTurning), + "story_turning": getStringValue(u.StoryTurning), + "helpOffer": getStringValue(u.HelpOffer), + "help_offer": getStringValue(u.HelpOffer), + "helpNeed": getStringValue(u.HelpNeed), + "help_need": getStringValue(u.HelpNeed), + "projectIntro": getStringValue(u.ProjectIntro), + "project_intro": getStringValue(u.ProjectIntro), + "is_vip": isVip, + } +} + +func parseInt(s string) (int, error) { + return strconv.Atoi(s) +} diff --git a/soul-api/internal/handler/vip_roles.go b/soul-api/internal/handler/vip_roles.go new file mode 100644 index 00000000..1d9c3d16 --- /dev/null +++ b/soul-api/internal/handler/vip_roles.go @@ -0,0 +1,90 @@ +package handler + +import ( + "net/http" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" +) + +// DBVipRolesList GET /api/db/vip-roles 角色列表(管理端 Set VIP 下拉用) +func DBVipRolesList(c *gin.Context) { + db := database.DB() + var roles []model.VipRole + if err := db.Order("sort ASC, id ASC").Find(&roles).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": roles}) +} + +// DBVipRolesAction POST /api/db/vip-roles 新增角色;PUT 更新;DELETE 删除 +func DBVipRolesAction(c *gin.Context) { + db := database.DB() + method := c.Request.Method + + if method == "POST" { + var body struct { + Name string `json:"name" binding:"required"` + Sort int `json:"sort"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 不能为空"}) + return + } + role := model.VipRole{Name: body.Name, Sort: body.Sort} + if err := db.Create(&role).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": role}) + return + } + + if method == "PUT" { + var body struct { + ID int `json:"id" binding:"required"` + Name *string `json:"name"` + Sort *int `json:"sort"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"}) + return + } + updates := map[string]interface{}{} + if body.Name != nil { + updates["name"] = *body.Name + } + if body.Sort != nil { + updates["sort"] = *body.Sort + } + if len(updates) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"}) + return + } + if err := db.Model(&model.VipRole{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"}) + return + } + + if method == "DELETE" { + id := c.Query("id") + if id == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "id 不能为空"}) + return + } + if err := db.Where("id = ?", id).Delete(&model.VipRole{}).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"}) +} diff --git a/soul-api/internal/handler/wechat.go b/soul-api/internal/handler/wechat.go new file mode 100644 index 00000000..555fa007 --- /dev/null +++ b/soul-api/internal/handler/wechat.go @@ -0,0 +1,160 @@ +package handler + +import ( + "fmt" + "net/http" + "strings" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + "soul-api/internal/wechat" + + "github.com/gin-gonic/gin" +) + +// WechatLogin POST /api/wechat/login +func WechatLogin(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// WechatPhoneLoginReq 手机号登录请求:code 为 wx.login() 的 code,phoneCode 为 getPhoneNumber 返回的 code +type WechatPhoneLoginReq struct { + Code string `json:"code"` // wx.login() 得到,用于 code2session 拿 openId + PhoneCode string `json:"phoneCode"` // getPhoneNumber 得到,用于换手机号 +} + +// WechatPhoneLogin POST /api/wechat/phone-login +// 请求体:code(必填)+ phoneCode(必填)。先 code2session 得到 openId,再 getPhoneNumber 得到手机号,创建/更新用户并返回与 /api/miniprogram/login 一致的数据结构。 +func WechatPhoneLogin(c *gin.Context) { + var req WechatPhoneLoginReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code 或 phoneCode"}) + return + } + if req.Code == "" || req.PhoneCode == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供 code 与 phoneCode"}) + return + } + + openID, sessionKey, _, err := wechat.Code2Session(req.Code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)}) + return + } + phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.PhoneCode) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("获取手机号失败: %v", err)}) + return + } + + db := database.DB() + var user model.User + result := db.Where("open_id = ?", openID).First(&user) + isNewUser := result.Error != nil + + if isNewUser { + referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:]) + nickname := "微信用户" + openID[len(openID)-4:] + avatar := "" + hasFullBook := false + earnings := 0.0 + pendingEarnings := 0.0 + referralCount := 0 + purchasedSections := "[]" + phone := phoneNumber + if countryCode != "" && countryCode != "86" { + phone = "+" + countryCode + " " + phoneNumber + } + user = model.User{ + ID: openID, + OpenID: &openID, + SessionKey: &sessionKey, + Nickname: &nickname, + Avatar: &avatar, + Phone: &phone, + ReferralCode: &referralCode, + HasFullBook: &hasFullBook, + PurchasedSections: &purchasedSections, + Earnings: &earnings, + PendingEarnings: &pendingEarnings, + ReferralCount: &referralCount, + } + if err := db.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"}) + return + } + } else { + phone := phoneNumber + if countryCode != "" && countryCode != "86" { + phone = "+" + countryCode + " " + phoneNumber + } + db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone}) + user.Phone = &phone + } + + var orderRows []struct { + ProductID string `gorm:"column:product_id"` + } + db.Raw(` + SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section' + `, user.ID).Scan(&orderRows) + purchasedSections := []string{} + for _, row := range orderRows { + if row.ProductID != "" { + purchasedSections = append(purchasedSections, row.ProductID) + } + } + + responseUser := map[string]interface{}{ + "id": user.ID, + "openId": strVal(user.OpenID), + "nickname": strVal(user.Nickname), + "avatar": strVal(user.Avatar), + "phone": strVal(user.Phone), + "wechatId": strVal(user.WechatID), + "referralCode": strVal(user.ReferralCode), + "hasFullBook": boolVal(user.HasFullBook), + "purchasedSections": purchasedSections, + "earnings": floatVal(user.Earnings), + "pendingEarnings": floatVal(user.PendingEarnings), + "referralCount": intVal(user.ReferralCount), + "createdAt": user.CreatedAt, + } + token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix()) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": map[string]interface{}{ + "openId": openID, + "user": responseUser, + "token": token, + }, + "isNewUser": isNewUser, + }) +} + +func strVal(p *string) string { + if p == nil { + return "" + } + return *p +} +func boolVal(p *bool) bool { + if p == nil { + return false + } + return *p +} +func floatVal(p *float64) float64 { + if p == nil { + return 0 + } + return *p +} +func intVal(p *int) int { + if p == nil { + return 0 + } + return *p +} diff --git a/soul-api/internal/handler/withdraw.go b/soul-api/internal/handler/withdraw.go new file mode 100644 index 00000000..0f50c05a --- /dev/null +++ b/soul-api/internal/handler/withdraw.go @@ -0,0 +1,363 @@ +package handler + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "os" + "time" + + "soul-api/internal/database" + "soul-api/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// computeAvailableWithdraw 与小程序 / referral 页可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核 +// 佣金按订单逐条 computeOrderCommission 求和(会员订单 20%/10%,内容订单 90%) +func computeAvailableWithdraw(db *gorm.DB, userID string) (available, totalCommission, withdrawn, pending float64, minAmount float64) { + minAmount = 10 + var cfg model.SystemConfig + if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil { + var config map[string]interface{} + if _ = json.Unmarshal(cfg.ConfigValue, &config); config != nil { + if m, ok := config["minWithdrawAmount"].(float64); ok { + minAmount = m + } + } + } + var orders []model.Order + db.Where("referrer_id = ? AND status = ?", userID, "paid").Find(&orders) + for i := range orders { + totalCommission += computeOrderCommission(db, &orders[i], nil) + } + var w struct{ Total float64 } + db.Model(&model.Withdrawal{}).Where("user_id = ? AND status = ?", userID, "success"). + Select("COALESCE(SUM(amount), 0)").Scan(&w) + withdrawn = w.Total + db.Model(&model.Withdrawal{}).Where("user_id = ? AND status IN ?", userID, []string{"pending", "processing", "pending_confirm"}). + Select("COALESCE(SUM(amount), 0)").Scan(&w) + pending = w.Total + available = math.Max(0, totalCommission-withdrawn-pending) + return available, totalCommission, withdrawn, pending, minAmount +} + +// generateWithdrawID 生成提现单号(不依赖 wechat 包) +func generateWithdrawID() string { + return fmt.Sprintf("WD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000) +} + +// WithdrawPost POST /api/withdraw 创建提现申请(仅落库待审核,不调用微信打款接口) +// 可提现逻辑与小程序 referral 页一致;二次查库校验防止超额。打款由管理端审核后手动/后续接入官方接口再处理。 +func WithdrawPost(c *gin.Context) { + var req struct { + UserID string `json:"userId" binding:"required"` + Amount float64 `json:"amount" binding:"required"` + UserName string `json:"userName"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"}) + return + } + if req.Amount <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现金额必须大于0"}) + return + } + + db := database.DB() + available, _, _, _, minWithdrawAmount := computeAvailableWithdraw(db, req.UserID) + if req.Amount > available { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": fmt.Sprintf("可提现金额不足(当前可提现:%.2f元)", available), + }) + return + } + if req.Amount < minWithdrawAmount { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": fmt.Sprintf("最低提现金额为%.0f元", minWithdrawAmount), + }) + return + } + + var user model.User + if err := db.Where("id = ?", req.UserID).First(&user).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户不存在"}) + return + } + if user.OpenID == nil || *user.OpenID == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定微信"}) + return + } + + withdrawID := generateWithdrawID() + status := "pending" + // 根据 user_id 已查到的用户信息,填充提现表所需字段;仅写入表中存在的列,避免 remark 等列不存在报错 + wechatID := user.WechatID + if (wechatID == nil || *wechatID == "") && user.OpenID != nil && *user.OpenID != "" { + wechatID = user.OpenID + } + withdrawal := model.Withdrawal{ + ID: withdrawID, + UserID: req.UserID, + Amount: req.Amount, + Status: &status, + WechatOpenid: user.OpenID, + WechatID: wechatID, + } + if err := db.Select("ID", "UserID", "Amount", "Status", "WechatOpenid", "WechatID").Create(&withdrawal).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "创建提现记录失败", + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "提现申请已提交,审核通过后将打款至您的微信零钱", + "data": map[string]interface{}{ + "id": withdrawal.ID, + "amount": req.Amount, + "status": "pending", + "created_at": withdrawal.CreatedAt, + }, + }) +} + +// AdminWithdrawTest GET/POST /api/admin/withdraw-test 提现测试接口,供 curl 等调试用 +// 参数:userId(默认 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg)、amount(默认 1) +// 测试时忽略最低提现额限制,仅校验可提现余额与用户存在 +func AdminWithdrawTest(c *gin.Context) { + userID := c.DefaultQuery("userId", "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg") + amountStr := c.DefaultQuery("amount", "1") + var amount float64 + if _, err := fmt.Sscanf(amountStr, "%f", &amount); err != nil || amount <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "amount 须为正数"}) + return + } + + db := database.DB() + available, _, _, _, _ := computeAvailableWithdraw(db, userID) + if amount > available { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": fmt.Sprintf("可提现金额不足(当前可提现:%.2f元)", available), + }) + return + } + + var user model.User + if err := db.Where("id = ?", userID).First(&user).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户不存在"}) + return + } + if user.OpenID == nil || *user.OpenID == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定微信"}) + return + } + + withdrawID := generateWithdrawID() + status := "pending" + wechatID := user.WechatID + if (wechatID == nil || *wechatID == "") && user.OpenID != nil && *user.OpenID != "" { + wechatID = user.OpenID + } + withdrawal := model.Withdrawal{ + ID: withdrawID, + UserID: userID, + Amount: amount, + Status: &status, + WechatOpenid: user.OpenID, + WechatID: wechatID, + } + if err := db.Select("ID", "UserID", "Amount", "Status", "WechatOpenid", "WechatID").Create(&withdrawal).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "创建提现记录失败", + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "提现测试已提交", + "data": map[string]interface{}{ + "id": withdrawal.ID, + "userId": userID, + "amount": amount, + "status": "pending", + "created_at": withdrawal.CreatedAt, + }, + }) +} + +// WithdrawRecords GET /api/withdraw/records?userId= 当前用户提现记录(GORM) +func WithdrawRecords(c *gin.Context) { + userId := c.Query("userId") + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"}) + return + } + var list []model.Withdrawal + if err := database.DB().Where("user_id = ?", userId).Order("created_at DESC").Limit(100).Find(&list).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": []interface{}{}}}) + return + } + out := make([]gin.H, 0, len(list)) + for _, w := range list { + st := "" + if w.Status != nil { + st = *w.Status + } + canReceive := st == "processing" || st == "pending_confirm" + out = append(out, gin.H{ + "id": w.ID, "amount": w.Amount, "status": st, + "createdAt": w.CreatedAt, "processedAt": w.ProcessedAt, + "canReceive": canReceive, + }) + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"list": out}}) +} + +// WithdrawConfirmInfo GET /api/miniprogram/withdraw/confirm-info?id= 获取某条提现的领取零钱参数(mchId/appId/package),供 wx.requestMerchantTransfer 使用 +func WithdrawConfirmInfo(c *gin.Context) { + id := c.Query("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 id"}) + return + } + db := database.DB() + var w model.Withdrawal + if err := db.Where("id = ?", id).First(&w).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "提现记录不存在"}) + return + } + st := "" + if w.Status != nil { + st = *w.Status + } + if st != "processing" && st != "pending_confirm" { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前状态不可领取"}) + return + } + mchId := os.Getenv("WECHAT_MCH_ID") + if mchId == "" { + mchId = "1318592501" + } + appId := os.Getenv("WECHAT_APPID") + if appId == "" { + appId = "wxb8bbb2b10dec74aa" + } + packageInfo := "" + if w.PackageInfo != nil && *w.PackageInfo != "" { + packageInfo = *w.PackageInfo + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "mchId": mchId, + "appId": appId, + "package": packageInfo, + }, + }) +} + +// WithdrawPendingConfirm GET /api/withdraw/pending-confirm?userId= 待确认收款列表(仅审核通过后) +// 只返回 processing、pending_confirm,供「我的」页「待确认收款」展示;pending 为待审核,不在此列表 +func WithdrawPendingConfirm(c *gin.Context) { + userId := c.Query("userId") + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"}) + return + } + db := database.DB() + var list []model.Withdrawal + // 仅审核已通过、等待用户确认收款的:processing(微信处理中)、pending_confirm(待用户点确认收款) + if err := db.Where("user_id = ? AND status IN ?", userId, []string{"processing", "pending_confirm"}). + Order("created_at DESC"). + Find(&list).Error; err != nil { + list = nil + } + out := make([]gin.H, 0, len(list)) + for _, w := range list { + item := gin.H{ + "id": w.ID, + "amount": w.Amount, + "createdAt": w.CreatedAt, + } + if w.PackageInfo != nil && *w.PackageInfo != "" { + item["package"] = *w.PackageInfo + } else { + item["package"] = "" + } + if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() { + item["userConfirmedAt"] = w.UserConfirmedAt.Format("2006-01-02 15:04:05") + } else { + item["userConfirmedAt"] = nil + } + out = append(out, item) + } + mchId := os.Getenv("WECHAT_MCH_ID") + if mchId == "" { + mchId = "1318592501" + } + appId := os.Getenv("WECHAT_APPID") + if appId == "" { + appId = "wxb8bbb2b10dec74aa" + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "list": out, + "mchId": mchId, + "appId": appId, + }, + }) +} + +// WithdrawConfirmReceived POST /api/miniprogram/withdraw/confirm-received 用户确认收款(记录已点击确认) +// body: { "withdrawalId": "xxx", "userId": "xxx" },仅本人可操作;更新 user_confirmed_at 并将状态置为 success,该条不再出现在待确认收款列表 +func WithdrawConfirmReceived(c *gin.Context) { + var req struct { + WithdrawalID string `json:"withdrawalId" binding:"required"` + UserID string `json:"userId" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawalId 或 userId"}) + return + } + db := database.DB() + var w model.Withdrawal + if err := db.Where("id = ? AND user_id = ?", req.WithdrawalID, req.UserID).First(&w).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "提现记录不存在或无权操作"}) + return + } + st := "" + if w.Status != nil { + st = *w.Status + } + // 仅处理中或待确认的可标记「用户已确认收款」 + if st != "processing" && st != "pending_confirm" && st != "success" { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前状态不可确认收款"}) + return + } + if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "已确认过"}) + return + } + now := time.Now() + // 更新为已确认收款,并将状态置为 success,待确认列表只含 processing/pending_confirm,故该条会从列表中移除 + up := map[string]interface{}{"user_confirmed_at": now, "status": "success"} + if err := db.Model(&w).Updates(up).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "更新失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "已记录确认收款"}) +} diff --git a/soul-api/internal/handler/withdraw_v3.go b/soul-api/internal/handler/withdraw_v3.go new file mode 100644 index 00000000..ac3a6047 --- /dev/null +++ b/soul-api/internal/handler/withdraw_v3.go @@ -0,0 +1,341 @@ +package handler + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "soul-api/internal/config" + "soul-api/internal/database" + "soul-api/internal/model" + "soul-api/internal/wechat/transferv3" + + "github.com/gin-gonic/gin" +) + +// getTransferV3Client 从 config 创建文档 V3 转账 Client(独立于 PowerWeChat) +func getTransferV3Client() (*transferv3.Client, error) { + cfg := config.Get() + if cfg == nil { + return nil, fmt.Errorf("config not loaded") + } + key, err := transferv3.LoadPrivateKeyFromPath(cfg.WechatKeyPath) + if err != nil { + return nil, fmt.Errorf("load private key: %w", err) + } + return transferv3.NewClient(cfg.WechatMchID, cfg.WechatAppID, cfg.WechatSerialNo, key), nil +} + +// WithdrawV3Initiate POST /api/v3/withdraw/initiate 根据文档发起商家转账到零钱(V3 独立实现) +// body: { "withdrawal_id": "xxx" },需先存在 pending 的提现记录 +func WithdrawV3Initiate(c *gin.Context) { + var req struct { + WithdrawalID string `json:"withdrawal_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawal_id"}) + return + } + + db := database.DB() + var w model.Withdrawal + if err := db.Where("id = ?", req.WithdrawalID).First(&w).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现记录不存在"}) + return + } + st := "" + if w.Status != nil { + st = *w.Status + } + if st != "pending" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "仅支持 pending 状态发起"}) + return + } + + openID := "" + if w.WechatOpenid != nil && *w.WechatOpenid != "" { + openID = *w.WechatOpenid + } + if openID == "" { + var u model.User + if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil { + openID = *u.OpenID + } + } + if openID == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定 openid"}) + return + } + + cfg := config.Get() + if cfg == nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "配置未加载"}) + return + } + + outBatchNo := fmt.Sprintf("WD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000) + outDetailNo := fmt.Sprintf("WDD%d%06d", time.Now().Unix(), time.Now().UnixNano()%1000000) + amountFen := int(w.Amount * 100) + if amountFen < 1 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "金额异常"}) + return + } + + batchRemark := fmt.Sprintf("提现 %.2f 元", w.Amount) + if len([]rune(batchRemark)) > 32 { + batchRemark = "用户提现" + } + + body := map[string]interface{}{ + "appid": cfg.WechatAppID, + "out_batch_no": outBatchNo, + "batch_name": "用户提现", + "batch_remark": batchRemark, + "total_amount": amountFen, + "total_num": 1, + "transfer_scene_id": "1005", + "transfer_detail_list": []map[string]interface{}{ + { + "out_detail_no": outDetailNo, + "transfer_amount": amountFen, + "transfer_remark": "提现", + "openid": openID, + }, + }, + } + if cfg.WechatTransferURL != "" { + body["notify_url"] = cfg.WechatTransferURL + } + bodyBytes, _ := json.Marshal(body) + + client, err := getTransferV3Client() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + + respBody, statusCode, err := client.PostBatches(bodyBytes) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + + if statusCode < 200 || statusCode >= 300 { + var errResp struct { + Code string `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal(respBody, &errResp) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": errResp.Message, + "code": errResp.Code, + }) + return + } + + var respData struct { + OutBatchNo string `json:"out_batch_no"` + BatchID string `json:"batch_id"` + CreateTime string `json:"create_time"` + BatchStatus string `json:"batch_status"` + } + _ = json.Unmarshal(respBody, &respData) + + now := time.Now() + processingStatus := "processing" + _ = db.Model(&w).Updates(map[string]interface{}{ + "status": processingStatus, + "batch_no": outBatchNo, + "detail_no": outDetailNo, + "batch_id": respData.BatchID, + "processed_at": now, + }).Error + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "已发起打款,微信处理中", + "data": gin.H{ + "out_batch_no": outBatchNo, + "batch_id": respData.BatchID, + "batch_status": respData.BatchStatus, + }, + }) +} + +// WithdrawV3Notify POST /api/v3/withdraw/notify 文档 V3 转账结果回调(验签可选,解密后更新状态) +func WithdrawV3Notify(c *gin.Context) { + rawBody, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "body read error"}) + return + } + + var envelope map[string]interface{} + if err := json.Unmarshal(rawBody, &envelope); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "invalid json"}) + return + } + + resource, _ := envelope["resource"].(map[string]interface{}) + if resource == nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "no resource"}) + return + } + + ciphertext, _ := resource["ciphertext"].(string) + nonceStr, _ := resource["nonce"].(string) + assoc, _ := resource["associated_data"].(string) + if ciphertext == "" || nonceStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "missing ciphertext/nonce"}) + return + } + if assoc == "" { + assoc = "mch_payment" + } + + cfg := config.Get() + if cfg == nil || len(cfg.WechatAPIv3Key) != 32 { + c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "config or apiv3 key invalid"}) + return + } + + decrypted, err := transferv3.DecryptResourceJSON(ciphertext, nonceStr, assoc, []byte(cfg.WechatAPIv3Key)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "decrypt failed"}) + return + } + + outBillNo, _ := decrypted["out_bill_no"].(string) + state, _ := decrypted["state"].(string) + failReason, _ := decrypted["fail_reason"].(string) + if outBillNo == "" { + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) + return + } + + db := database.DB() + var w model.Withdrawal + if err := db.Where("detail_no = ?", outBillNo).First(&w).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) + return + } + cur := "" + if w.Status != nil { + cur = *w.Status + } + if cur != "processing" && cur != "pending_confirm" { + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) + return + } + + now := time.Now() + up := map[string]interface{}{"processed_at": now} + switch state { + case "SUCCESS": + up["status"] = "success" + case "FAIL", "CANCELLED": + up["status"] = "failed" + if failReason != "" { + up["fail_reason"] = failReason + } + default: + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) + return + } + if err := db.Model(&w).Updates(up).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "update failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) +} + +// WithdrawV3Query POST /api/v3/withdraw/query 主动查询转账结果并更新(文档:按商户批次/明细单号查询) +// body: { "withdrawal_id": "xxx" } +func WithdrawV3Query(c *gin.Context) { + var req struct { + WithdrawalID string `json:"withdrawal_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 withdrawal_id"}) + return + } + + db := database.DB() + var w model.Withdrawal + if err := db.Where("id = ?", req.WithdrawalID).First(&w).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "提现记录不存在"}) + return + } + batchNo := "" + detailNo := "" + if w.BatchNo != nil { + batchNo = *w.BatchNo + } + if w.DetailNo != nil { + detailNo = *w.DetailNo + } + if batchNo == "" || detailNo == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未发起过微信转账"}) + return + } + + client, err := getTransferV3Client() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + + respBody, statusCode, err := client.GetTransferDetail(batchNo, detailNo) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + + if statusCode != 200 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": string(respBody), + }) + return + } + + var detail struct { + DetailStatus string `json:"detail_status"` + FailReason string `json:"fail_reason"` + } + _ = json.Unmarshal(respBody, &detail) + + now := time.Now() + up := map[string]interface{}{"processed_at": now} + switch strings.ToUpper(detail.DetailStatus) { + case "SUCCESS": + up["status"] = "success" + case "FAIL": + up["status"] = "failed" + if detail.FailReason != "" { + up["fail_reason"] = detail.FailReason + } + default: + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "查询成功,状态未终态", + "detail_status": detail.DetailStatus, + }) + return + } + if err := db.Model(&w).Updates(up).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "已同步状态", + "detail_status": detail.DetailStatus, + }) +} diff --git a/soul-api/internal/middleware/admin_auth.go b/soul-api/internal/middleware/admin_auth.go new file mode 100644 index 00000000..72d351b2 --- /dev/null +++ b/soul-api/internal/middleware/admin_auth.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "net/http" + + "soul-api/internal/auth" + "soul-api/internal/config" + + "github.com/gin-gonic/gin" +) + +const adminClaimsKey = "admin_claims" + +// AdminAuth 管理端鉴权:校验 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 new file mode 100644 index 00000000..8643e934 --- /dev/null +++ b/soul-api/internal/middleware/ratelimit.go @@ -0,0 +1,65 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" + + "github.com/gin-gonic/gin" +) + +// RateLimiter 按 IP 的限流器 +type RateLimiter struct { + mu sync.Mutex + clients map[string]*rate.Limiter + r rate.Limit + b int +} + +// NewRateLimiter 创建限流中间件,r 每秒请求数,b 突发容量 +func NewRateLimiter(r rate.Limit, b int) *RateLimiter { + return &RateLimiter{ + clients: make(map[string]*rate.Limiter), + r: r, + b: b, + } +} + +// getLimiter 获取或创建该 key 的 limiter +func (rl *RateLimiter) getLimiter(key string) *rate.Limiter { + rl.mu.Lock() + defer rl.mu.Unlock() + if lim, ok := rl.clients[key]; ok { + return lim + } + lim := rate.NewLimiter(rl.r, rl.b) + rl.clients[key] = lim + return lim +} + +// Middleware 返回 Gin 限流中间件(按客户端 IP) +func (rl *RateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + key := c.ClientIP() + lim := rl.getLimiter(key) + if !lim.Allow() { + c.AbortWithStatus(http.StatusTooManyRequests) + return + } + c.Next() + } +} + +// Cleanup 定期清理过期 limiter(可选,避免 map 无限增长) +func (rl *RateLimiter) Cleanup(interval time.Duration) { + ticker := time.NewTicker(interval) + go func() { + for range ticker.C { + rl.mu.Lock() + rl.clients = make(map[string]*rate.Limiter) + rl.mu.Unlock() + } + }() +} diff --git a/soul-api/internal/middleware/secure.go b/soul-api/internal/middleware/secure.go new file mode 100644 index 00000000..7e9eec19 --- /dev/null +++ b/soul-api/internal/middleware/secure.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/unrolled/secure" +) + +// Secure 安全响应头中间件 +func Secure() gin.HandlerFunc { + s := secure.New(secure.Options{ + FrameDeny: true, + ContentTypeNosniff: true, + BrowserXssFilter: true, + ContentSecurityPolicy: "frame-ancestors 'none'", + ReferrerPolicy: "no-referrer", + }) + return func(c *gin.Context) { + err := s.Process(c.Writer, c.Request) + if err != nil { + c.Abort() + return + } + c.Next() + } +} diff --git a/soul-api/internal/model/README.txt b/soul-api/internal/model/README.txt new file mode 100644 index 00000000..a2c6b5a7 --- /dev/null +++ b/soul-api/internal/model/README.txt @@ -0,0 +1 @@ +在此目录放置 GORM 模型与请求/响应结构体,例如 User、Order、Withdrawal、Config 等。 diff --git a/soul-api/internal/model/admin_user.go b/soul-api/internal/model/admin_user.go new file mode 100644 index 00000000..69b7ff7c --- /dev/null +++ b/soul-api/internal/model/admin_user.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// AdminUser 后台管理员用户 +type AdminUser struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"column:username;size:64;uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"column:password_hash;size:128;not null" json:"-"` + Role string `gorm:"column:role;size:32;not null;default:admin" json:"role"` // super_admin | admin + Name string `gorm:"column:name;size:64" json:"name"` + Status string `gorm:"column:status;size:16;not null;default:active" json:"status"` // active | disabled + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (AdminUser) TableName() string { + return "admin_users" +} diff --git a/soul-api/internal/model/author_config.go b/soul-api/internal/model/author_config.go new file mode 100644 index 00000000..a599840f --- /dev/null +++ b/soul-api/internal/model/author_config.go @@ -0,0 +1,19 @@ +package model + +import "time" + +// AuthorConfig 作者详情配置,小程序「关于作者」页展示,单行记录 +type AuthorConfig struct { + ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Name string `gorm:"column:name;size:80;not null" json:"name"` + Avatar string `gorm:"column:avatar;size:4" json:"avatar"` // 首字母占位,无头像时显示 + AvatarImg string `gorm:"column:avatar_img;size:500" json:"avatarImg"` // 头像图片 URL + Title string `gorm:"column:title;size:200" json:"title"` + Bio string `gorm:"column:bio;type:text" json:"bio"` + Stats string `gorm:"column:stats;type:text" json:"-"` // JSON: [{"label":"","value":""}] + Highlights string `gorm:"column:highlights;type:text" json:"-"` // JSON: ["a","b"] + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (AuthorConfig) TableName() string { return "author_config" } diff --git a/soul-api/internal/model/chapter.go b/soul-api/internal/model/chapter.go new file mode 100644 index 00000000..6f0272bb --- /dev/null +++ b/soul-api/internal/model/chapter.go @@ -0,0 +1,28 @@ +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 new file mode 100644 index 00000000..2db72b55 --- /dev/null +++ b/soul-api/internal/model/match_record.go @@ -0,0 +1,18 @@ +package model + +import "time" + +// MatchRecord 匹配记录,每次用户成功匹配时写入 +type MatchRecord struct { + ID string `gorm:"column:id;primaryKey;size:50" json:"id"` + UserID string `gorm:"column:user_id;index;size:50;not null" json:"userId"` + MatchType string `gorm:"column:match_type;index;size:50" json:"matchType"` + Phone *string `gorm:"column:phone;size:20" json:"phone"` + WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId"` + MatchedUserID string `gorm:"column:matched_user_id;index;size:50" json:"matchedUserId"` + MatchScore *int `gorm:"column:match_score" json:"matchScore"` + Status *string `gorm:"column:status;size:20" json:"status"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (MatchRecord) TableName() string { return "match_records" } diff --git a/soul-api/internal/model/mentor.go b/soul-api/internal/model/mentor.go new file mode 100644 index 00000000..c85f3db0 --- /dev/null +++ b/soul-api/internal/model/mentor.go @@ -0,0 +1,25 @@ +package model + +import "time" + +// Mentor stitch_soul 导师,独立于 match 类型 +type Mentor struct { + ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Avatar string `gorm:"column:avatar;size:500" json:"avatar"` + Name string `gorm:"column:name;size:80;not null" json:"name"` + Intro string `gorm:"column:intro;size:500" json:"intro"` // 简介/头衔 + Tags string `gorm:"column:tags;size:500" json:"tags"` // 技能标签,逗号分隔 + PriceSingle *float64 `gorm:"column:price_single;type:decimal(10,2)" json:"priceSingle"` // 单次咨询 + PriceHalfYear *float64 `gorm:"column:price_half_year;type:decimal(10,2)" json:"priceHalfYear"` // 半年 + PriceYear *float64 `gorm:"column:price_year;type:decimal(10,2)" json:"priceYear"` // 年度 + Quote string `gorm:"column:quote;size:500" json:"quote"` // 引言 + WhyFind string `gorm:"column:why_find;type:text" json:"whyFind"` // 为什么找 + Offering string `gorm:"column:offering;type:text" json:"offering"` // 提供什么 + JudgmentStyle string `gorm:"column:judgment_style;size:500" json:"judgmentStyle"` // 判断风格,逗号分隔 + Sort int `gorm:"column:sort;default:0" json:"sort"` + Enabled *bool `gorm:"column:enabled;default:1" json:"enabled"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (Mentor) TableName() string { return "mentors" } diff --git a/soul-api/internal/model/mentor_consultation.go b/soul-api/internal/model/mentor_consultation.go new file mode 100644 index 00000000..c14d40a5 --- /dev/null +++ b/soul-api/internal/model/mentor_consultation.go @@ -0,0 +1,19 @@ +package model + +import "time" + +// MentorConsultation 导师预约单 +type MentorConsultation struct { + ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + UserID int `gorm:"column:user_id;not null;index" json:"userId"` + MentorID int `gorm:"column:mentor_id;not null;index" json:"mentorId"` + ConsultationType string `gorm:"column:consultation_type;size:20;not null" json:"consultationType"` // single, half_year, year + Amount float64 `gorm:"column:amount;type:decimal(10,2);not null" json:"amount"` + Status string `gorm:"column:status;size:20;default:created;index" json:"status"` // created, pending_pay, paid, completed, cancelled + OrderID *int `gorm:"column:order_id" json:"orderId,omitempty"` // 关联支付订单 + ScheduledAt *time.Time `gorm:"column:scheduled_at" json:"scheduledAt,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (MentorConsultation) TableName() string { return "mentor_consultations" } diff --git a/soul-api/internal/model/order.go b/soul-api/internal/model/order.go new file mode 100644 index 00000000..8552911e --- /dev/null +++ b/soul-api/internal/model/order.go @@ -0,0 +1,25 @@ +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 new file mode 100644 index 00000000..a9a93c3f --- /dev/null +++ b/soul-api/internal/model/reading_progress.go @@ -0,0 +1,20 @@ +package model + +import "time" + +// ReadingProgress 对应表 reading_progress +type ReadingProgress struct { + ID int `gorm:"column:id;primaryKey;autoIncrement"` + UserID string `gorm:"column:user_id;size:50"` + SectionID string `gorm:"column:section_id;size:50"` + Progress int `gorm:"column:progress"` + Duration int `gorm:"column:duration"` + Status string `gorm:"column:status;size:20"` + CompletedAt *time.Time `gorm:"column:completed_at"` + FirstOpenAt *time.Time `gorm:"column:first_open_at"` + LastOpenAt *time.Time `gorm:"column:last_open_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (ReadingProgress) TableName() string { return "reading_progress" } diff --git a/soul-api/internal/model/referral_binding.go b/soul-api/internal/model/referral_binding.go new file mode 100644 index 00000000..cfd2b53f --- /dev/null +++ b/soul-api/internal/model/referral_binding.go @@ -0,0 +1,22 @@ +package model + +import "time" + +// ReferralBinding 对应表 referral_bindings +type ReferralBinding struct { + ID string `gorm:"column:id;primaryKey;size:50"` + ReferrerID string `gorm:"column:referrer_id;size:50"` + RefereeID string `gorm:"column:referee_id;size:50"` + ReferralCode string `gorm:"column:referral_code;size:20"` + Status *string `gorm:"column:status;size:20"` + BindingDate time.Time `gorm:"column:binding_date"` + ExpiryDate time.Time `gorm:"column:expiry_date"` + CommissionAmount *float64 `gorm:"column:commission_amount;type:decimal(10,2)"` + PurchaseCount *int `gorm:"column:purchase_count"` // 购买次数 + TotalCommission *float64 `gorm:"column:total_commission;type:decimal(10,2)"` // 累计佣金 + LastPurchaseDate *time.Time `gorm:"column:last_purchase_date"` // 最后购买日期 + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (ReferralBinding) TableName() string { return "referral_bindings" } diff --git a/soul-api/internal/model/referral_visit.go b/soul-api/internal/model/referral_visit.go new file mode 100644 index 00000000..8b6918e1 --- /dev/null +++ b/soul-api/internal/model/referral_visit.go @@ -0,0 +1,16 @@ +package model + +import "time" + +// ReferralVisit 对应表 referral_visits +type ReferralVisit struct { + ID int `gorm:"column:id;primaryKey;autoIncrement"` + ReferrerID string `gorm:"column:referrer_id;size:50"` + VisitorID *string `gorm:"column:visitor_id;size:50"` + VisitorOpenID *string `gorm:"column:visitor_openid;size:100"` + Source *string `gorm:"column:source;size:50"` + Page *string `gorm:"column:page;size:200"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (ReferralVisit) TableName() string { return "referral_visits" } diff --git a/soul-api/internal/model/system_config.go b/soul-api/internal/model/system_config.go new file mode 100644 index 00000000..590e221a --- /dev/null +++ b/soul-api/internal/model/system_config.go @@ -0,0 +1,35 @@ +package model + +import ( + "database/sql/driver" + "time" +) + +// ConfigValue 存 system_config.config_value(JSON 列,可为 object 或 array) +type ConfigValue []byte + +func (c ConfigValue) Value() (driver.Value, error) { return []byte(c), nil } +func (c *ConfigValue) Scan(value interface{}) error { + if value == nil { + *c = nil + return nil + } + b, ok := value.([]byte) + if !ok { + return nil + } + *c = append((*c)[0:0], b...) + return nil +} + +// SystemConfig 对应表 system_config,JSON 输出与现网 1:1(小写驼峰) +type SystemConfig struct { + ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ConfigKey string `gorm:"column:config_key;uniqueIndex;size:100" json:"configKey"` + ConfigValue ConfigValue `gorm:"column:config_value;type:json" json:"configValue"` + Description *string `gorm:"column:description;size:200" json:"description,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (SystemConfig) TableName() string { return "system_config" } diff --git a/soul-api/internal/model/user.go b/soul-api/internal/model/user.go new file mode 100644 index 00000000..066f8aef --- /dev/null +++ b/soul-api/internal/model/user.go @@ -0,0 +1,55 @@ +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 new file mode 100644 index 00000000..3c5b4818 --- /dev/null +++ b/soul-api/internal/model/user_address.go @@ -0,0 +1,20 @@ +package model + +import "time" + +// UserAddress 对应表 user_addresses +type UserAddress struct { + ID string `gorm:"column:id;primaryKey;size:50"` + UserID string `gorm:"column:user_id;size:50"` + Name string `gorm:"column:name;size:50"` + Phone string `gorm:"column:phone;size:20"` + Province string `gorm:"column:province;size:50"` + City string `gorm:"column:city;size:50"` + District string `gorm:"column:district;size:50"` + Detail string `gorm:"column:detail;size:200"` + IsDefault bool `gorm:"column:is_default"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (UserAddress) TableName() string { return "user_addresses" } diff --git a/soul-api/internal/model/user_track.go b/soul-api/internal/model/user_track.go new file mode 100644 index 00000000..f86f42dd --- /dev/null +++ b/soul-api/internal/model/user_track.go @@ -0,0 +1,16 @@ +package model + +import "time" + +// UserTrack 对应表 user_tracks +type UserTrack struct { + ID string `gorm:"column:id;primaryKey;size:50"` + UserID string `gorm:"column:user_id;size:100"` + Action string `gorm:"column:action;size:50"` + ChapterID *string `gorm:"column:chapter_id;size:100"` + Target *string `gorm:"column:target;size:200"` + ExtraData []byte `gorm:"column:extra_data;type:json"` + CreatedAt *time.Time `gorm:"column:created_at"` +} + +func (UserTrack) TableName() string { return "user_tracks" } diff --git a/soul-api/internal/model/vip_role.go b/soul-api/internal/model/vip_role.go new file mode 100644 index 00000000..8f253d02 --- /dev/null +++ b/soul-api/internal/model/vip_role.go @@ -0,0 +1,14 @@ +package model + +import "time" + +// VipRole 超级个体固定角色,用于 Set VIP 时下拉选择 +type VipRole struct { + ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Name string `gorm:"column:name;size:50;not null" json:"name"` + Sort int `gorm:"column:sort;default:0" json:"sort"` // 下拉展示顺序,越小越前 + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +func (VipRole) TableName() string { return "vip_roles" } diff --git a/soul-api/internal/model/wechat_callback_log.go b/soul-api/internal/model/wechat_callback_log.go new file mode 100644 index 00000000..4e118e0f --- /dev/null +++ b/soul-api/internal/model/wechat_callback_log.go @@ -0,0 +1,21 @@ +package model + +import "time" + +// WechatCallbackLog 微信回调日志(转账结果通知、支付通知等) +// 表名 wechat_callback_logs +type WechatCallbackLog struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + CallbackType string `gorm:"column:callback_type;size:32;index" json:"callbackType"` // transfer | pay + OutBatchNo string `gorm:"column:out_batch_no;size:64;index" json:"outBatchNo"` // 商家批次单号 + OutDetailNo string `gorm:"column:out_detail_no;size:64;index" json:"outDetailNo"` // 商家明细单号(转账即 out_bill_no) + TransferBillNo string `gorm:"column:transfer_bill_no;size:64" json:"transferBillNo"` // 微信转账单号 + State string `gorm:"column:state;size:32" json:"state"` // SUCCESS | FAIL | CANCELLED 等 + FailReason string `gorm:"column:fail_reason;size:500" json:"failReason"` + HandlerResult string `gorm:"column:handler_result;size:20" json:"handlerResult"` // success | fail + HandlerError string `gorm:"column:handler_error;size:1000" json:"handlerError"` // 业务处理错误信息 + RequestBody string `gorm:"column:request_body;type:text" json:"-"` // 原始/解密后 body(可选,不输出 JSON) + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` +} + +func (WechatCallbackLog) TableName() string { return "wechat_callback_logs" } diff --git a/soul-api/internal/model/withdrawal.go b/soul-api/internal/model/withdrawal.go new file mode 100644 index 00000000..ed125557 --- /dev/null +++ b/soul-api/internal/model/withdrawal.go @@ -0,0 +1,24 @@ +package model + +import "time" + +// Withdrawal 对应表 withdrawals +type Withdrawal struct { + ID string `gorm:"column:id;primaryKey;size:50" json:"id"` + UserID string `gorm:"column:user_id;size:50" json:"userId"` + Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"` + Status *string `gorm:"column:status;size:20" json:"status"` + WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId"` + WechatOpenid *string `gorm:"column:wechat_openid;size:100" json:"wechatOpenid"` + BatchNo *string `gorm:"column:batch_no;size:100" json:"batchNo,omitempty"` // 商家批次单号 + DetailNo *string `gorm:"column:detail_no;size:100" json:"detailNo,omitempty"` // 商家明细单号 + BatchID *string `gorm:"column:batch_id;size:100" json:"batchId,omitempty"` // 微信批次单号 + PackageInfo *string `gorm:"column:package_info;size:500" json:"packageInfo,omitempty"` // 微信返回的 package_info,供小程序 wx.requestMerchantTransfer + Remark *string `gorm:"column:remark;size:200" json:"remark,omitempty"` // 提现备注 + FailReason *string `gorm:"column:fail_reason;size:500" json:"failReason,omitempty"` // 失败原因 + UserConfirmedAt *time.Time `gorm:"column:user_confirmed_at" json:"userConfirmedAt,omitempty"` // 用户点击「确认收款」时间 + CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` + ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"` +} + +func (Withdrawal) TableName() string { return "withdrawals" } diff --git a/soul-api/internal/repository/README.txt b/soul-api/internal/repository/README.txt new file mode 100644 index 00000000..4f0cfda6 --- /dev/null +++ b/soul-api/internal/repository/README.txt @@ -0,0 +1 @@ +在此目录放置数据库访问层,供 service 调用,例如 UserRepo、OrderRepo、ConfigRepo 等。 diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go new file mode 100644 index 00000000..310ac75c --- /dev/null +++ b/soul-api/internal/router/router.go @@ -0,0 +1,317 @@ +package router + +import ( + "soul-api/internal/config" + "soul-api/internal/handler" + "soul-api/internal/middleware" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// Setup 创建并配置 Gin 引擎,路径与 app/api 一致 +func Setup(cfg *config.Config) *gin.Engine { + gin.SetMode(cfg.Mode) + r := gin.New() + r.Use(gin.Recovery()) + r.Use(gin.Logger()) + _ = r.SetTrustedProxies(cfg.TrustedProxies) + + r.Use(middleware.Secure()) + r.Use(cors.New(cors.Config{ + AllowOrigins: cfg.CORSOrigins, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + AllowCredentials: true, + MaxAge: 86400, + })) + rateLimiter := middleware.NewRateLimiter(100, 200) + r.Use(rateLimiter.Middleware()) + + r.Static("/uploads", "./uploads") + + api := r.Group("/api") + { + // ----- 管理端 ----- + api.GET("/admin", handler.AdminCheck) + api.POST("/admin", handler.AdminLogin) + api.POST("/admin/logout", handler.AdminLogout) + + admin := api.Group("/admin") + admin.Use(middleware.AdminAuth()) + { + admin.GET("/chapters", handler.AdminChaptersList) + admin.POST("/chapters", handler.AdminChaptersAction) + admin.PUT("/chapters", handler.AdminChaptersAction) + admin.DELETE("/chapters", handler.AdminChaptersAction) + admin.GET("/content", handler.AdminContent) + admin.POST("/content", handler.AdminContent) + admin.PUT("/content", handler.AdminContent) + admin.DELETE("/content", handler.AdminContent) + admin.GET("/distribution/overview", handler.AdminDistributionOverview) + admin.GET("/payment", handler.AdminPayment) + admin.POST("/payment", handler.AdminPayment) + admin.PUT("/payment", handler.AdminPayment) + admin.DELETE("/payment", handler.AdminPayment) + admin.GET("/referral", handler.AdminReferral) + admin.POST("/referral", handler.AdminReferral) + admin.PUT("/referral", handler.AdminReferral) + admin.DELETE("/referral", handler.AdminReferral) + admin.GET("/withdrawals", handler.AdminWithdrawalsList) + admin.PUT("/withdrawals", handler.AdminWithdrawalsAction) + admin.POST("/withdrawals/sync", handler.AdminWithdrawalsSync) + admin.GET("/withdraw-test", handler.AdminWithdrawTest) + admin.POST("/withdraw-test", handler.AdminWithdrawTest) + admin.GET("/settings", handler.AdminSettingsGet) + admin.POST("/settings", handler.AdminSettingsPost) + admin.GET("/referral-settings", handler.AdminReferralSettingsGet) + admin.POST("/referral-settings", handler.AdminReferralSettingsPost) + admin.GET("/author-settings", handler.AdminAuthorSettingsGet) + admin.POST("/author-settings", handler.AdminAuthorSettingsPost) + admin.PUT("/orders/refund", handler.AdminOrderRefund) + admin.GET("/users", handler.AdminUsersList) + admin.POST("/users", handler.AdminUsersAction) + admin.PUT("/users", handler.AdminUsersAction) + admin.DELETE("/users", handler.AdminUsersAction) + } + + // ----- 鉴权 ----- + api.POST("/auth/login", handler.AuthLogin) + api.POST("/auth/reset-password", handler.AuthResetPassword) + + // ----- 书籍/章节 ----- + api.GET("/book/all-chapters", handler.BookAllChapters) + api.GET("/book/chapter/:id", handler.BookChapterByID) + api.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID) + api.GET("/book/chapters", handler.BookChapters) + api.POST("/book/chapters", handler.BookChapters) + api.PUT("/book/chapters", handler.BookChapters) + api.DELETE("/book/chapters", handler.BookChapters) + api.GET("/book/hot", handler.BookHot) + api.GET("/book/recommended", handler.BookRecommended) + api.GET("/book/latest-chapters", handler.BookLatestChapters) + api.GET("/book/search", handler.BookSearch) + api.GET("/book/stats", handler.BookStats) + api.GET("/book/sync", handler.BookSync) + api.POST("/book/sync", handler.BookSync) + + // ----- CKB ----- + api.POST("/ckb/join", handler.CKBJoin) + api.POST("/ckb/match", handler.CKBMatch) + api.GET("/ckb/sync", handler.CKBSync) + api.POST("/ckb/sync", handler.CKBSync) + + // ----- 配置 ----- + api.GET("/config", handler.GetConfig) + // 小程序用:GET /api/db/config 返回 freeChapters、prices(不鉴权,先于 db 组匹配) + api.GET("/db/config", handler.GetPublicDBConfig) + + // ----- 内容 ----- + api.GET("/content", handler.ContentGet) + + // ----- 定时任务 ----- + api.GET("/cron/sync-orders", handler.CronSyncOrders) + api.POST("/cron/sync-orders", handler.CronSyncOrders) + api.GET("/cron/unbind-expired", handler.CronUnbindExpired) + api.POST("/cron/unbind-expired", handler.CronUnbindExpired) + + // ----- 数据库(管理端) ----- + db := api.Group("/db") + db.Use(middleware.AdminAuth()) + { + db.GET("/book", handler.DBBookAction) + db.POST("/book", handler.DBBookAction) + db.PUT("/book", handler.DBBookAction) + db.DELETE("/book", handler.DBBookDelete) + db.GET("/chapters", handler.DBChapters) + db.POST("/chapters", handler.DBChapters) + db.GET("/config/full", handler.DBConfigGet) // 管理端拉全量配置;GET /api/db/config 已用于公开接口 GetPublicDBConfig + db.POST("/config", handler.DBConfigPost) + db.DELETE("/config", handler.DBConfigDelete) + db.GET("/distribution", handler.DBDistribution) + db.GET("/init", handler.DBInitGet) + db.POST("/init", handler.DBInit) + db.GET("/migrate", handler.DBMigrateGet) + db.POST("/migrate", handler.DBMigratePost) + db.GET("/users", handler.DBUsersList) + db.POST("/users", handler.DBUsersAction) + db.PUT("/users", handler.DBUsersAction) + db.DELETE("/users", handler.DBUsersDelete) + db.GET("/users/referrals", handler.DBUsersReferrals) + db.GET("/vip-roles", handler.DBVipRolesList) + db.POST("/vip-roles", handler.DBVipRolesAction) + db.PUT("/vip-roles", handler.DBVipRolesAction) + db.DELETE("/vip-roles", handler.DBVipRolesAction) + db.GET("/match-records", handler.DBMatchRecordsList) + db.GET("/mentors", handler.DBMentorsList) + db.POST("/mentors", handler.DBMentorsAction) + db.PUT("/mentors", handler.DBMentorsAction) + db.DELETE("/mentors", handler.DBMentorsAction) + db.GET("/mentor-consultations", handler.DBMentorConsultationsList) + } + + // ----- 分销 ----- + api.GET("/distribution", handler.DistributionGet) + api.POST("/distribution", handler.DistributionGet) + api.PUT("/distribution", handler.DistributionGet) + api.GET("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig) + api.POST("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig) + api.DELETE("/distribution/auto-withdraw-config", handler.DistributionAutoWithdrawConfig) + api.GET("/distribution/messages", handler.DistributionMessages) + api.POST("/distribution/messages", handler.DistributionMessages) + + // ----- 文档生成 ----- + api.POST("/documentation/generate", handler.DocGenerate) + + // ----- 找伙伴 ----- + api.GET("/match/config", handler.MatchConfigGet) + api.POST("/match/config", handler.MatchConfigPost) + api.POST("/match/users", handler.MatchUsers) + + // ----- 菜单 ----- + api.GET("/menu", handler.MenuGet) + + // ----- 订单 ----- + api.GET("/orders", handler.OrdersList) + + // ----- 支付 ----- + api.POST("/payment/alipay/notify", handler.PaymentAlipayNotify) + api.POST("/payment/callback", handler.PaymentCallback) + api.POST("/payment/create-order", handler.PaymentCreateOrder) + api.GET("/payment/methods", handler.PaymentMethods) + api.GET("/payment/query", handler.PaymentQuery) + api.GET("/payment/status/:orderSn", handler.PaymentStatusOrderSn) + api.POST("/payment/verify", handler.PaymentVerify) + api.POST("/payment/wechat/notify", handler.PaymentWechatNotify) + api.GET("/payment/wechat/transfer/notify", handler.PaymentWechatTransferNotify) + api.POST("/payment/wechat/transfer/notify", handler.PaymentWechatTransferNotify) + + // ----- 推荐 ----- + api.POST("/referral/bind", handler.ReferralBind) + api.GET("/referral/data", handler.ReferralData) + api.POST("/referral/visit", handler.ReferralVisit) + + // ----- 搜索 ----- + api.GET("/search", handler.SearchGet) + + // ----- 同步 ----- + api.GET("/sync", handler.SyncGet) + api.POST("/sync", handler.SyncPost) + api.PUT("/sync", handler.SyncPut) + + // ----- 上传 ----- + api.POST("/upload", handler.UploadPost) + api.DELETE("/upload", handler.UploadDelete) + + // ----- 用户 ----- + api.GET("/user/addresses", handler.UserAddressesGet) + api.POST("/user/addresses", handler.UserAddressesPost) + api.GET("/user/addresses/:id", handler.UserAddressesByID) + api.PUT("/user/addresses/:id", handler.UserAddressesByID) + api.DELETE("/user/addresses/:id", handler.UserAddressesByID) + api.GET("/user/check-purchased", handler.UserCheckPurchased) + api.GET("/user/profile", handler.UserProfileGet) + api.POST("/user/profile", handler.UserProfilePost) + api.GET("/user/purchase-status", handler.UserPurchaseStatus) + api.GET("/user/reading-progress", handler.UserReadingProgressGet) + api.POST("/user/reading-progress", handler.UserReadingProgressPost) + api.GET("/user/track", handler.UserTrackGet) + api.POST("/user/track", handler.UserTrackPost) + api.POST("/user/update", handler.UserUpdate) + + // ----- 微信登录 ----- + api.POST("/wechat/login", handler.WechatLogin) + api.POST("/wechat/phone-login", handler.WechatPhoneLogin) + + // ----- 小程序组(所有小程序端接口统一在 /api/miniprogram 下) ----- + miniprogram := api.Group("/miniprogram") + { + miniprogram.GET("/config", handler.GetPublicDBConfig) + miniprogram.POST("/login", handler.MiniprogramLogin) + miniprogram.POST("/phone-login", handler.WechatPhoneLogin) + miniprogram.POST("/phone", handler.MiniprogramPhone) + miniprogram.GET("/pay", handler.MiniprogramPay) + miniprogram.POST("/pay", handler.MiniprogramPay) + miniprogram.POST("/pay/notify", handler.MiniprogramPayNotify) // 微信支付回调,URL 需在商户平台配置 + miniprogram.POST("/qrcode", handler.MiniprogramQrcode) + miniprogram.GET("/qrcode/image", handler.MiniprogramQrcodeImage) + miniprogram.GET("/book/all-chapters", handler.BookAllChapters) + miniprogram.GET("/book/chapter/:id", handler.BookChapterByID) + miniprogram.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID) + miniprogram.GET("/book/hot", handler.BookHot) + miniprogram.GET("/book/recommended", handler.BookRecommended) + miniprogram.GET("/book/latest-chapters", handler.BookLatestChapters) + miniprogram.GET("/book/search", handler.BookSearch) + miniprogram.GET("/book/stats", handler.BookStats) + miniprogram.POST("/referral/visit", handler.ReferralVisit) + miniprogram.POST("/referral/bind", handler.ReferralBind) + miniprogram.GET("/referral/data", handler.ReferralData) + miniprogram.GET("/earnings", handler.MyEarnings) + miniprogram.GET("/match/config", handler.MatchConfigGet) + miniprogram.POST("/match/users", handler.MatchUsers) + miniprogram.POST("/ckb/join", handler.CKBJoin) + miniprogram.POST("/ckb/match", handler.CKBMatch) + miniprogram.POST("/ckb/lead", handler.CKBLead) + miniprogram.POST("/upload", handler.UploadPost) + miniprogram.DELETE("/upload", handler.UploadDelete) + miniprogram.GET("/user/addresses", handler.UserAddressesGet) + miniprogram.POST("/user/addresses", handler.UserAddressesPost) + miniprogram.GET("/user/addresses/:id", handler.UserAddressesByID) + miniprogram.PUT("/user/addresses/:id", handler.UserAddressesByID) + miniprogram.DELETE("/user/addresses/:id", handler.UserAddressesByID) + miniprogram.GET("/user/check-purchased", handler.UserCheckPurchased) + miniprogram.GET("/user/profile", handler.UserProfileGet) + miniprogram.POST("/user/profile", handler.UserProfilePost) + miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus) + miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet) + miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost) + miniprogram.POST("/user/update", handler.UserUpdate) + miniprogram.POST("/withdraw", handler.WithdrawPost) + miniprogram.GET("/withdraw/records", handler.WithdrawRecords) + miniprogram.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm) + miniprogram.POST("/withdraw/confirm-received", handler.WithdrawConfirmReceived) + miniprogram.GET("/withdraw/confirm-info", handler.WithdrawConfirmInfo) + // VIP 接口(小程序专用,按使用方区分路径) + miniprogram.GET("/vip/status", handler.VipStatus) + miniprogram.GET("/vip/profile", handler.VipProfileGet) + miniprogram.POST("/vip/profile", handler.VipProfilePost) + miniprogram.GET("/vip/members", handler.VipMembers) + // 用户列表/单个(首页超级个体、会员详情回退) + miniprogram.GET("/users", handler.MiniprogramUsers) + miniprogram.GET("/orders", handler.MiniprogramOrders) + // 导师(stitch_soul) + miniprogram.GET("/mentors", handler.MiniprogramMentorsList) + miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail) + miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook) + miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor) + } + + // ----- 提现 ----- + api.POST("/withdraw", handler.WithdrawPost) + api.GET("/withdraw/records", handler.WithdrawRecords) + api.GET("/withdraw/pending-confirm", handler.WithdrawPendingConfirm) + // 提现测试(固定用户 1 元,无需 admin 鉴权,仅用于脚本/本地调试) + api.GET("/withdraw-test", handler.AdminWithdrawTest) + api.POST("/withdraw-test", handler.AdminWithdrawTest) + + // ----- 提现 V3(独立实现,依文档 提现功能完整技术文档.md) ----- + api.POST("/v3/withdraw/initiate", handler.WithdrawV3Initiate) + api.POST("/v3/withdraw/notify", handler.WithdrawV3Notify) + api.POST("/v3/withdraw/query", handler.WithdrawV3Query) + } + + // 根路径不返回任何页面(仅 204) + r.GET("/", func(c *gin.Context) { + c.Status(204) + }) + + // 健康检查:返回状态与版本号(版本号从 .env 的 APP_VERSION 读取,打包/上传前写入) + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "ok", + "version": cfg.Version, + }) + }) + + return r +} diff --git a/soul-api/internal/service/README.txt b/soul-api/internal/service/README.txt new file mode 100644 index 00000000..50e47169 --- /dev/null +++ b/soul-api/internal/service/README.txt @@ -0,0 +1 @@ +在此目录放置业务逻辑,供 handler 调用,例如 AdminService、UserService、PaymentService 等。 diff --git a/soul-api/internal/wechat/miniprogram.go b/soul-api/internal/wechat/miniprogram.go new file mode 100644 index 00000000..c2100e81 --- /dev/null +++ b/soul-api/internal/wechat/miniprogram.go @@ -0,0 +1,449 @@ +package wechat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "soul-api/internal/config" + + "github.com/ArtisanCloud/PowerLibs/v3/object" + subrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/basicService/subscribeMessage/request" + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models" + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/power" + "github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram" + "github.com/ArtisanCloud/PowerWeChat/v3/src/payment" + notifyrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/notify/request" + "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request" +) + +var ( + miniProgramApp *miniProgram.MiniProgram + paymentApp *payment.Payment + cfg *config.Config +) + +// resolveCertPaths 若证书/私钥路径为 URL 则下载到临时文件并返回本地路径 +func resolveCertPaths(c *config.Config) (certPath, keyPath string, err error) { + certPath = c.WechatCertPath + keyPath = c.WechatKeyPath + if certPath == "" || keyPath == "" { + return certPath, keyPath, nil + } + if strings.HasPrefix(keyPath, "http://") || strings.HasPrefix(keyPath, "https://") { + dir, e := os.MkdirTemp("", "wechat_cert_*") + if e != nil { + return "", "", fmt.Errorf("创建临时目录失败: %w", e) + } + keyPath, e = downloadToFile(keyPath, filepath.Join(dir, "apiclient_key.pem")) + if e != nil { + return "", "", e + } + if strings.HasPrefix(certPath, "http://") || strings.HasPrefix(certPath, "https://") { + certPath, e = downloadToFile(certPath, filepath.Join(dir, "apiclient_cert.pem")) + if e != nil { + return "", "", e + } + } else { + // cert 是本地路径,只下载了 key + certPath = c.WechatCertPath + } + } + return certPath, keyPath, nil +} + +func downloadToFile(url, filePath string) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("下载文件失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("下载返回状态: %d", resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取内容失败: %w", err) + } + if err := os.WriteFile(filePath, data, 0600); err != nil { + return "", fmt.Errorf("写入临时文件失败: %w", err) + } + return filePath, nil +} + +// Init 初始化微信客户端(小程序 + 支付 v3 + 转账均使用 PowerWeChat) +func Init(c *config.Config) error { + cfg = c + + var err error + miniProgramApp, err = miniProgram.NewMiniProgram(&miniProgram.UserConfig{ + AppID: cfg.WechatAppID, + Secret: cfg.WechatAppSecret, + HttpDebug: cfg.Mode == "debug", + }) + if err != nil { + return fmt.Errorf("初始化小程序失败: %w", err) + } + + certPath, keyPath, err := resolveCertPaths(cfg) + if err != nil { + return fmt.Errorf("解析证书路径: %w", err) + } + paymentConfig := &payment.UserConfig{ + AppID: cfg.WechatAppID, + MchID: cfg.WechatMchID, + MchApiV3Key: cfg.WechatAPIv3Key, + Key: cfg.WechatMchKey, + CertPath: certPath, + KeyPath: keyPath, + SerialNo: cfg.WechatSerialNo, + NotifyURL: cfg.WechatNotifyURL, + HttpDebug: cfg.Mode == "debug", + } + paymentApp, err = payment.NewPayment(paymentConfig) + if err != nil { + return fmt.Errorf("初始化支付(v3)失败: %w", err) + } + + return nil +} + +// Code2Session 小程序登录 +func Code2Session(code string) (openID, sessionKey, unionID string, err error) { + ctx := context.Background() + response, err := miniProgramApp.Auth.Session(ctx, code) + if err != nil { + return "", "", "", fmt.Errorf("code2Session失败: %w", err) + } + + // PowerWeChat v3 返回的是 *object.HashMap + if response.ErrCode != 0 { + return "", "", "", fmt.Errorf("微信返回错误: %d - %s", response.ErrCode, response.ErrMsg) + } + + openID = response.OpenID + sessionKey = response.SessionKey + unionID = response.UnionID + + return openID, sessionKey, unionID, nil +} + +// GetAccessToken 获取小程序 access_token(用于手机号解密、小程序码生成) +func GetAccessToken() (string, error) { + ctx := context.Background() + tokenResp, err := miniProgramApp.AccessToken.GetToken(ctx, false) + if err != nil { + return "", fmt.Errorf("获取access_token失败: %w", err) + } + return tokenResp.AccessToken, nil +} + +// GetPhoneNumber 获取用户手机号 +func GetPhoneNumber(code string) (phoneNumber, countryCode string, err error) { + token, err := GetAccessToken() + if err != nil { + return "", "", err + } + + url := fmt.Sprintf("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s", token) + + reqBody := map[string]string{"code": code} + jsonData, _ := json.Marshal(reqBody) + + resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData)) + if err != nil { + return "", "", fmt.Errorf("请求微信接口失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var result struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + PhoneInfo struct { + PhoneNumber string `json:"phoneNumber"` + PurePhoneNumber string `json:"purePhoneNumber"` + CountryCode string `json:"countryCode"` + } `json:"phone_info"` + } + + if err := json.Unmarshal(body, &result); err != nil { + return "", "", fmt.Errorf("解析微信返回失败: %w", err) + } + + if result.ErrCode != 0 { + return "", "", fmt.Errorf("微信返回错误: %d - %s", result.ErrCode, result.ErrMsg) + } + + phoneNumber = result.PhoneInfo.PhoneNumber + if phoneNumber == "" { + phoneNumber = result.PhoneInfo.PurePhoneNumber + } + countryCode = result.PhoneInfo.CountryCode + if countryCode == "" { + countryCode = "86" + } + + return phoneNumber, countryCode, nil +} + +// GenerateMiniProgramCode 生成小程序码 +func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) { + token, err := GetAccessToken() + if err != nil { + return nil, err + } + + url := fmt.Sprintf("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=%s", token) + + if width <= 0 || width > 430 { + width = 280 + } + if page == "" { + page = "pages/index/index" + } + // 微信建议 scene 仅含英文字母、数字;& 和 = 可能导致异常,将 & 转为 _ 再传给微信 + scene = strings.ReplaceAll(scene, "&", "_") + if len(scene) > 32 { + scene = scene[:32] + } + + // 仅 developer/trial 生成对应版本码,其余一律正式版,避免扫码提示「开发版已过期」 + envVersion := "release" + if cfg != nil { + state := strings.TrimSpace(cfg.WechatMiniProgramState) + switch state { + case "developer": + envVersion = "develop" + case "trial": + envVersion = "trial" + default: + envVersion = "release" + } + } + if cfg != nil { + fmt.Printf("[GenerateMiniProgramCode] env_version=%s (WechatMiniProgramState=%q)\n", envVersion, cfg.WechatMiniProgramState) + } + reqBody := map[string]interface{}{ + "scene": scene, + "page": page, + "width": width, + "auto_color": false, + "line_color": map[string]int{"r": 0, "g": 206, "b": 209}, + "is_hyaline": false, + "env_version": envVersion, + } + jsonData, _ := json.Marshal(reqBody) + resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("请求微信接口失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + // 无论 Content-Type,先尝试按 JSON 解析:微信错误时返回小体积 JSON,否则会误报「图片数据异常(太小)」 + var errResult struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if json.Unmarshal(body, &errResult) == nil && errResult.ErrCode != 0 { + return nil, fmt.Errorf("生成小程序码失败: %d - %s", errResult.ErrCode, errResult.ErrMsg) + } + if len(body) < 1000 { + return nil, fmt.Errorf("返回的图片数据异常(太小),可能未发布对应版本或参数错误") + } + return body, nil +} + +// GetPayNotifyURL 返回支付回调地址(从 config.BaseURL 派生,与商户平台配置一致) +func GetPayNotifyURL() string { + if cfg != nil && cfg.WechatNotifyURL != "" { + return cfg.WechatNotifyURL + } + if cfg != nil && cfg.BaseURL != "" { + return cfg.BaseURLJoin("/api/miniprogram/pay/notify") + } + return "https://soulapi.quwanzhi.com/api/miniprogram/pay/notify" +} + +// PayJSAPIOrder 微信支付 v3 小程序 JSAPI 统一下单,返回 prepay_id +func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int, description, attach string) (prepayID string, err error) { + if paymentApp == nil { + return "", fmt.Errorf("支付未初始化") + } + req := &request.RequestJSAPIPrepay{ + PrepayBase: request.PrepayBase{ + AppID: cfg.WechatAppID, + MchID: cfg.WechatMchID, + NotifyUrl: GetPayNotifyURL(), + }, + Description: description, + OutTradeNo: orderSn, + Amount: &request.JSAPIAmount{ + Total: amountCents, + Currency: "CNY", + }, + Payer: &request.JSAPIPayer{OpenID: openID}, + Attach: attach, + } + res, err := paymentApp.Order.JSAPITransaction(ctx, req) + if err != nil { + return "", err + } + if res == nil || res.PrepayID == "" { + return "", fmt.Errorf("微信返回 prepay_id 为空") + } + return res.PrepayID, nil +} + +// GetJSAPIPayParams 根据 prepay_id 生成小程序 wx.requestPayment 所需参数(v3 签名) +func GetJSAPIPayParams(prepayID string) (map[string]string, error) { + if paymentApp == nil { + return nil, fmt.Errorf("支付未初始化") + } + cfgMap, err := paymentApp.JSSDK.BridgeConfig(prepayID, false) + if err != nil { + return nil, err + } + out := make(map[string]string) + if m, ok := cfgMap.(*object.StringMap); ok && m != nil { + for k, v := range *m { + out[k] = v + } + } + if len(out) == 0 && cfgMap != nil { + if ms, ok := cfgMap.(map[string]interface{}); ok { + for k, v := range ms { + if s, ok := v.(string); ok { + out[k] = s + } + } + } + } + return out, nil +} + +// QueryOrderByOutTradeNo 根据商户订单号查询订单状态(v3) +func QueryOrderByOutTradeNo(ctx context.Context, outTradeNo string) (tradeState, transactionID string, totalFee int, err error) { + if paymentApp == nil { + return "", "", 0, fmt.Errorf("支付未初始化") + } + res, err := paymentApp.Order.QueryByOutTradeNumber(ctx, outTradeNo) + if err != nil { + return "", "", 0, err + } + if res == nil { + return "", "", 0, nil + } + tradeState = res.TradeState + transactionID = res.TransactionID + if res.Amount != nil { + totalFee = int(res.Amount.Total) + } + return tradeState, transactionID, totalFee, nil +} + +// HandlePayNotify 处理 v3 支付回调:验签并解密后调用 handler,返回应写回微信的 HTTP 响应 +// handler 参数:orderSn, transactionID, totalFee(分), attach(JSON), openID +func HandlePayNotify(req *http.Request, handler func(orderSn, transactionID string, totalFee int, attach, openID string) error) (*http.Response, error) { + if paymentApp == nil { + return nil, fmt.Errorf("支付未初始化") + } + return paymentApp.HandlePaidNotify(req, func(_ *notifyrequest.RequestNotify, transaction *models.Transaction, fail func(string)) interface{} { + if transaction == nil { + fail("transaction is nil") + return nil + } + orderSn := transaction.OutTradeNo + transactionID := transaction.TransactionID + totalFee := 0 + if transaction.Amount != nil { + totalFee = int(transaction.Amount.Total) + } + attach := transaction.Attach + openID := "" + if transaction.Payer != nil { + openID = transaction.Payer.OpenID + } + if err := handler(orderSn, transactionID, totalFee, attach, openID); err != nil { + fail(err.Error()) + return nil + } + return nil + }) +} + +// HandleTransferNotify 处理商家转账结果回调:验签并解密后调用 handler,返回应写回微信的 HTTP 响应 +// handler 参数:outBillNo(商户单号/即我们存的 detail_no)、transferBillNo、state(SUCCESS/FAIL/CANCELLED)、failReason +func HandleTransferNotify(req *http.Request, handler func(outBillNo, transferBillNo, state, failReason string) error) (*http.Response, error) { + if paymentApp == nil { + return nil, fmt.Errorf("支付/转账未初始化") + } + return paymentApp.HandleTransferBillsNotify(req, func(_ *notifyrequest.RequestNotify, bill *models.TransferBills, fail func(string)) interface{} { + if bill == nil { + fail("bill is nil") + return nil + } + outBillNo := bill.OutBillNo + transferBillNo := bill.TransferBillNo + state := bill.State + failReason := bill.FailReason + if err := handler(outBillNo, transferBillNo, state, failReason); err != nil { + fail(err.Error()) + return nil + } + return nil + }) +} + +// GenerateOrderSn 生成订单号 +func GenerateOrderSn() string { + now := time.Now() + timestamp := now.Format("20060102150405") + random := now.UnixNano() % 1000000 + return fmt.Sprintf("MP%s%06d", timestamp, random) +} + +// WithdrawSubscribeTemplateID 提现结果订阅消息模板 ID(与小程序 app.js withdrawSubscribeTmplId 一致) +const WithdrawSubscribeTemplateID = "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE" + +// SendWithdrawSubscribeMessage 发起转账成功后发订阅消息(提现成功/待确认收款) +// openID 为接收人 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/refund.go b/soul-api/internal/wechat/refund.go new file mode 100644 index 00000000..995f2bba --- /dev/null +++ b/soul-api/internal/wechat/refund.go @@ -0,0 +1,41 @@ +package wechat + +import ( + "context" + "fmt" + "time" + + "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/refund/request" +) + +// RefundOrder 调用微信支付 v3 退款接口(全额退款) +// orderSn: 商户订单号;transactionID: 微信支付单号(二选一,有则优先用 transactionID);totalCents: 订单金额(分);reason: 退款原因 +func RefundOrder(ctx context.Context, orderSn, transactionID string, totalCents int, reason string) error { + if paymentApp == nil || paymentApp.Refund == nil { + return fmt.Errorf("支付/退款未初始化") + } + if totalCents <= 0 { + return fmt.Errorf("退款金额必须大于 0") + } + outRefundNo := fmt.Sprintf("RF%s%06d", time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000) + if reason == "" { + reason = "用户申请退款" + } + req := &request.RequestRefund{ + OutRefundNo: outRefundNo, + Reason: reason, + Amount: &request.RefundAmount{ + Refund: totalCents, + Total: totalCents, + Currency: "CNY", + From: []*request.RefundAmountFrom{}, + }, + } + if transactionID != "" { + req.TransactionID = transactionID + } else { + req.OutTradeNo = orderSn + } + _, err := paymentApp.Refund.Refund(ctx, req) + return err +} diff --git a/soul-api/internal/wechat/transfer.go b/soul-api/internal/wechat/transfer.go new file mode 100644 index 00000000..2b860c5e --- /dev/null +++ b/soul-api/internal/wechat/transfer.go @@ -0,0 +1,230 @@ +package wechat + +import ( + "context" + "fmt" + "time" + + "soul-api/internal/config" + + "github.com/ArtisanCloud/PowerLibs/v3/object" + fundAppRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/fundApp/request" + "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/transfer/request" +) + +// TransferParams 转账参数 +type TransferParams struct { + OutBatchNo string // 商家批次单号(唯一) + OutDetailNo string // 商家明细单号(唯一) + OpenID string // 收款用户 openid + Amount int // 转账金额(分) + UserName string // 收款用户姓名(可选,用于实名校验) + Remark string // 转账备注 + BatchName string // 批次名称(如"提现") + BatchRemark string // 批次备注 +} + +// TransferResult 转账结果 +type TransferResult struct { + BatchID string // 微信批次单号 + OutBatchNo string // 商家批次单号 + CreateTime time.Time // 批次创建时间 + BatchStatus string // 批次状态:ACCEPTED-已受理 等 +} + +// InitTransfer 保留兼容:转账已由 Init() 中 PowerWeChat Payment 统一初始化,调用无副作用 +func InitTransfer(_ *config.Config) error { + return nil +} + +// InitiateTransfer 发起商家转账到零钱(PowerWeChat TransferBatch) +func InitiateTransfer(params TransferParams) (*TransferResult, error) { + if paymentApp == nil { + return nil, fmt.Errorf("支付/转账未初始化,请先调用 wechat.Init") + } + + detail := &request.TransferDetail{ + OutDetailNO: params.OutDetailNo, + TransferAmount: params.Amount, + TransferRemark: params.Remark, + OpenID: params.OpenID, + } + if params.UserName != "" { + detail.UserName = object.NewNullString(params.UserName, true) + } + req := &request.RequestTransferBatch{ + AppID: cfg.WechatAppID, + OutBatchNO: params.OutBatchNo, + BatchName: params.BatchName, + BatchRemark: params.BatchRemark, + TotalAmount: params.Amount, + TotalNum: 1, + TransferDetailList: []*request.TransferDetail{detail}, + } + if cfg.WechatTransferURL != "" { + req.SetNotifyUrl(cfg.WechatTransferURL) + } + + resp, err := paymentApp.TransferBatch.Batch(context.Background(), req) + if err != nil { + return nil, fmt.Errorf("发起转账失败: %w", err) + } + if resp == nil { + return nil, fmt.Errorf("转账返回为空") + } + + result := &TransferResult{ + OutBatchNo: resp.OutBatchNo, + BatchStatus: "ACCEPTED", + } + if resp.BatchId != "" { + result.BatchID = resp.BatchId + } + if !resp.CreateTime.IsZero() { + result.CreateTime = resp.CreateTime + } + return result, nil +} + +// QueryTransfer 查询转账结果(可选,转账状态也可通过回调获取) +func QueryTransfer(outBatchNo, outDetailNo string) (map[string]interface{}, error) { + if paymentApp == nil { + return map[string]interface{}{ + "out_batch_no": outBatchNo, + "out_detail_no": outDetailNo, + "status": "unknown", + "message": "转账未初始化", + }, nil + } + detail, err := paymentApp.TransferBatch.QueryOutBatchNoDetail(context.Background(), outBatchNo, outDetailNo) + if err != nil { + return map[string]interface{}{ + "out_batch_no": outBatchNo, + "out_detail_no": outDetailNo, + "status": "processing", + "message": err.Error(), + }, nil + } + if detail == nil { + return map[string]interface{}{ + "out_batch_no": outBatchNo, + "out_detail_no": outDetailNo, + "status": "processing", + "message": "转账处理中", + }, nil + } + return map[string]interface{}{ + "out_batch_no": outBatchNo, + "out_detail_no": outDetailNo, + "detail_status": detail.DetailStatus, + "fail_reason": detail.FailReason, + "transfer_amount": detail.TransferAmount, + }, nil +} + +// GenerateTransferBatchNo 生成转账批次单号 +func GenerateTransferBatchNo() string { + now := time.Now() + timestamp := now.Format("20060102150405") + random := now.UnixNano() % 1000000 + return fmt.Sprintf("WD%s%06d", timestamp, random) +} + +// GenerateTransferDetailNo 生成转账明细单号 +func GenerateTransferDetailNo() string { + now := time.Now() + timestamp := now.Format("20060102150405") + random := now.UnixNano() % 1000000 + return fmt.Sprintf("WDD%s%06d", timestamp, random) +} + +// FundAppTransferParams 单笔转账(FundApp 发起转账)参数 +type FundAppTransferParams struct { + OutBillNo string // 商户单号(唯一,回调时 out_bill_no 即此值,建议存到 withdrawal.detail_no) + OpenID string + UserName string // 可选 + Amount int // 分 + Remark string + NotifyURL string + TransferSceneId string // 可选,如 "1005" +} + +// FundAppTransferResult 单笔转账结果(微信同步返回,无需等回调即可落库) +type FundAppTransferResult struct { + OutBillNo string // 商户单号 + TransferBillNo string // 微信转账单号 + State string // 如 WAIT_USER_CONFIRM 表示待用户确认收款 + PackageInfo string // 供小程序 wx.requestMerchantTransfer 使用 + CreateTime string // 微信返回的 create_time +} + +// InitiateTransferByFundApp 发起商家转账到零钱(PowerWeChat FundApp.TransferBills 单笔接口) +// 与 TransferBatch 不同,此为 /v3/fund-app/mch-transfer/transfer-bills 单笔发起,回调仍为 MCHTRANSFER.BILL.FINISHED,解密后 out_bill_no 即本接口传入的 OutBillNo +func InitiateTransferByFundApp(params FundAppTransferParams) (*FundAppTransferResult, error) { + if paymentApp == nil || paymentApp.FundApp == nil { + return nil, fmt.Errorf("支付/转账未初始化,请先调用 wechat.Init") + } + req := &fundAppRequest.RequestTransferBills{ + Appid: cfg.WechatAppID, + OutBillNo: params.OutBillNo, + TransferSceneId: params.TransferSceneId, + Openid: params.OpenID, + UserName: params.UserName, + TransferAmount: params.Amount, + TransferRemark: params.Remark, + NotifyUrl: params.NotifyURL, + } + // 1005=佣金报酬:微信要求同时传 transfer_scene_report_infos,岗位类型与报酬说明分开两条 + if params.TransferSceneId == "1005" { + req.TransferSceneReportInfos = []fundAppRequest.TransferSceneReportInfo{ + {InfoType: "岗位类型", InfoContent: "会员"}, + {InfoType: "报酬说明", InfoContent: "提现"}, + } + } + if req.NotifyUrl == "" && cfg.WechatTransferURL != "" { + req.NotifyUrl = cfg.WechatTransferURL + } + ctx := context.Background() + resp, err := paymentApp.FundApp.TransferBills(ctx, req) + if err != nil { + return nil, fmt.Errorf("发起转账失败: %w", err) + } + if resp == nil { + return nil, fmt.Errorf("转账返回为空") + } + // 微信返回 4xx 时 body 可能被解析到 resp,需根据 code 或 out_bill_no 判断是否成功 + if resp.Code != "" { + msg := resp.Message + if msg == "" { + msg = resp.Code + } + return nil, fmt.Errorf("微信接口报错: %s", msg) + } + if resp.OutBillNo == "" { + return nil, fmt.Errorf("微信未返回商户单号,可能请求被拒绝(如IP未加入白名单)") + } + result := &FundAppTransferResult{ + OutBillNo: resp.OutBillNo, + TransferBillNo: resp.TransferBillNo, + State: resp.State, + PackageInfo: resp.PackageInfo, + CreateTime: resp.CreateTime, + } + return result, nil +} + +// QueryTransferByOutBill 按商户单号查询单笔转账结果(FundApp 接口,用于 sync) +func QueryTransferByOutBill(outBillNo string) (state, transferBillNo, failReason string, err error) { + if paymentApp == nil || paymentApp.FundApp == nil { + return "", "", "", fmt.Errorf("支付/转账未初始化") + } + ctx := context.Background() + resp, err := paymentApp.FundApp.QueryOutBill(ctx, outBillNo) + if err != nil { + return "", "", "", err + } + if resp == nil { + return "", "", "", nil + } + return resp.State, resp.TransferBillNo, resp.FailReason, nil +} diff --git a/soul-api/internal/wechat/transferv3/client.go b/soul-api/internal/wechat/transferv3/client.go new file mode 100644 index 00000000..471907fa --- /dev/null +++ b/soul-api/internal/wechat/transferv3/client.go @@ -0,0 +1,120 @@ +package transferv3 + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "time" +) + +const wechatAPIBase = "https://api.mch.weixin.qq.com" + +func nonce() string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 32) + _, _ = rand.Read(b) + for i := range b { + b[i] = chars[int(b[i])%len(chars)] + } + return string(b) +} + +// Client 文档 V3 商家转账到零钱(签名 + HTTP) +type Client struct { + MchID string + AppID string + SerialNo string + PrivateKey *rsa.PrivateKey + BaseURL string +} + +// NewClient 使用已有私钥创建 Client +func NewClient(mchID, appID, serialNo string, privateKey *rsa.PrivateKey) *Client { + base := wechatAPIBase + return &Client{ + MchID: mchID, + AppID: appID, + SerialNo: serialNo, + PrivateKey: privateKey, + BaseURL: base, + } +} + +// LoadPrivateKeyFromPath 从 PEM 文件路径加载商户私钥 +func LoadPrivateKeyFromPath(path string) (*rsa.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return LoadPrivateKeyFromPEM(data) +} + +// LoadPrivateKeyFromPEM 从 PEM 内容解析商户私钥(支持 PKCS#1 或 PKCS#8) +func LoadPrivateKeyFromPEM(pemContent []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemContent) + if block == nil { + return nil, fmt.Errorf("no PEM block found") + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err == nil { + return key, nil + } + k, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + rsaKey, ok := k.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not RSA private key") + } + return rsaKey, nil +} + +// do 带签名的 HTTP 请求 +func (c *Client) do(method, path, body string) ([]byte, int, error) { + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + nonceStr := nonce() + signMsg := BuildSignMessage(method, path, timestamp, nonceStr, body) + sig, err := Sign(signMsg, c.PrivateKey) + if err != nil { + return nil, 0, err + } + auth := BuildAuthorization(c.MchID, nonceStr, sig, timestamp, c.SerialNo) + + fullURL := c.BaseURL + path + req, err := http.NewRequest(method, fullURL, bytes.NewBufferString(body)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return data, resp.StatusCode, nil +} + +// PostBatches 发起转账(文档:POST /v3/transfer/batches) +func (c *Client) PostBatches(body []byte) ([]byte, int, error) { + return c.do("POST", "/v3/transfer/batches", string(body)) +} + +// GetTransferDetail 按商户批次单号、商户明细单号查询(文档:GET .../batch-id/{}/details/detail-id/{}) +func (c *Client) GetTransferDetail(outBatchNo, outDetailNo string) ([]byte, int, error) { + path := "/v3/transfer/batches/batch-id/" + url.PathEscape(outBatchNo) + + "/details/detail-id/" + url.PathEscape(outDetailNo) + return c.do("GET", path, "") +} diff --git a/soul-api/internal/wechat/transferv3/decrypt.go b/soul-api/internal/wechat/transferv3/decrypt.go new file mode 100644 index 00000000..66d5dd91 --- /dev/null +++ b/soul-api/internal/wechat/transferv3/decrypt.go @@ -0,0 +1,52 @@ +package transferv3 + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" +) + +// DecryptResource 解密回调 resource(文档:AEAD_AES_256_GCM,密钥 APIv3 32 字节,密文=实际密文+16 字节 tag) +func DecryptResource(ciphertextBase64, nonce, associatedData string, apiV3Key []byte) ([]byte, error) { + if len(apiV3Key) != 32 { + return nil, fmt.Errorf("apiV3 key must be 32 bytes, got %d", len(apiV3Key)) + } + raw, err := base64.StdEncoding.DecodeString(ciphertextBase64) + if err != nil { + return nil, fmt.Errorf("base64 decode: %w", err) + } + if len(raw) < 16 { + return nil, fmt.Errorf("ciphertext too short") + } + tag := raw[len(raw)-16:] + ctext := raw[:len(raw)-16] + + block, err := aes.NewCipher(apiV3Key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + plain, err := aead.Open(nil, []byte(nonce), append(ctext, tag...), []byte(associatedData)) + if err != nil { + return nil, fmt.Errorf("aes-gcm decrypt: %w", err) + } + return plain, nil +} + +// DecryptResourceJSON 解密并解析为 JSON 对象(回调解密后的 resource) +func DecryptResourceJSON(ciphertextBase64, nonce, associatedData string, apiV3Key []byte) (map[string]interface{}, error) { + plain, err := DecryptResource(ciphertextBase64, nonce, associatedData, apiV3Key) + if err != nil { + return nil, err + } + var out map[string]interface{} + if err := json.Unmarshal(plain, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/soul-api/internal/wechat/transferv3/sign.go b/soul-api/internal/wechat/transferv3/sign.go new file mode 100644 index 00000000..83fce832 --- /dev/null +++ b/soul-api/internal/wechat/transferv3/sign.go @@ -0,0 +1,48 @@ +package transferv3 + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" +) + +// BuildSignMessage 构建请求签名串(文档:请求方法\n请求URL路径\n时间戳\n随机串\n请求报文主体\n) +func BuildSignMessage(method, urlPath, timestamp, nonce, body string) string { + return method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n" +} + +// Sign 使用商户私钥 SHA256withRSA 签名,返回 Base64 +func Sign(signMessage string, privateKey *rsa.PrivateKey) (string, error) { + h := sha256.Sum256([]byte(signMessage)) + sig, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, h[:]) + if err != nil { + return "", fmt.Errorf("rsa sign: %w", err) + } + return base64.StdEncoding.EncodeToString(sig), nil +} + +// BuildAuthorization 构建 Authorization 头(文档格式) +func BuildAuthorization(mchID, nonce, signature, timestamp, serialNo string) string { + return fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`, + mchID, nonce, signature, timestamp, serialNo) +} + +// BuildVerifyMessage 构建回调验签串(文档:时间戳\n随机串\n请求报文主体\n) +func BuildVerifyMessage(timestamp, nonce, body string) string { + return timestamp + "\n" + nonce + "\n" + body + "\n" +} + +// VerifySignature 使用平台公钥验证回调签名(Wechatpay-Signature 为 Base64) +func VerifySignature(timestamp, nonce, body, signatureBase64 string, publicKey *rsa.PublicKey) bool { + msg := BuildVerifyMessage(timestamp, nonce, body) + sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signatureBase64)) + if err != nil { + return false + } + h := sha256.Sum256([]byte(msg)) + err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, h[:], sig) + return err == nil +} diff --git a/soul-api/qgL5DeGe9A.txt b/soul-api/qgL5DeGe9A.txt new file mode 100644 index 00000000..d4045a5b --- /dev/null +++ b/soul-api/qgL5DeGe9A.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..e638b0a2 Binary files /dev/null and b/soul-api/scripts/__pycache__/test_transfer_notify.cpython-311.pyc differ diff --git a/soul-api/scripts/add-admin-users.sql b/soul-api/scripts/add-admin-users.sql new file mode 100644 index 00000000..50826f17 --- /dev/null +++ b/soul-api/scripts/add-admin-users.sql @@ -0,0 +1,18 @@ +-- 后台管理员用户表(与 soul-api 管理端鉴权配合) +CREATE TABLE IF NOT EXISTS admin_users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(128) NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'admin', + name VARCHAR(64) DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME DEFAULT NULL, + UNIQUE KEY uk_admin_users_username (username), + KEY idx_admin_users_deleted_at (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 初始超级管理员需通过 soul-api 启动时从 ADMIN_USERNAME/ADMIN_PASSWORD 自动迁移 +-- 或执行以下命令生成 bcrypt 哈希后插入(示例密码 admin123): +-- 在 Go 中: hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) diff --git a/soul-api/scripts/add-author-config-table.sql b/soul-api/scripts/add-author-config-table.sql new file mode 100644 index 00000000..3225572a --- /dev/null +++ b/soul-api/scripts/add-author-config-table.sql @@ -0,0 +1,23 @@ +-- 作者详情独立表,每个字段单独列,便于编辑与保存 +-- 执行:mysql -u user -p db < soul-api/scripts/add-author-config-table.sql + +CREATE TABLE IF NOT EXISTS author_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(80) NOT NULL DEFAULT '卡若', + avatar VARCHAR(4) NOT NULL DEFAULT 'K', + avatar_img VARCHAR(500) NOT NULL DEFAULT '', + title VARCHAR(200) NOT NULL DEFAULT '', + bio TEXT, + stats TEXT COMMENT 'JSON: [{"label":"","value":""}]', + highlights TEXT COMMENT 'JSON: ["a","b"]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 插入默认一行(仅当表为空时) +INSERT INTO author_config (name, avatar, avatar_img, title, bio, stats, highlights) +SELECT '卡若', 'K', '', 'Soul派对房主理人 · 私域运营专家', + '每天早上6点到9点,在Soul派对房分享真实的创业故事。专注私域运营与项目变现,用云阿米巴模式帮助创业者构建可持续的商业体系。', + '[{"label":"商业案例","value":"62"},{"label":"连续直播","value":"365天"},{"label":"派对分享","value":"1000+"}]', + '["5年私域运营经验","帮助100+品牌从0到1增长","连续创业者,擅长商业模式设计"]' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM author_config LIMIT 1); diff --git a/soul-api/scripts/add-chapter-edition-fields.sql b/soul-api/scripts/add-chapter-edition-fields.sql new file mode 100644 index 00000000..f678dda0 --- /dev/null +++ b/soul-api/scripts/add-chapter-edition-fields.sql @@ -0,0 +1,9 @@ +-- ============================================================ +-- 章节归属字段 - chapters 表(普通版/增值版) +-- 用途:添加文章时区分该章节属于普通版还是增值版 +-- 执行:mysql -u user -p database < soul-api/scripts/add-chapter-edition-fields.sql +-- ============================================================ + +-- 新增字段(若列已存在会报错,可忽略) +ALTER TABLE chapters ADD COLUMN edition_standard TINYINT(1) NULL DEFAULT 1 COMMENT '是否属于普通版,1=是'; +ALTER TABLE chapters ADD COLUMN edition_premium TINYINT(1) NULL DEFAULT 0 COMMENT '是否属于增值版,1=是'; diff --git a/soul-api/scripts/add-chapters-is-new.sql b/soul-api/scripts/add-chapters-is-new.sql new file mode 100644 index 00000000..43a81fc7 --- /dev/null +++ b/soul-api/scripts/add-chapters-is-new.sql @@ -0,0 +1,8 @@ +-- ============================================================ +-- stitch_soul P0:chapters 表新增 is_new 字段 +-- 用途:目录/首页「最新新增」标识,管理端可勾选 +-- 执行前请先备份数据库! +-- ============================================================ + +-- 新增 is_new 字段(若列已存在会报 Duplicate column name,可忽略) +ALTER TABLE chapters ADD COLUMN is_new TINYINT(1) NULL DEFAULT 0 COMMENT '是否标记为最新新增'; diff --git a/soul-api/scripts/add-mentors.sql b/soul-api/scripts/add-mentors.sql new file mode 100644 index 00000000..92e75cde --- /dev/null +++ b/soul-api/scripts/add-mentors.sql @@ -0,0 +1,40 @@ +-- stitch_soul 导师与预约表 +-- 执行:mysql -u user -p db < soul-api/scripts/add-mentors.sql + +CREATE TABLE IF NOT EXISTS `mentors` ( + `id` int NOT NULL AUTO_INCREMENT, + `avatar` varchar(500) DEFAULT '', + `name` varchar(80) NOT NULL, + `intro` varchar(500) DEFAULT '', + `tags` varchar(500) DEFAULT '', + `price_single` decimal(10,2) DEFAULT NULL, + `price_half_year` decimal(10,2) DEFAULT NULL, + `price_year` decimal(10,2) DEFAULT NULL, + `quote` varchar(500) DEFAULT '', + `why_find` text, + `offering` text, + `judgment_style` varchar(500) DEFAULT '', + `sort` int DEFAULT 0, + `enabled` tinyint(1) DEFAULT 1, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_enabled_sort` (`enabled`,`sort`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `mentor_consultations` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `mentor_id` int NOT NULL, + `consultation_type` varchar(20) NOT NULL, + `amount` decimal(10,2) NOT NULL, + `status` varchar(20) DEFAULT 'created', + `order_id` int DEFAULT NULL, + `scheduled_at` datetime DEFAULT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_mentor_id` (`mentor_id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/soul-api/scripts/add-order-refund-reason.sql b/soul-api/scripts/add-order-refund-reason.sql new file mode 100644 index 00000000..4dc32085 --- /dev/null +++ b/soul-api/scripts/add-order-refund-reason.sql @@ -0,0 +1,5 @@ +-- 订单表新增退款原因列 +-- 执行: mysql -u user -p db < soul-api/scripts/add-order-refund-reason.sql + +-- 若列已存在(如由 GORM AutoMigrate 创建)会报错,可忽略 +ALTER TABLE orders ADD COLUMN refund_reason VARCHAR(500) DEFAULT NULL COMMENT '退款原因' AFTER referrer_id; diff --git a/soul-api/scripts/add-orders-indexes.sql b/soul-api/scripts/add-orders-indexes.sql new file mode 100644 index 00000000..92ae7c24 --- /dev/null +++ b/soul-api/scripts/add-orders-indexes.sql @@ -0,0 +1,15 @@ +-- orders 表索引优化:提升 /api/orders 列表、营收统计查询性能 +-- 执行:mysql -u user -p database < soul-api/scripts/add-orders-indexes.sql +-- 幂等:索引已存在时跳过,可重复执行 + +-- 1. idx_status_created 复合索引 +SELECT COUNT(*) INTO @cnt FROM information_schema.statistics +WHERE table_schema = DATABASE() AND table_name = 'orders' AND index_name = 'idx_status_created'; +SET @sql = IF(@cnt = 0, 'ALTER TABLE orders ADD INDEX idx_status_created (status, created_at)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 2. idx_user_id 索引 +SELECT COUNT(*) INTO @cnt FROM information_schema.statistics +WHERE table_schema = DATABASE() AND table_name = 'orders' AND index_name = 'idx_user_id'; +SET @sql = IF(@cnt = 0, 'ALTER TABLE orders ADD INDEX idx_user_id (user_id)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/soul-api/scripts/add-sort-order-to-chapters.sql b/soul-api/scripts/add-sort-order-to-chapters.sql new file mode 100644 index 00000000..0d9b4bae --- /dev/null +++ b/soul-api/scripts/add-sort-order-to-chapters.sql @@ -0,0 +1,5 @@ +-- 为 chapters 表添加 sort_order 列(支持拖拽排序) +-- 执行:cd 项目根目录 && node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-sort-order-to-chapters.sql +-- 若 sort_order 已存在会报错,可忽略 + +ALTER TABLE chapters ADD COLUMN sort_order INT DEFAULT 0; diff --git a/soul-api/scripts/add-user-profile-fields.sql b/soul-api/scripts/add-user-profile-fields.sql new file mode 100644 index 00000000..4190793b --- /dev/null +++ b/soul-api/scripts/add-user-profile-fields.sql @@ -0,0 +1,15 @@ +-- P3 资料扩展:users 表新增个人资料字段(stitch_soul) +-- 用于 comprehensive_profile_editor、enhanced_professional_profile + +ALTER TABLE users ADD COLUMN mbti VARCHAR(16) NULL COMMENT 'MBTI类型' AFTER wechat_id; +ALTER TABLE users ADD COLUMN region VARCHAR(100) NULL COMMENT '地区' AFTER mbti; +ALTER TABLE users ADD COLUMN industry VARCHAR(100) NULL COMMENT '行业' AFTER region; +ALTER TABLE users ADD COLUMN position VARCHAR(100) NULL COMMENT '职位' AFTER industry; +ALTER TABLE users ADD COLUMN business_scale VARCHAR(100) NULL COMMENT '业务体量' AFTER position; +ALTER TABLE users ADD COLUMN skills VARCHAR(500) NULL COMMENT '我擅长' AFTER business_scale; +ALTER TABLE users ADD COLUMN story_best_month TEXT NULL COMMENT '最赚钱的一个月' AFTER skills; +ALTER TABLE users ADD COLUMN story_achievement TEXT NULL COMMENT '最有成就感的事' AFTER story_best_month; +ALTER TABLE users ADD COLUMN story_turning TEXT NULL COMMENT '人生转折点' AFTER story_achievement; +ALTER TABLE users ADD COLUMN help_offer VARCHAR(500) NULL COMMENT '我能帮助大家什么' AFTER story_turning; +ALTER TABLE users ADD COLUMN help_need VARCHAR(500) NULL COMMENT '我需要什么帮助' AFTER help_offer; +ALTER TABLE users ADD COLUMN project_intro TEXT NULL COMMENT '项目介绍' AFTER help_need; diff --git a/soul-api/scripts/add-vip-activated-at.sql b/soul-api/scripts/add-vip-activated-at.sql new file mode 100644 index 00000000..ee36d60f --- /dev/null +++ b/soul-api/scripts/add-vip-activated-at.sql @@ -0,0 +1,13 @@ +-- 新增 users.vip_activated_at:成为 VIP 时间,用于排序(后付款/后设置在前) +-- 执行:mysql -u user -p database < add-vip-activated-at.sql +-- 若列已存在会报错,可忽略 + +ALTER TABLE users ADD COLUMN vip_activated_at DATETIME NULL COMMENT '成为VIP时间,付款=pay_time,手动=now,排序用'; + +-- 可选:为已有 VIP 用户回填 vip_activated_at(取该用户最近一次 vip 订单的 pay_time) +-- UPDATE users u +-- SET u.vip_activated_at = ( +-- SELECT MAX(o.pay_time) FROM orders o +-- WHERE o.user_id = u.id AND o.product_type = 'vip' AND o.status = 'paid' +-- ) +-- WHERE u.is_vip = 1 AND u.vip_activated_at IS NULL; diff --git a/soul-api/scripts/add-vip-profile-fields.sql b/soul-api/scripts/add-vip-profile-fields.sql new file mode 100644 index 00000000..335a460b --- /dev/null +++ b/soul-api/scripts/add-vip-profile-fields.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- 会员资料字段 - users 表 +-- 用途:VIP 页保存资料、创业老板排行展示(与用户信息 phone/wechat_id 分离) +-- 执行前请先备份数据库! +-- ============================================================ + +-- 1. 检查:查看 users 表是否已有这些列(可选执行) +-- SHOW COLUMNS FROM users LIKE 'vip_name'; +-- SHOW COLUMNS FROM users LIKE 'vip_avatar'; +-- SHOW COLUMNS FROM users LIKE 'vip_project'; +-- SHOW COLUMNS FROM users LIKE 'vip_contact'; +-- SHOW COLUMNS FROM users LIKE 'vip_bio'; + +-- 2. 新增会员资料字段(若列已存在会报 Duplicate column name,可忽略该条) +-- -------------------------------------------------------- +ALTER TABLE users ADD COLUMN vip_name VARCHAR(100) NULL COMMENT '会员姓名(创业老板排行)'; +ALTER TABLE users ADD COLUMN vip_avatar VARCHAR(500) NULL COMMENT '会员头像'; +ALTER TABLE users ADD COLUMN vip_project VARCHAR(200) NULL COMMENT '项目名称'; +ALTER TABLE users ADD COLUMN vip_contact VARCHAR(100) NULL COMMENT '会员联系方式(展示用,与 phone/wechat_id 分离)'; +ALTER TABLE users ADD COLUMN vip_bio TEXT NULL COMMENT '一句话简介'; diff --git a/soul-api/scripts/add-vip-roles-and-fields.sql b/soul-api/scripts/add-vip-roles-and-fields.sql new file mode 100644 index 00000000..adf5e0f1 --- /dev/null +++ b/soul-api/scripts/add-vip-roles-and-fields.sql @@ -0,0 +1,25 @@ +-- 1. 新建 vip_roles 表 +CREATE TABLE IF NOT EXISTS vip_roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称', + sort INT DEFAULT 0 COMMENT '下拉展示顺序,越小越前', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) COMMENT '超级个体固定角色'; + +-- 2. 插入默认角色(UNIQUE name 防重复) +INSERT IGNORE INTO vip_roles (name, sort) VALUES + ('创始人', 1), + ('投资人', 2), + ('产品经理', 3), + ('技术负责人', 4), + ('运营总监', 5), + ('销售总监', 6), + ('市场总监', 7), + ('合伙人', 8), + ('顾问', 9), + ('品牌主理人', 10); + +-- 3. users 表新增 vip_sort、vip_role +ALTER TABLE users ADD COLUMN vip_sort INT NULL COMMENT '手动排序,越小越前'; +ALTER TABLE users ADD COLUMN vip_role VARCHAR(50) NULL COMMENT '角色:从 vip_roles 选或手动填写'; diff --git a/soul-api/scripts/fix-vip-orders.sql b/soul-api/scripts/fix-vip-orders.sql new file mode 100644 index 00000000..1860e29d --- /dev/null +++ b/soul-api/scripts/fix-vip-orders.sql @@ -0,0 +1,83 @@ +-- ============================================================ +-- VIP 订单修复脚本 +-- 场景:甲方开发的 VIP 支付可能未正确设置 product_type +-- 会员价:1980 元 +-- 执行前请先备份数据库! +-- ============================================================ + +-- 1. 诊断:查看当前疑似 VIP 订单的状态(执行后人工确认) +-- -------------------------------------------------------- +SELECT id, order_sn, user_id, product_type, amount, status, pay_time, created_at +FROM orders +WHERE amount = 1980 + AND (status = 'paid' OR status = 'completed') +ORDER BY pay_time DESC; + +-- 2. 统计:有多少条需要修复 +-- -------------------------------------------------------- +SELECT COUNT(*) AS need_fix_count +FROM orders +WHERE amount = 1980 + AND (status = 'paid' OR status = 'completed') + AND (product_type IS NULL OR product_type = '' OR product_type NOT IN ('vip', 'fullbook')); + +-- 3. 修复:将 1980 元已支付订单的 product_type 设为 'vip' +-- -------------------------------------------------------- +-- 条件:金额=1980 且 已支付 且 product_type 不是 vip/fullbook +UPDATE orders +SET product_type = 'vip' +WHERE amount = 1980 + AND (status = 'paid' OR status = 'completed') + AND (product_type IS NULL OR product_type = '' OR product_type NOT IN ('vip', 'fullbook')); + +-- 4. 兼容大小写:若 product_type 为 'VIP'、'Vip' 等,统一为小写 +-- -------------------------------------------------------- +UPDATE orders +SET product_type = 'vip' +WHERE amount = 1980 + AND (status = 'paid' OR status = 'completed') + AND LOWER(TRIM(product_type)) = 'vip' + AND product_type != 'vip'; + +-- 5. 验证:修复后应无遗漏 +-- -------------------------------------------------------- +SELECT id, order_sn, user_id, product_type, amount, status +FROM orders +WHERE amount = 1980 + AND (status = 'paid' OR status = 'completed') + AND product_type NOT IN ('vip', 'fullbook'); +-- 期望结果:0 行 + +-- ============================================================ +-- 可选:若线上 next-project 用 users 表存 is_vip,需确保字段存在 +-- 执行前请确认 users 表是否已有这些列! +-- ============================================================ + +-- 6. 检查 users 表是否有 VIP 相关列(MySQL) +-- SHOW COLUMNS FROM users LIKE 'is_vip'; +-- SHOW COLUMNS FROM users LIKE 'vip_expire_date'; + +-- 7. 若 users 表无 VIP 列,可执行以下 ALTER(按需取消注释) +-- -------------------------------------------------------- +-- ALTER TABLE users ADD COLUMN is_vip TINYINT(1) DEFAULT 0; +-- ALTER TABLE users ADD COLUMN vip_expire_date DATETIME NULL; +-- ALTER TABLE users ADD COLUMN vip_name VARCHAR(100) NULL; +-- ALTER TABLE users ADD COLUMN vip_avatar VARCHAR(500) NULL; +-- ALTER TABLE users ADD COLUMN vip_project VARCHAR(200) NULL; +-- ALTER TABLE users ADD COLUMN vip_contact VARCHAR(100) NULL; +-- ALTER TABLE users ADD COLUMN vip_bio TEXT NULL; + +-- 8. 从 orders 同步到 users(仅当用 users 表存 VIP 时) +-- 将 1980 元已支付订单对应的用户标记为 VIP,过期日 = pay_time + 365 天 +-- -------------------------------------------------------- +-- UPDATE users u +-- INNER JOIN ( +-- SELECT user_id, MAX(pay_time) AS last_pay +-- FROM orders +-- WHERE amount = 1980 +-- AND (status = 'paid' OR status = 'completed') +-- AND product_type IN ('vip', 'fullbook') +-- GROUP BY user_id +-- ) o ON u.id = o.user_id +-- SET u.is_vip = 1, +-- u.vip_expire_date = DATE_ADD(o.last_pay, INTERVAL 365 DAY); diff --git a/soul-api/scripts/sync-orders.sh b/soul-api/scripts/sync-orders.sh new file mode 100644 index 00000000..194a64a5 --- /dev/null +++ b/soul-api/scripts/sync-orders.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# 订单对账防漏单 - 宝塔定时任务用 +# 建议每 10 分钟执行一次 + +URL="${SYNC_ORDERS_URL:-https://soul.quwanzhi.com/api/cron/sync-orders}" + +curl -s -X GET "$URL" \ + -H "User-Agent: Baota-Cron/1.0" \ + --connect-timeout 10 \ + --max-time 30 + +echo "" diff --git a/soul-api/scripts/test-p0-endpoints.ps1 b/soul-api/scripts/test-p0-endpoints.ps1 new file mode 100644 index 00000000..3083cb19 --- /dev/null +++ b/soul-api/scripts/test-p0-endpoints.ps1 @@ -0,0 +1,61 @@ +# stitch_soul P0 接口验证脚本 +# 用法:先启动 soul-api,然后执行 .\scripts\test-p0-endpoints.ps1 +# 可指定 baseUrl:$env:API_BASE = "http://localhost:8080"; .\scripts\test-p0-endpoints.ps1 + +$base = if ($env:API_BASE) { $env:API_BASE } else { "http://localhost:8080" } + +Write-Host "=== stitch_soul P0 接口测试 ===" -ForegroundColor Cyan +Write-Host "Base: $base`n" -ForegroundColor Gray + +$passed = 0 +$failed = 0 + +function Test-Endpoint { + param($name, $path) + try { + $r = Invoke-RestMethod -Uri "$base$path" -Method GET -TimeoutSec 5 + if ($r.success -eq $true) { + Write-Host "[PASS] $name" -ForegroundColor Green + $script:passed++ + return $r + } else { + Write-Host "[FAIL] $name - success != true" -ForegroundColor Red + $script:failed++ + } + } catch { + Write-Host "[FAIL] $name - $($_.Exception.Message)" -ForegroundColor Red + $script:failed++ + } + return $null +} + +# 1. book/all-chapters(含 isNew) +$r1 = Test-Endpoint "GET /api/miniprogram/book/all-chapters" "/api/miniprogram/book/all-chapters" +if ($r1 -and $r1.data) { + $first = $r1.data[0] + if ($null -ne $first.PSObject.Properties['isNew']) { + Write-Host " -> isNew 字段存在" -ForegroundColor Green + } else { + Write-Host " -> 警告: isNew 字段可能缺失" -ForegroundColor Yellow + } +} + +# 2. book/recommended(精选推荐,前3章+tag) +$r2 = Test-Endpoint "GET /api/miniprogram/book/recommended" "/api/miniprogram/book/recommended" +if ($r2 -and $r2.data) { + Write-Host " -> 返回 $($r2.data.Count) 条,tag: $($r2.data[0].tag)" -ForegroundColor Gray +} + +# 3. book/latest-chapters(最新更新) +$r3 = Test-Endpoint "GET /api/miniprogram/book/latest-chapters" "/api/miniprogram/book/latest-chapters" +if ($r3 -and $r3.data) { + Write-Host " -> 返回 $($r3.data.Count) 条" -ForegroundColor Gray +} + +# 4. book/hot(热门章节) +$r4 = Test-Endpoint "GET /api/miniprogram/book/hot" "/api/miniprogram/book/hot" +if ($r4 -and $r4.data) { + Write-Host " -> 返回 $($r4.data.Count) 条" -ForegroundColor Gray +} + +Write-Host "`n=== 结果: $passed 通过, $failed 失败 ===" -ForegroundColor $(if ($failed -eq 0) { "Green" } else { "Yellow" }) diff --git a/soul-api/scripts/test_transfer_notify.py b/soul-api/scripts/test_transfer_notify.py new file mode 100644 index 00000000..0bf114f8 --- /dev/null +++ b/soul-api/scripts/test_transfer_notify.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +模拟微信「商家转账到零钱」结果通知回调,请求本地/远程回调接口, +用于验证:1)接口是否可达 2)wechat_callback_logs 表是否会写入一条记录。 + +说明:未使用真实签名与加密,服务端会验签失败并返回 500, +但仍会写入 wechat_callback_logs 一条 handler_result=fail 的记录。 +运行前请确保 soul-api 已启动;运行后请查表 wechat_callback_logs 是否有新行。 +""" + +import json +import ssl +import sys +from datetime import datetime +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError + +# 默认请求地址(可改环境或命令行) +DEFAULT_URL = "http://localhost:8080/api/payment/wechat/transfer/notify" + + +def main(): + args = [a for a in sys.argv[1:] if a and not a.startswith("-")] + insecure = "--insecure" in sys.argv or "-k" in sys.argv + url = args[0] if args else DEFAULT_URL + + if insecure and url.startswith("https://"): + print("已启用 --insecure,跳过 SSL 证书校验(仅用于本地/测试)") + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + else: + ctx = None + + # 模拟微信回调的请求体结构(真实场景中 resource.ciphertext 为 AEAD_AES_256_GCM 加密,这里用占位) + body = { + "id": "test-notify-id-" + datetime.now().strftime("%Y%m%d%H%M%S"), + "create_time": datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00"), + "resource_type": "encrypt-resource", + "event_type": "MCHTRANSFER.BILL.FINISHED", + "summary": "模拟转账结果通知", + "resource": { + "original_type": "mch_payment", + "algorithm": "AEAD_AES_256_GCM", + "ciphertext": "fake-base64-ciphertext-for-test", + "nonce": "fake-nonce", + "associated_data": "mch_payment", + }, + } + body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") + + headers = { + "Content-Type": "application/json", + "Wechatpay-Timestamp": str(int(datetime.now().timestamp())), + "Wechatpay-Nonce": "test-nonce-" + datetime.now().strftime("%H%M%S"), + "Wechatpay-Signature": "fake-signature-for-test", + "Wechatpay-Serial": "fake-serial-for-test", + } + + req = Request(url, data=body_bytes, headers=headers, method="POST") + + print(f"POST {url}") + print(f"Body (摘要): event_type={body['event_type']}, resource_type={body['resource_type']}") + print("-" * 50) + + try: + with urlopen(req, timeout=10, context=ctx) as resp: + print(f"HTTP 状态: {resp.status}") + raw = resp.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(raw) + print("响应 JSON:", json.dumps(parsed, ensure_ascii=False, indent=2)) + except Exception: + print("响应 body:", raw[:500]) + except HTTPError as e: + print(f"HTTP 状态: {e.code}") + raw = e.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(raw) + print("响应 JSON:", json.dumps(parsed, ensure_ascii=False, indent=2)) + except Exception: + print("响应 body:", raw[:500]) + except URLError as e: + print(f"请求失败: {e.reason}") + sys.exit(1) + + print("-" * 50) + print("请检查数据库表 wechat_callback_logs 是否有新记录(本次为模拟请求,预期会有一条 handler_result=fail 的记录)。") + + +if __name__ == "__main__": + main() diff --git a/soul-api/scripts/test_withdraw.py b/soul-api/scripts/test_withdraw.py new file mode 100644 index 00000000..77b54bbf --- /dev/null +++ b/soul-api/scripts/test_withdraw.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +请求提现测试接口:固定用户提现 1 元(默认),无需 admin_session。 +用法: + python test_withdraw.py + python test_withdraw.py https://soul.quwanzhi.com + python test_withdraw.py http://localhost:8080 2 +""" + +import json +import sys +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError +from urllib.parse import urlencode + +DEFAULT_BASE = "http://localhost:8080" +DEFAULT_USER_ID = "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg" +DEFAULT_AMOUNT = "1" + + +def main(): + base = DEFAULT_BASE + amount = DEFAULT_AMOUNT + args = [a for a in sys.argv[1:] if a] + if args: + if args[0].startswith("http://") or args[0].startswith("https://"): + base = args[0].rstrip("/") + args = args[1:] + if args: + amount = args[0] + + path = "/api/withdraw-test" + if not base.endswith(path): + base = base.rstrip("/") + path + url = f"{base}?{urlencode({'userId': DEFAULT_USER_ID, 'amount': amount})}" + + req = Request(url, method="GET") + req.add_header("Accept", "application/json") + + print(f"GET {url}") + print("-" * 50) + + try: + with urlopen(req, timeout=15) as resp: + raw = resp.read().decode("utf-8", errors="replace") + try: + print(json.dumps(json.loads(raw), ensure_ascii=False, indent=2)) + except Exception: + print(raw) + except HTTPError as e: + raw = e.read().decode("utf-8", errors="replace") + try: + print(json.dumps(json.loads(raw), ensure_ascii=False, indent=2)) + except Exception: + print(raw) + print(f"HTTP {e.code}", file=sys.stderr) + except URLError as e: + print(f"请求失败: {e.reason}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/soul-api/switch-env.bat b/soul-api/switch-env.bat new file mode 100644 index 00000000..1b686d36 --- /dev/null +++ b/soul-api/switch-env.bat @@ -0,0 +1,46 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" + +REM air(make dev) 默认用 .env.development;本 bat 用于切到正式环境后 go run / 直接运行 +echo. +echo Soul API - 环境切换 +echo ------------------- +echo air 已默认用开发配置,本工具用于切换 .env 供 go run 等使用 +echo. +echo 1. 正式环境 (.env.production) - 切到正式配置 +echo 2. 开发环境 (.env.development) +echo 3. 退出 +echo. + +set /p choice=请选择 (1/2/3): + +if "%choice%"=="1" goto prod +if "%choice%"=="2" goto dev +if "%choice%"=="3" goto end +echo 无效选择 +goto end + +:prod +if not exist .env.production ( + echo 错误: .env.production 不存在 + goto end +) +copy /y .env.production .env >nul +echo. +echo 已切换到: 正式环境 +goto end + +:dev +if not exist .env.development ( + echo 错误: .env.development 不存在 + goto end +) +copy /y .env.development .env >nul +echo. +echo 已切换到: 开发环境 +goto end + +:end +echo. +pause diff --git a/soul-api/uploads/avatars/1772165051417228100_qpc606.png b/soul-api/uploads/avatars/1772165051417228100_qpc606.png new file mode 100644 index 00000000..fabb67db Binary files /dev/null and b/soul-api/uploads/avatars/1772165051417228100_qpc606.png differ diff --git a/soul-api/uploads/avatars/1772442635632473500_925k91.jpeg b/soul-api/uploads/avatars/1772442635632473500_925k91.jpeg new file mode 100644 index 00000000..36f71485 Binary files /dev/null and b/soul-api/uploads/avatars/1772442635632473500_925k91.jpeg differ diff --git a/soul-api/uploads/avatars/1772527731359912500_uxz4c3.png b/soul-api/uploads/avatars/1772527731359912500_uxz4c3.png new file mode 100644 index 00000000..c02ae73f Binary files /dev/null and b/soul-api/uploads/avatars/1772527731359912500_uxz4c3.png differ diff --git a/soul-api/uploads/avatars/1772695740195258300_dmlhu7.jpeg b/soul-api/uploads/avatars/1772695740195258300_dmlhu7.jpeg new file mode 100644 index 00000000..31220d90 Binary files /dev/null and b/soul-api/uploads/avatars/1772695740195258300_dmlhu7.jpeg differ diff --git a/soul-api/wechat/info-2026-03-05T12-38-23.609.log.gz b/soul-api/wechat/info-2026-03-05T12-38-23.609.log.gz new file mode 100644 index 00000000..c2d65c43 Binary files /dev/null and b/soul-api/wechat/info-2026-03-05T12-38-23.609.log.gz differ diff --git a/soul-api/wechat/info-2026-03-06T08-22-36.646.log.gz b/soul-api/wechat/info-2026-03-06T08-22-36.646.log.gz new file mode 100644 index 00000000..f3640e5a Binary files /dev/null and b/soul-api/wechat/info-2026-03-06T08-22-36.646.log.gz differ diff --git a/soul-api/商家转账.md b/soul-api/商家转账.md new file mode 100644 index 00000000..863c941c --- /dev/null +++ b/soul-api/商家转账.md @@ -0,0 +1,24 @@ +req := &request.RequestTransferBills{ + Appid: "Appid", + OutBillNo: "OutBillNo", + TransferSceneId: "TransferSceneId", + Openid: "Openid", + UserName: "UserName", + TransferAmount: 1, + TransferRemark: "TransferRemark", + NotifyUrl: "NotifyUrl", + UserRecvPerception: "UserRecvPerception", + TransferSceneReportInfos: []request.TransferSceneReportInfo{ + { + InfoType: "InfoType", + InfoContent: "InfoContent", + }, + }, +} +ctx := c.Request.Context() +//fmt.Dump(ctx) +rs, err := services.PaymentApp.FundApp.TransferBills(ctx, req) +if err != nil { + panic(err) +} +c.JSON(http.StatusOK, rs) \ No newline at end of file diff --git a/soul-api/宝塔反向代理说明.txt b/soul-api/宝塔反向代理说明.txt new file mode 100644 index 00000000..a776fa15 --- /dev/null +++ b/soul-api/宝塔反向代理说明.txt @@ -0,0 +1,76 @@ +# soul-api 域名 404 原因与解决 + +## 原因 +域名请求先到 Nginx,若没有把请求转发到本机 8080 的 Go,或站点用了 root/静态目录,就会 404。 + +--- + +## 一、先确认 Go 是否在跑(必做) + +在宝塔终端或 SSH 里执行: + + curl -s http://127.0.0.1:8080/health + +- 若返回 {"status":"ok"}:说明 Go 正常,问题在 Nginx,看下面第二步。 +- 若连接被拒绝或超时:说明 8080 没在监听。去 宝塔 → Go项目管理 → soulApi → 服务状态,看是否“运行中”;看“项目日志”是否有报错。 + +--- + +## 二、Nginx 必须“整站走代理”,不能走 root + +添加了反向代理仍 404,多半是: + +- 站点默认有 location / { root ...; index ...; },请求被当成静态文件处理,/health 找不到就 404; +- 或反向代理只绑在了子路径(如 /api),/ 和 /health 没被代理。 + +做法:让 soulapi.quwanzhi.com 的**所有路径**都走 8080,不要用 root。 + +在宝塔:网站 → soulapi.quwanzhi.com → 设置 → 配置文件,找到该站点的 server { ... },按下面两种方式之一改。 + +### 方式 A:只保留一个 location /(推荐) + +把 server 里**原来的** location / { ... }(含 root、index 的那段)**删掉或注释掉**,只保留下面这一段: + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + +保存 → 重载 Nginx(或 宝塔 里点“重载配置”)。 + +### 方式 B:整站用下面这一整段 server(HTTPS 示例) + +若你希望整站只做反向代理、不混静态,可以把该站点的 server 块整体替换成下面内容(把 your_ssl_cert 等换成你实际的证书路径;没有 SSL 就只用 listen 80 那段): + + server { + listen 80; + listen 443 ssl http2; + server_name soulapi.quwanzhi.com; + # SSL 证书路径按宝塔实际填写,例如: + # ssl_certificate /www/server/panel/vhost/cert/soulapi.quwanzhi.com/fullchain.pem; + # ssl_certificate_key /www/server/panel/vhost/cert/soulapi.quwanzhi.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } + +保存并重载 Nginx。 + +--- + +## 三、改完后自测 + +- 本机:curl -s https://soulapi.quwanzhi.com/health +- 或浏览器打开:https://soulapi.quwanzhi.com/health +应看到:{"status":"ok"} +- 打开 https://soulapi.quwanzhi.com/ 应看到“部署成功”页面。 diff --git a/soul-api/开发文档/miniprogram接口补全说明.md b/soul-api/开发文档/miniprogram接口补全说明.md new file mode 100644 index 00000000..0c0c0536 --- /dev/null +++ b/soul-api/开发文档/miniprogram接口补全说明.md @@ -0,0 +1,55 @@ +# 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 new file mode 100644 index 00000000..6eb1737f --- /dev/null +++ b/soul-api/提现功能完整技术文档.md @@ -0,0 +1,1020 @@ +# 提现功能技术文档(微信支付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 new file mode 100644 index 00000000..ed1c4878 --- /dev/null +++ b/soul-api/文档索引.md @@ -0,0 +1,13 @@ +# soul-api 文档索引 + +> 以下文档已整理至 **开发文档**,此处保留为源码参考。 + +| 原文件 | 开发文档位置 | +|--------|--------------| +| 宝塔反向代理说明.txt | [8、部署/宝塔反向代理说明](../开发文档/8、部署/宝塔反向代理说明.md) | +| 订阅消息.md | [8、部署/订阅消息](../开发文档/8、部署/订阅消息.md) | +| 管理端鉴权设计.md | [6、后端/管理端鉴权设计](../开发文档/6、后端/管理端鉴权设计.md) | +| 商家转账.md | [8、部署/商家转账](../开发文档/8、部署/商家转账.md) | +| 提现功能完整技术文档.md | [8、部署/提现功能完整技术文档](../开发文档/8、部署/提现功能完整技术文档.md) | + +详见 [开发文档 README](../开发文档/README.md)。 diff --git a/soul-api/管理端鉴权设计.md b/soul-api/管理端鉴权设计.md new file mode 100644 index 00000000..068de0ad --- /dev/null +++ b/soul-api/管理端鉴权设计.md @@ -0,0 +1,116 @@ +# 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 new file mode 100644 index 00000000..8ffa5476 --- /dev/null +++ b/soul-api/订阅消息.md @@ -0,0 +1,23 @@ +data := &power.HashMap{ + "phrase4": power.StringMap{ + "value": "提现成功",//提现结果:提现成功、提现失败 + }, + "amount5": pwer.StringMap{ + "value": "¥8.6",//提现金额 + }, + "thing8": power.StringMap{ + "value": "微信打款成功,请点击查收",//备注,如果打款失败就提示请联系官方客服 + }, +} +MiniProgramApp.SubscribeMessage.Send(ctx, &request.RequestSubscribeMessageSend{ + ToUser: "OPENID",//需要根据订单号联表查询,提现表的user_id就是opend_id + TemplateID: "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",//这串是正确的 + Page: "/pages/my/my", + // developer为开发版;trial为体验版;formal为正式版 这块最好根据我的域名区分, + // 开发环境是souldev.quwanzhi.com 正式环境是 soulapi.quwanzhi.com + MiniProgramState: "formal", + Lang: "zh_CN", + Data: data, +}) + +{"create_time":"2026-02-10T18:02:54+08:00","out_bill_no":"WD1770691555206100","package_info":"ABBQO+oYAAABAAAAAAAk+yPZGrq+hyjETwKLaRAAAADnGpepZahT9IkJjn90+1qg6ZgBGi0Qjs+Pff8cmSa31vfwaewAXCM6F4nJ9wEZRdwDm4QridPWurNI1lWD7iSS7oX/YzP5XOnpeAlYX3tjHLTDdDQ=","state":"WAIT_USER_CONFIRM","transfer_bill_no":"1330000114850082602100071440076263"} \ No newline at end of file