优化提现记录页面,新增“领取零钱”按钮以支持用户提现操作,并更新相关API以获取转账信息。调整页面布局以提升用户体验,同时确保状态字段的一致性和可读性。

This commit is contained in:
乘风
2026-02-09 21:29:52 +08:00
parent 0e716cbc6e
commit ae35460622
16 changed files with 528 additions and 566 deletions

View File

@@ -211,10 +211,14 @@ func AdminWithdrawalsAction(c *gin.Context) {
"batch_id": batchID,
"processed_at": now,
}).Error
// 始终返回 out_batch_no 便于追踪batch_id 为微信返回,可能为空
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已发起打款,微信处理中",
"data": gin.H{"batch_id": batchID},
"data": gin.H{
"batch_id": batchID,
"out_batch_no": outBatchNo,
},
})
return

View File

@@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
@@ -287,33 +288,19 @@ func miniprogramPayPost(c *gin.Context) {
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
}
// 调用微信统一下单
params := map[string]string{
"body": description,
"out_trade_no": orderSn,
"total_fee": fmt.Sprintf("%d", totalFee),
"spbill_create_ip": clientIP,
"notify_url": "https://soul.quwanzhi.com/api/miniprogram/pay/notify",
"trade_type": "JSAPI",
"openid": req.OpenID,
"attach": fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID),
}
result, err := wechat.PayV2UnifiedOrder(params)
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.StatusInternalServerError, gin.H{"success": false, "error": fmt.Sprintf("微信支付请求失败: %v", err)})
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("微信支付请求失败: %v", err)})
return
}
prepayID := result["prepay_id"]
if prepayID == "" {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "微信支付返回数据异常"})
payParams, err := wechat.GetJSAPIPayParams(prepayID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("生成支付参数失败: %v", err)})
return
}
// 生成小程序支付参数
payParams := wechat.GenerateJSAPIPayParams(prepayID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
@@ -332,7 +319,8 @@ func miniprogramPayGet(c *gin.Context) {
return
}
result, err := wechat.PayV2OrderQuery(orderSn)
ctx := c.Request.Context()
tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, orderSn)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -344,10 +332,7 @@ func miniprogramPayGet(c *gin.Context) {
return
}
// 映射微信支付状态
tradeState := result["trade_state"]
status := "paying"
switch tradeState {
case "SUCCESS":
status = "paid"
@@ -357,175 +342,130 @@ func miniprogramPayGet(c *gin.Context) {
status = "refunded"
}
totalFee := 0
if result["total_fee"] != "" {
fmt.Sscanf(result["total_fee"], "%d", &totalFee)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"status": status,
"orderSn": orderSn,
"transactionId": result["transaction_id"],
"transactionId": transactionID,
"totalFee": totalFee,
},
})
}
// MiniprogramPayNotify POST /api/miniprogram/pay/notify
// MiniprogramPayNotify POST /api/miniprogram/pay/notifyv3 支付回调PowerWeChat 验签解密)
func MiniprogramPayNotify(c *gin.Context) {
// 读取 XML body
body, err := c.GetRawData()
if err != nil {
c.String(http.StatusBadRequest, failResponse())
return
}
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)
// 解析 XML
data := wechat.XMLToMap(string(body))
// 验证签名
if !wechat.VerifyPayNotify(data) {
fmt.Println("[PayNotify] 签名验证失败")
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,
}
db.Create(&order)
} else if *order.Status != "paid" {
status := "paid"
now := time.Now()
db.Model(&order).Updates(map[string]interface{}{
"status": status,
"transaction_id": transactionID,
"pay_time": now,
})
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 == "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)
}
return nil
})
if err != nil {
fmt.Printf("[PayNotify] 处理回调失败: %v\n", err)
c.String(http.StatusOK, failResponse())
return
}
// 检查支付结果
if data["return_code"] != "SUCCESS" || data["result_code"] != "SUCCESS" {
fmt.Printf("[PayNotify] 支付未成功: %s\n", data["err_code"])
c.String(http.StatusOK, successResponse())
return
}
orderSn := data["out_trade_no"]
transactionID := data["transaction_id"]
totalFee := 0
fmt.Sscanf(data["total_fee"], "%d", &totalFee)
totalAmount := float64(totalFee) / 100
openID := data["openid"]
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 data["attach"] != "" {
json.Unmarshal([]byte(data["attach"]), &attach)
}
db := database.DB()
// 用 openID 解析真实买家身份
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
defer resp.Body.Close()
for k, v := range resp.Header {
if len(v) > 0 {
c.Header(k, v[0])
}
}
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,
}
db.Create(&order)
} else if *order.Status != "paid" {
// 更新订单状态
status := "paid"
now := time.Now()
db.Model(&order).Updates(map[string]interface{}{
"status": status,
"transaction_id": transactionID,
"pay_time": now,
})
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 == "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 {
// 首次购买该章节,这里不需要更新 purchased_sections因为查询时会从 orders 表读取
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"
}
result := db.Where(
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
buyerUserID, attach.ProductType, productID, orderSn,
).Delete(&model.Order{})
if result.RowsAffected > 0 {
fmt.Printf("[PayNotify] 已清理无效订单: %d 个\n", result.RowsAffected)
}
// 处理分销佣金
processReferralCommission(db, buyerUserID, totalAmount, orderSn)
}
c.String(http.StatusOK, successResponse())
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}
// 处理分销佣金

View File

@@ -152,14 +152,57 @@ func WithdrawRecords(c *gin.Context) {
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"
}
// package 需由「用户确认模式」转账接口返回并落库,当前批量转账无 package返回空有值时可调 wx.requestMerchantTransfer
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= 待确认/处理中收款列表
// 返回 pending、processing、pending_confirm 的提现,供小程序展示;并返回 mchId、appId 供确认收款用
func WithdrawPendingConfirm(c *gin.Context) {