优化提现功能,新增静默请求选项以支持无弹窗请求,提升用户体验。同时,更新微信转账逻辑,确保在未初始化转账客户端时正确处理状态,增强系统的灵活性和可维护性。调整API响应字段,确保一致性,提升代码可读性。

This commit is contained in:
乘风
2026-02-09 19:26:19 +08:00
parent fc57938bfe
commit ef1cb8cabd
20 changed files with 858 additions and 1378 deletions

View File

@@ -17,7 +17,9 @@ WECHAT_NOTIFY_URL=https://soul.quwanzhi.com/api/miniprogram/pay/notify
# 微信转账配置API v3
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
WECHAT_KEY_PATH=certs/apiclient_key.pem
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
WECHAT_TRANSFER_URL=https://soul.quwanzhi.com/api/payment/wechat/transfer/notify

View File

@@ -15,14 +15,14 @@ type Config struct {
TrustedProxies []string
CORSOrigins []string
Version string // APP_VERSION打包/部署前写在 .env/health 返回
// 微信小程序配置
WechatAppID string
WechatAppSecret string
WechatMchID string
WechatMchKey string
WechatNotifyURL string
// 微信转账配置API v3
WechatAPIv3Key string
WechatCertPath string
@@ -37,8 +37,7 @@ var defaultCORSOrigins = []string{
"http://127.0.0.1:5174",
"https://soul.quwanzhi.com",
"http://soul.quwanzhi.com",
"https://soulapi.quwanzhi.com",
"http://soulapi.quwanzhi.com",
"http://souladmin.quwanzhi.com",
}
// parseCORSOrigins 从环境变量 CORS_ORIGINS 读取(逗号分隔),未设置则用默认值
@@ -80,7 +79,7 @@ func Load() (*Config, error) {
if version == "" {
version = "0.0.0"
}
// 微信配置
wechatAppID := os.Getenv("WECHAT_APPID")
if wechatAppID == "" {
@@ -102,7 +101,7 @@ func Load() (*Config, error) {
if wechatNotifyURL == "" {
wechatNotifyURL = "https://soul.quwanzhi.com/api/miniprogram/pay/notify" // 默认回调地址
}
// 转账配置
wechatAPIv3Key := os.Getenv("WECHAT_APIV3_KEY")
if wechatAPIv3Key == "" {

View File

@@ -1,11 +1,13 @@
package handler
import (
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
@@ -77,12 +79,15 @@ func AdminWithdrawalsList(c *gin.Context) {
}
// 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"`
ID string `json:"id"`
Action string `json:"action"`
ErrorMessage string `json:"errorMessage"`
Reason string `json:"reason"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
@@ -95,25 +100,125 @@ func AdminWithdrawalsAction(c *gin.Context) {
if reason == "" && body.Action == "reject" {
reason = "管理员拒绝"
}
var newStatus string
db := database.DB()
now := time.Now()
switch body.Action {
case "approve":
newStatus = "success"
case "reject":
newStatus = "failed"
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
}
// 调用微信转账接口;未初始化时仅标记为已打款(线下打款)
outBatchNo := wechat.GenerateTransferBatchNo()
outDetailNo := wechat.GenerateTransferDetailNo()
remark := "提现"
if w.Remark != nil && *w.Remark != "" {
remark = *w.Remark
}
amountFen := int(w.Amount * 100)
if amountFen < 1 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现金额异常"})
return
}
params := wechat.TransferParams{
OutBatchNo: outBatchNo,
OutDetailNo: outDetailNo,
OpenID: openID,
Amount: amountFen,
Remark: remark,
BatchName: "用户提现",
BatchRemark: fmt.Sprintf("用户 %s 提现 %.2f 元", w.UserID, w.Amount),
}
result, err := wechat.InitiateTransfer(params)
if err != nil {
// 未初始化转账客户端:仅标记为 success提示线下打款
if err.Error() == "转账客户端未初始化" {
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "success",
"processed_at": now,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已标记为已打款。当前未接入微信转账,请线下打款。",
})
return
}
// 其他打款失败:记失败原因
failMsg := err.Error()
_ = 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
}
// 打款已受理,更新为处理中并保存微信批次号
processingStatus := "processing"
batchID := result.BatchID
_ = db.Model(&w).Updates(map[string]interface{}{
"status": processingStatus,
"batch_no": outBatchNo,
"detail_no": outDetailNo,
"batch_id": batchID,
"processed_at": now,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已发起打款,微信处理中",
"data": gin.H{"batch_id": batchID},
})
return
default:
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 approve 或 reject"})
return
}
now := time.Now()
err := database.DB().Model(&model.Withdrawal{}).Where("id = ?", body.ID).Updates(map[string]interface{}{
"status": newStatus,
"error_message": 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": "操作成功"})
}

View File

@@ -235,17 +235,18 @@ func ReferralData(c *gin.Context) {
totalVisits = int(visitCount)
}
// 5. 提现统计
// 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 = 'pending'", userId).
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 = 'success'", userId).
Where("user_id = ? AND status = ?", userId, "success").
Scan(&successWithdraw)
pendingWithdrawAmount := pendingWithdraw.Total

View File

@@ -1,152 +1,135 @@
package handler
import (
"encoding/json"
"fmt"
"math"
"net/http"
"os"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// WithdrawPost POST /api/withdraw 创建提现申请并发起微信转账
// computeAvailableWithdraw 与小程序 / referral 页可提现逻辑一致:可提现 = 累计佣金 - 已提现 - 待审核
// 用于 referral/data 展示与 withdraw 接口二次查库校验(不信任前端传参)
func computeAvailableWithdraw(db *gorm.DB, userID string) (available, totalCommission, withdrawn, pending float64, minAmount float64) {
distributorShare := 0.9
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 share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
if m, ok := config["minWithdrawAmount"].(float64); ok {
minAmount = m
}
}
}
var sumOrder struct{ Total float64 }
db.Model(&model.Order{}).Where("referrer_id = ? AND status = ?", userID, "paid").
Select("COALESCE(SUM(amount), 0) as total").Scan(&sumOrder)
totalCommission = sumOrder.Total * distributorShare
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"` // 可选,实名校验用
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
}
if req.Amount < 1 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "最低提现金额为1元"})
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
}
db := database.DB()
// 查询用户信息,获取 openid 和待提现金额
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
}
// 检查待结算收益是否足够
pendingEarnings := 0.0
if user.PendingEarnings != nil {
pendingEarnings = *user.PendingEarnings
}
if pendingEarnings < req.Amount {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": fmt.Sprintf("可提现金额不足(当前:%.2f元)", pendingEarnings),
})
return
}
// 生成转账单号
outBatchNo := wechat.GenerateTransferBatchNo()
outDetailNo := wechat.GenerateTransferDetailNo()
// 创建提现记录
withdrawID := generateWithdrawID()
status := "pending"
remark := req.Remark
if remark == "" {
remark = "提现"
}
withdrawal := model.Withdrawal{
ID: outDetailNo,
UserID: req.UserID,
Amount: req.Amount,
Status: &status,
BatchNo: &outBatchNo,
DetailNo: &outDetailNo,
Remark: &remark,
ID: withdrawID,
UserID: req.UserID,
Amount: req.Amount,
Status: &status,
}
if err := db.Create(&withdrawal).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建提现记录失败"})
return
}
// 发起微信转账
transferAmount := int(req.Amount * 100) // 转为分
transferParams := wechat.TransferParams{
OutBatchNo: outBatchNo,
OutDetailNo: outDetailNo,
OpenID: *user.OpenID,
Amount: transferAmount,
UserName: req.UserName,
Remark: remark,
BatchName: "用户提现",
BatchRemark: fmt.Sprintf("用户 %s 提现 %.2f 元", req.UserID, req.Amount),
}
result, err := wechat.InitiateTransfer(transferParams)
if err != nil {
// 转账失败,更新提现状态为失败
failedStatus := "failed"
failReason := fmt.Sprintf("发起转账失败: %v", err)
db.Model(&withdrawal).Updates(map[string]interface{}{
"status": failedStatus,
"fail_reason": failReason,
})
if err := db.Select("ID", "UserID", "Amount", "Status").Create(&withdrawal).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "发起转账失败,请稍后重试",
"message": "创建提现记录失败",
"error": err.Error(),
})
return
}
// 更新提现记录状态
processingStatus := "processing"
batchID := result.BatchID
db.Model(&withdrawal).Updates(map[string]interface{}{
"status": processingStatus,
"batch_id": batchID,
})
// 扣减用户的待结算收益,增加已提现金额
db.Model(&user).Updates(map[string]interface{}{
"pending_earnings": db.Raw("pending_earnings - ?", req.Amount),
"withdrawn_earnings": db.Raw("COALESCE(withdrawn_earnings, 0) + ?", req.Amount),
})
fmt.Printf("[Withdraw] 用户 %s 提现 %.2f 元,转账批次号: %s\n", req.UserID, req.Amount, outBatchNo)
_ = db.Model(&withdrawal).Updates(map[string]interface{}{
"remark": remark,
"wechat_openid": user.OpenID,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "提现申请已提交,预计2小时内到账",
"message": "提现申请已提交,审核通过后将打款至您的微信零钱",
"data": map[string]interface{}{
"id": withdrawal.ID,
"amount": req.Amount,
"status": processingStatus,
"out_batch_no": outBatchNo,
"batch_id": result.BatchID,
"created_at": withdrawal.CreatedAt,
"id": withdrawal.ID,
"amount": req.Amount,
"status": "pending",
"created_at": withdrawal.CreatedAt,
},
})
}

View File

@@ -6,7 +6,10 @@ import (
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"soul-api/internal/config"
@@ -45,11 +48,28 @@ func InitTransfer(c *config.Config) error {
return nil
}
// loadPrivateKey 加载商户私钥
// loadPrivateKey 加载商户私钥。path 支持本地路径或 http(s) 链接(如 OSS 地址)。
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
privateKeyBytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
var privateKeyBytes []byte
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
resp, err := http.Get(path)
if err != nil {
return nil, fmt.Errorf("从链接获取私钥失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取私钥返回异常状态: %d", resp.StatusCode)
}
privateKeyBytes, err = io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取私钥内容失败: %w", err)
}
} else {
var err error
privateKeyBytes, err = os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
}
}
block, _ := pem.Decode(privateKeyBytes)

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
exit status 1exit status 1exit status 1exit status 1

Binary file not shown.