Files
soul-yongping/soul-api/internal/handler/balance.go
2026-03-17 13:17:49 +08:00

411 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"fmt"
"net/http"
"strconv"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// BalanceGet GET /api/miniprogram/balance?userId=
func BalanceGet(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
var ub model.UserBalance
if err := db.Where("user_id = ?", userID).First(&ub).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": 0}})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": ub.Balance}})
}
// BalanceTransactionsGet GET /api/miniprogram/balance/transactions?userId=&page=&pageSize=
func BalanceTransactionsGet(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
db := database.DB()
var total int64
db.Model(&model.BalanceTransaction{}).Where("user_id = ?", userID).Count(&total)
var list []model.BalanceTransaction
if err := db.Where("user_id = ?", userID).Order("created_at DESC").
Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "data": []interface{}{}, "total": 0})
return
}
out := make([]gin.H, 0, len(list))
for _, t := range list {
orderID := ""
if t.OrderID != nil {
orderID = *t.OrderID
}
out = append(out, gin.H{
"id": t.ID, "type": t.Type, "amount": t.Amount,
"orderId": orderID, "createdAt": t.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": total})
}
// BalanceRechargePost POST /api/miniprogram/balance/recharge
func BalanceRechargePost(c *gin.Context) {
var req struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gte=0.01"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
return
}
orderSn := wechat.GenerateOrderSn()
db := database.DB()
desc := fmt.Sprintf("余额充值 ¥%.2f", req.Amount)
status := "created"
order := model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: req.UserID,
ProductType: "balance_recharge",
ProductID: &orderSn,
Amount: req.Amount,
Description: &desc,
Status: &status,
}
if err := db.Create(&order).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建订单失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"orderSn": orderSn}})
}
// BalanceRechargeConfirmPost POST /api/miniprogram/balance/recharge/confirm
func BalanceRechargeConfirmPost(c *gin.Context) {
var req struct {
OrderSn string `json:"orderSn" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 orderSn"})
return
}
db := database.DB()
err := db.Transaction(func(tx *gorm.DB) error {
var order model.Order
if err := tx.Where("order_sn = ? AND product_type = ?", req.OrderSn, "balance_recharge").First(&order).Error; err != nil {
return err
}
status := ""
if order.Status != nil {
status = *order.Status
}
if status != "paid" {
return fmt.Errorf("订单未支付")
}
// 幂等:检查是否已处理
var cnt int64
tx.Model(&model.BalanceTransaction{}).Where("order_id = ? AND type = ?", req.OrderSn, "recharge").Count(&cnt)
if cnt > 0 {
return nil // 已处理,直接成功
}
// 增加余额
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = balance + ?, updated_at = NOW()", order.UserID, order.Amount)
txID := fmt.Sprintf("bt_%d", time.Now().UnixNano()%100000000000)
tx.Create(&model.BalanceTransaction{
ID: txID, UserID: order.UserID, Type: "recharge", Amount: order.Amount,
OrderID: &req.OrderSn, CreatedAt: time.Now(),
})
return nil
})
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// BalanceConsumePost POST /api/miniprogram/balance/consume
func BalanceConsumePost(c *gin.Context) {
var req struct {
UserID string `json:"userId" binding:"required"`
ProductType string `json:"productType" binding:"required"`
ProductID string `json:"productId"`
Amount float64 `json:"amount" binding:"required,gte=0.01"`
ReferralCode string `json:"referralCode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
return
}
db := database.DB()
// 后端价格校验
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
if priceErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
return
}
if req.Amount < standardPrice-0.01 || req.Amount > standardPrice+0.01 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "金额校验失败"})
return
}
amount := standardPrice
// 解析推荐人
var referrerID *string
if req.ReferralCode != "" {
var refUser model.User
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
referrerID = &refUser.ID
}
}
if referrerID == nil {
var binding struct {
ReferrerID string `gorm:"column:referrer_id"`
}
_ = 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 binding.ReferrerID != "" {
referrerID = &binding.ReferrerID
}
}
productID := req.ProductID
if productID == "" {
switch req.ProductType {
case "vip":
productID = "vip_annual"
case "fullbook":
productID = "fullbook"
default:
productID = req.ProductID
}
}
err := db.Transaction(func(tx *gorm.DB) error {
var ub model.UserBalance
if err := tx.Where("user_id = ?", req.UserID).First(&ub).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("余额不足")
}
return err
}
if ub.Balance < amount {
return fmt.Errorf("余额不足")
}
tx.Model(&model.UserBalance{}).Where("user_id = ?", req.UserID).Update("balance", gorm.Expr("balance - ?", amount))
orderSn := wechat.GenerateOrderSn()
desc := ""
switch req.ProductType {
case "section":
desc = "章节购买-" + productID
case "fullbook":
desc = "《一场Soul的创业实验》全书"
case "vip":
desc = "卡若创业派对VIP年度会员365天"
default:
desc = "余额消费"
}
pm := "balance"
status := "paid"
now := time.Now()
order := model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: req.UserID,
ProductType: req.ProductType,
ProductID: &productID,
Amount: amount,
Description: &desc,
Status: &status,
PaymentMethod: &pm,
ReferrerID: referrerID,
PayTime: &now,
}
if err := tx.Create(&order).Error; err != nil {
return err
}
txID := fmt.Sprintf("bt_%d", time.Now().UnixNano()%100000000000)
tx.Create(&model.BalanceTransaction{
ID: txID, UserID: req.UserID, Type: "consume", Amount: -amount,
OrderID: &orderSn, CreatedAt: now,
})
// 激活权益
if req.ProductType == "fullbook" {
tx.Model(&model.User{}).Where("id = ?", req.UserID).Update("has_full_book", true)
} else if req.ProductType == "vip" {
activateVIP(tx, req.UserID, 365, now)
}
// 分佣
if referrerID != nil {
processReferralCommission(tx, req.UserID, amount, orderSn, &order)
}
return nil
})
if err != nil {
if err.Error() == "余额不足" {
c.JSON(http.StatusOK, 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})
}
// BalanceRefundPost POST /api/miniprogram/balance/refund
func BalanceRefundPost(c *gin.Context) {
var req struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gte=0.01"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
return
}
// 首版简化:暂不实现微信原路退,仅扣减余额并记录
db := database.DB()
err := db.Transaction(func(tx *gorm.DB) error {
var ub model.UserBalance
if err := tx.Where("user_id = ?", req.UserID).First(&ub).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("余额为零")
}
return err
}
if ub.Balance < req.Amount {
return fmt.Errorf("余额不足")
}
tx.Model(&model.UserBalance{}).Where("user_id = ?", req.UserID).Update("balance", gorm.Expr("balance - ?", req.Amount))
txID := fmt.Sprintf("bt_%d", time.Now().UnixNano()%100000000000)
tx.Create(&model.BalanceTransaction{
ID: txID, UserID: req.UserID, Type: "refund", Amount: -req.Amount,
CreatedAt: time.Now(),
})
return nil
})
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "退款申请已提交1-3个工作日内原路返回"}})
}
// AdminUserBalanceGet GET /api/admin/users/:id/balance 管理端-用户余额与最近交易
func AdminUserBalanceGet(c *gin.Context) {
userID := c.Param("id")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少用户ID"})
return
}
db := database.DB()
var ub model.UserBalance
balance := 0.0
if err := db.Where("user_id = ?", userID).First(&ub).Error; err == nil {
balance = ub.Balance
}
var list []model.BalanceTransaction
db.Where("user_id = ?", userID).Order("created_at DESC").Limit(20).Find(&list)
transactions := make([]gin.H, 0, len(list))
for _, t := range list {
orderID := ""
if t.OrderID != nil {
orderID = *t.OrderID
}
transactions = append(transactions, gin.H{
"id": t.ID, "type": t.Type, "amount": t.Amount,
"orderId": orderID, "createdAt": t.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": balance, "transactions": transactions}})
}
// AdminUserBalanceAdjust POST /api/admin/users/:id/balance/adjust 管理端-人工调整用户余额
func AdminUserBalanceAdjust(c *gin.Context) {
userID := c.Param("id")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少用户ID"})
return
}
var req struct {
Amount float64 `json:"amount" binding:"required"` // 正数增加,负数扣减
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
return
}
if req.Amount == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "调整金额不能为 0"})
return
}
db := database.DB()
err := db.Transaction(func(tx *gorm.DB) error {
var ub model.UserBalance
if err := tx.Where("user_id = ?", userID).First(&ub).Error; err != nil {
if err == gorm.ErrRecordNotFound {
ub = model.UserBalance{UserID: userID, Balance: 0}
} else {
return err
}
}
newBalance := ub.Balance + req.Amount
if newBalance < 0 {
return fmt.Errorf("调整后余额不能为负,当前余额 %.2f", ub.Balance)
}
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = ?, updated_at = NOW()", userID, newBalance)
txID := fmt.Sprintf("bt_adj_%d", time.Now().UnixNano()%100000000000)
return tx.Create(&model.BalanceTransaction{
ID: txID, UserID: userID, Type: "admin_adjust", Amount: req.Amount,
CreatedAt: time.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": "余额已调整"})
}
// ConfirmBalanceRechargeByOrder 支付成功后确认充值(幂等),供 PayNotify 和 activateOrderBenefits 调用
func ConfirmBalanceRechargeByOrder(db *gorm.DB, order *model.Order) error {
if order == nil || order.ProductType != "balance_recharge" {
return nil
}
orderSn := order.OrderSN
return db.Transaction(func(tx *gorm.DB) error {
var cnt int64
tx.Model(&model.BalanceTransaction{}).Where("order_id = ? AND type = ?", orderSn, "recharge").Count(&cnt)
if cnt > 0 {
return nil // 已处理,幂等
}
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = balance + ?, updated_at = NOW()", order.UserID, order.Amount)
txID := fmt.Sprintf("bt_%d", time.Now().UnixNano()%100000000000)
return tx.Create(&model.BalanceTransaction{
ID: txID, UserID: order.UserID, Type: "recharge", Amount: order.Amount,
OrderID: &orderSn, CreatedAt: time.Now(),
}).Error
})
}