diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index 07f1068d..6a308a54 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -20,8 +20,10 @@ Page({ totalSections: 62, readCount: 0, referralCount: 0, - earnings: 0, - pendingEarnings: 0, + earnings: '-', + pendingEarnings: '-', + earningsLoading: true, + earningsRefreshing: false, // 阅读统计 totalReadTime: 0, @@ -123,7 +125,7 @@ Page({ const userIdShort = userId.length > 20 ? userId.slice(0, 10) + '...' + userId.slice(-6) : userId const userWechat = wx.getStorageSync('user_wechat') || userInfo.wechat || '' - // 先设基础信息;收益数据由 loadReferralEarnings 从推广中心同源接口拉取并覆盖 + // 先设基础信息;收益由 loadMyEarnings 专用接口拉取,加载前用 - 占位 this.setData({ isLoggedIn: true, userInfo, @@ -131,12 +133,13 @@ Page({ userWechat, readCount: Math.min(app.getReadCount(), this.data.totalSections || 62), referralCount: userInfo.referralCount || 0, - earnings: '0.00', - pendingEarnings: '0.00', + earnings: '-', + pendingEarnings: '-', + earningsLoading: true, recentChapters: recentList, totalReadTime: Math.floor(Math.random() * 200) + 50 }) - this.loadReferralEarnings() + this.loadMyEarnings() this.loadPendingConfirm() } else { this.setData({ @@ -145,8 +148,9 @@ Page({ userIdShort: '', readCount: app.getReadCount(), referralCount: 0, - earnings: '0.00', - pendingEarnings: '0.00', + earnings: '-', + pendingEarnings: '-', + earningsLoading: false, recentChapters: [] }) } @@ -250,32 +254,48 @@ Page({ } }, - // 从与推广中心相同的接口拉取收益数据并更新展示(累计收益 = totalCommission,可提现 = 累计-已提现-待审核) - async loadReferralEarnings() { + // 专用接口:拉取「我的收益」卡片数据(累计、可提现、推荐人数) + async loadMyEarnings() { const userInfo = app.globalData.userInfo - if (!app.globalData.isLoggedIn || !userInfo || !userInfo.id) return - + if (!app.globalData.isLoggedIn || !userInfo || !userInfo.id) { + this.setData({ earningsLoading: false }) + return + } const formatMoney = (num) => (typeof num === 'number' ? num.toFixed(2) : '0.00') - try { - const res = await app.request('/api/miniprogram/referral/data?userId=' + userInfo.id) - if (!res || !res.success || !res.data) return - + const res = await app.request('/api/miniprogram/earnings?userId=' + userInfo.id) + if (!res || !res.success || !res.data) { + this.setData({ earningsLoading: false, earnings: '0.00', pendingEarnings: '0.00' }) + return + } const d = res.data - const totalCommissionNum = d.totalCommission || 0 - const withdrawnNum = d.withdrawnEarnings || 0 - const pendingWithdrawNum = d.pendingWithdrawAmount || 0 - const availableEarningsNum = totalCommissionNum - withdrawnNum - pendingWithdrawNum - this.setData({ - earnings: formatMoney(totalCommissionNum), - pendingEarnings: formatMoney(availableEarningsNum), - referralCount: d.referralCount || d.stats?.totalBindings || this.data.referralCount + earnings: formatMoney(d.totalCommission), + pendingEarnings: formatMoney(d.availableEarnings), + referralCount: d.referralCount ?? this.data.referralCount, + earningsLoading: false, + earningsRefreshing: false }) } catch (e) { - console.log('[My] 拉取推广收益失败:', e && e.message) + console.log('[My] 拉取我的收益失败:', e && e.message) + this.setData({ + earningsLoading: false, + earningsRefreshing: false, + earnings: '0.00', + pendingEarnings: '0.00' + }) } }, + + // 点击刷新图标:刷新我的收益 + async refreshEarnings() { + if (!this.data.isLoggedIn) return + if (this.data.earningsRefreshing) return + this.setData({ earningsRefreshing: true }) + wx.showToast({ title: '刷新中...', icon: 'loading', duration: 2000 }) + await this.loadMyEarnings() + wx.showToast({ title: '已刷新', icon: 'success' }) + }, // 微信原生获取头像(button open-type="chooseAvatar" 回调) async onChooseAvatar(e) { diff --git a/miniprogram/pages/my/my.wxml b/miniprogram/pages/my/my.wxml index e33dd9ed..2a5e74d2 100644 --- a/miniprogram/pages/my/my.wxml +++ b/miniprogram/pages/my/my.wxml @@ -97,27 +97,26 @@ - + 💰 我的收益 - - 推广中心 - + + - + 累计收益 - ¥{{earnings}} + {{earningsLoading ? '-' : '¥' + earnings}} 可提现 - ¥{{pendingEarnings}} + {{earningsLoading ? '-' : '¥' + pendingEarnings}} diff --git a/miniprogram/pages/my/my.wxss b/miniprogram/pages/my/my.wxss index 9c4b0e3e..2fe68b6d 100644 --- a/miniprogram/pages/my/my.wxss +++ b/miniprogram/pages/my/my.wxss @@ -471,6 +471,36 @@ font-weight: 600; } +/* 我的收益 - 刷新图标 */ +.earnings-refresh-wrap { + display: flex; + align-items: center; + justify-content: center; + width: 64rpx; + height: 64rpx; + border-radius: 50%; + background: rgba(0, 206, 209, 0.15); +} + +.earnings-refresh-wrap:active { + background: rgba(0, 206, 209, 0.3); +} + +.earnings-refresh-icon { + font-size: 36rpx; + font-weight: 600; + color: #00CED1; +} + +.earnings-refresh-spin { + animation: earnings-spin 0.8s linear infinite; +} + +@keyframes earnings-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + /* 收益数据 */ .earnings-data { display: flex; diff --git a/soul-api/dev_dev.py b/soul-api/dev_dev.py index b19d5afa..48b11b6d 100644 --- a/soul-api/dev_dev.py +++ b/soul-api/dev_dev.py @@ -142,6 +142,28 @@ def set_env_port(env_path, port=DEPLOY_PORT): 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] 打包部署文件 ...") @@ -160,7 +182,8 @@ def pack_deploy(root, binary_path, include_env=True): print(" [已包含] .env.example -> .env (请服务器上检查配置)") if os.path.isfile(staging_env): set_env_port(staging_env, DEPLOY_PORT) - print(" [已设置] PORT=%s(部署用)" % 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): @@ -276,11 +299,13 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto 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:用 setsid nohup 避免断开杀进程,多等几秒再检测 + # SSH:只杀「工作目录为本项目」的 soul-api,避免误杀其他 Go 项目 restart_cmd = ( - "cd %s && pkill -f './soul-api' 2>/dev/null; sleep 2; " - "setsid nohup ./soul-api >> soul-api.log 2>&1 /dev/null && echo RESTART_OK || echo RESTART_FAIL" + "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() diff --git a/soul-api/devlop.py b/soul-api/devlop.py index 3d2056f1..94faaf1b 100644 --- a/soul-api/devlop.py +++ b/soul-api/devlop.py @@ -142,6 +142,28 @@ def set_env_port(env_path, port=DEPLOY_PORT): 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] 打包部署文件 ...") @@ -160,7 +182,8 @@ def pack_deploy(root, binary_path, include_env=True): print(" [已包含] .env.example -> .env (请服务器上检查配置)") if os.path.isfile(staging_env): set_env_port(staging_env, DEPLOY_PORT) - print(" [已设置] PORT=%s(部署用)" % 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): @@ -276,11 +299,13 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto 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:用 setsid nohup 避免断开杀进程,多等几秒再检测 + # SSH:只杀「工作目录为本项目」的 soul-api,避免误杀其他 Go 项目 restart_cmd = ( - "cd %s && pkill -f './soul-api' 2>/dev/null; sleep 2; " - "setsid nohup ./soul-api >> soul-api.log 2>&1 /dev/null && echo RESTART_OK || echo RESTART_FAIL" + "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() diff --git a/soul-api/internal/config/config.go b/soul-api/internal/config/config.go index f9cd50b9..e457c966 100644 --- a/soul-api/internal/config/config.go +++ b/soul-api/internal/config/config.go @@ -18,11 +18,12 @@ type Config struct { Version string // APP_VERSION,打包/部署前写在 .env,/health 返回 // 微信小程序配置 - WechatAppID string - WechatAppSecret string - WechatMchID string - WechatMchKey string - WechatNotifyURL string + WechatAppID string + WechatAppSecret string + WechatMchID string + WechatMchKey string + WechatNotifyURL string + WechatMiniProgramState string // 订阅消息跳转版本:developer/formal,从 .env WECHAT_MINI_PROGRAM_STATE 读取 // 微信转账配置(API v3) WechatAPIv3Key string @@ -119,6 +120,10 @@ func Load() (*Config, error) { if wechatNotifyURL == "" { wechatNotifyURL = "https://soul.quwanzhi.com/api/miniprogram/pay/notify" // 默认回调地址 } + wechatMiniProgramState := os.Getenv("WECHAT_MINI_PROGRAM_STATE") + if wechatMiniProgramState != "developer" && wechatMiniProgramState != "trial" { + wechatMiniProgramState = "formal" // 默认正式版 + } // 转账配置 wechatAPIv3Key := os.Getenv("WECHAT_APIV3_KEY") @@ -162,12 +167,13 @@ func Load() (*Config, error) { TrustedProxies: []string{"127.0.0.1", "::1"}, CORSOrigins: parseCORSOrigins(), Version: version, - WechatAppID: wechatAppID, - WechatAppSecret: wechatAppSecret, - WechatMchID: wechatMchID, - WechatMchKey: wechatMchKey, - WechatNotifyURL: wechatNotifyURL, - WechatAPIv3Key: wechatAPIv3Key, + WechatAppID: wechatAppID, + WechatAppSecret: wechatAppSecret, + WechatMchID: wechatMchID, + WechatMchKey: wechatMchKey, + WechatNotifyURL: wechatNotifyURL, + WechatMiniProgramState: wechatMiniProgramState, + WechatAPIv3Key: wechatAPIv3Key, WechatCertPath: wechatCertPath, WechatKeyPath: wechatKeyPath, WechatSerialNo: wechatSerialNo, diff --git a/soul-api/internal/handler/referral.go b/soul-api/internal/handler/referral.go index aa7fa4e1..f7e03e5b 100644 --- a/soul-api/internal/handler/referral.go +++ b/soul-api/internal/handler/referral.go @@ -429,6 +429,67 @@ func round(val float64, precision int) float64 { return math.Round(val*ratio) / ratio } +// MyEarnings GET /api/miniprogram/earnings 仅返回「我的收益」卡片所需数据(累计、可提现、推荐人数),用于我的页展示与刷新 +func MyEarnings(c *gin.Context) { + userId := c.Query("userId") + if userId == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户ID不能为空"}) + return + } + db := database.DB() + distributorShare := 0.9 + var cfg model.SystemConfig + if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil { + var config map[string]interface{} + if _ = json.Unmarshal(cfg.ConfigValue, &config); config["distributorShare"] != nil { + if share, ok := config["distributorShare"].(float64); ok { + distributorShare = share / 100 + } + } + } + var user model.User + if err := db.Where("id = ?", userId).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) + return + } + var paidOrders []struct { + Amount float64 + } + db.Model(&model.Order{}). + Select("amount"). + Where("referrer_id = ? AND status = 'paid'", userId). + Find(&paidOrders) + totalAmount := 0.0 + for _, o := range paidOrders { + totalAmount += o.Amount + } + var pendingWithdraw struct{ Total float64 } + db.Model(&model.Withdrawal{}). + Select("COALESCE(SUM(amount), 0) as total"). + Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}). + Scan(&pendingWithdraw) + var successWithdraw struct{ Total float64 } + db.Model(&model.Withdrawal{}). + Select("COALESCE(SUM(amount), 0) as total"). + Where("user_id = ? AND status = ?", userId, "success"). + Scan(&successWithdraw) + totalCommission := totalAmount * distributorShare + pendingWithdrawAmount := pendingWithdraw.Total + withdrawnFromTable := successWithdraw.Total + availableEarnings := totalCommission - withdrawnFromTable - pendingWithdrawAmount + if availableEarnings < 0 { + availableEarnings = 0 + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "totalCommission": round(totalCommission, 2), + "availableEarnings": round(availableEarnings, 2), + "referralCount": getIntValue(user.ReferralCount), + }, + }) +} + // ReferralVisit POST /api/referral/visit 记录推荐访问(不需登录) func ReferralVisit(c *gin.Context) { var req struct { diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index 5b4d3188..bde9183b 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -223,6 +223,7 @@ func Setup(cfg *config.Config) *gin.Engine { 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) diff --git a/soul-api/internal/wechat/miniprogram.go b/soul-api/internal/wechat/miniprogram.go index da970a2d..9d7904d3 100644 --- a/soul-api/internal/wechat/miniprogram.go +++ b/soul-api/internal/wechat/miniprogram.go @@ -419,8 +419,8 @@ func SendWithdrawSubscribeMessage(ctx context.Context, openID string, amount flo "thing8": object.HashMap{"value": thing8}, } state := "formal" - if cfg != nil && cfg.Mode == "debug" { - state = "developer" + if cfg != nil && cfg.WechatMiniProgramState != "" { + state = cfg.WechatMiniProgramState } _, err := miniProgramApp.SubscribeMessage.Send(ctx, &subrequest.RequestSubscribeMessageSend{ ToUser: openID, diff --git a/soul-api/soul-api b/soul-api/soul-api index 4b540e3a..2ced1770 100644 Binary files a/soul-api/soul-api and b/soul-api/soul-api differ diff --git a/技术文档.md b/技术文档.md new file mode 100644 index 00000000..2a479f92 --- /dev/null +++ b/技术文档.md @@ -0,0 +1,54 @@ +# soul-api Go 技术栈 + +## 语言与运行时 + +- **Go 1.25** + +## Web 框架与 HTTP + +- **Gin**(`github.com/gin-gonic/gin`):HTTP 路由与请求处理 +- **gin-contrib/cors**:跨域 +- **unrolled/secure**:安全头(HTTPS 重定向、HSTS 等,在 `middleware.Secure()` 中使用) + +## 数据层 + +- **GORM**(`gorm.io/gorm`):ORM +- **GORM MySQL 驱动**(`gorm.io/driver/mysql`):连接 MySQL +- **go-sql-driver/mysql**:底层 MySQL 驱动(GORM 间接依赖) + +## 微信生态 + +- **PowerWeChat**(`github.com/ArtisanCloud/PowerWeChat/v3`):微信开放能力(小程序、支付、商家转账等) +- **PowerLibs**(`github.com/ArtisanCloud/PowerLibs/v3`):PowerWeChat 依赖 + +## 配置与环境 + +- **godotenv**(`github.com/joho/godotenv`):从 `.env` 加载环境变量 +- 业务配置集中在 `internal/config`,通过 `config.Load()` 读取 + +## 鉴权与安全 + +- **golang-jwt/jwt/v5**:管理端 JWT 签发与校验(`internal/auth/adminjwt.go`) +- 管理端路由使用 `middleware.AdminAuth()` 做 JWT 校验 + +## 工具与间接依赖 + +- **golang.org/x/time**:时间/限流相关(如 `rate`) +- **gin-contrib/sse**:SSE(Gin 间接) +- **bytedance/sonic**:JSON 编解码(Gin 默认) +- **go-playground/validator**:请求体校验(Gin 的 `ShouldBindJSON` 等) +- **redis/go-redis**:仅在依赖图中出现(PowerWeChat 等间接引入),项目代码中未直接使用 Redis + +## 项目结构(技术栈视角) + +| 层级 | 技术/约定 | +|----------|------------| +| 入口 | `cmd/server/main.go`,标准库 `net/http` + Gin | +| 路由 | `internal/router`,Gin Group(`/api`、`/admin`、`/miniprogram` 等) | +| 中间件 | CORS、Secure、限流(`middleware.RateLimiter`)、管理端 JWT | +| 业务逻辑 | `internal/handler`,GORM + `internal/model` | +| 数据访问 | `internal/database` 提供 `DB() *gorm.DB`,统一用 GORM | +| 微信相关 | `internal/wechat`(小程序、支付、转账等封装) | +| 开发工具 | `.air.toml` 热重载、Makefile | + +整体上是一个 **Gin + GORM + MySQL + 微信 PowerWeChat + JWT 管理端鉴权** 的 Go 后端,面向小程序与管理端 API。