新增技术文档,详细描述了项目的技术栈、配置、鉴权与安全、数据层等内容。同时,更新小程序页面以支持收益数据的加载与刷新功能,优化用户体验。新增收益接口以返回用户的累计收益和可提现金额,并调整相关逻辑以确保数据准确性。

This commit is contained in:
乘风
2026-02-11 12:05:54 +08:00
parent a174d8e16d
commit 2c9364fd2f
11 changed files with 276 additions and 55 deletions

View File

@@ -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) {

View File

@@ -97,27 +97,26 @@
<view class="bg-decoration bg-decoration-brand"></view>
<view class="earnings-content">
<!-- 标题行 -->
<!-- 标题行:右侧为刷新图标 -->
<view class="earnings-header">
<view class="earnings-title-wrap">
<text class="earnings-icon">💰</text>
<text class="earnings-title">我的收益</text>
</view>
<view class="earnings-link" bindtap="goToReferral">
<text class="link-text brand-color">推广中心</text>
<text class="link-arrow brand-color">↗</text>
<view class="earnings-refresh-wrap" bindtap="refreshEarnings">
<text class="earnings-refresh-icon {{earningsRefreshing ? 'earnings-refresh-spin' : ''}}">↻</text>
</view>
</view>
<!-- 收益数据 -->
<!-- 收益数据:加载中显示 - 占位 -->
<view class="earnings-data">
<view class="earnings-main">
<text class="earnings-label">累计收益</text>
<text class="earnings-amount-large gold-gradient">¥{{earnings}}</text>
<text class="earnings-amount-large gold-gradient">{{earningsLoading ? '-' : '¥' + earnings}}</text>
</view>
<view class="earnings-secondary">
<text class="earnings-label">可提现</text>
<text class="earnings-amount-medium">¥{{pendingEarnings}}</text>
<text class="earnings-amount-medium">{{earningsLoading ? '-' : '¥' + pendingEarnings}}</text>
</view>
</view>

View File

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

View File

@@ -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 & "
"sleep 3; pgrep -f './soul-api' >/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 & "
"sleep 3; T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do "
"[ \"$(readlink -f /proc/$p/cwd 2>/dev/null)\" = \"$T\" ] && echo RESTART_OK && exit 0; done; echo RESTART_FAIL"
) % project_path
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=20)
out = stdout.read().decode("utf-8", errors="replace").strip()

View File

@@ -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 & "
"sleep 3; pgrep -f './soul-api' >/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 & "
"sleep 3; T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do "
"[ \"$(readlink -f /proc/$p/cwd 2>/dev/null)\" = \"$T\" ] && echo RESTART_OK && exit 0; done; echo RESTART_FAIL"
) % project_path
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=20)
out = stdout.read().decode("utf-8", errors="replace").strip()

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

54
技术文档.md Normal file
View File

@@ -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**SSEGin 间接)
- **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。