Files
soul-yongping/soul-api/internal/handler/balance.go

405 lines
13 KiB
Go
Raw Normal View History

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.RelatedOrder != nil {
orderID = *t.RelatedOrder
}
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("related_order = ? 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)
tx.Create(&model.BalanceTransaction{
UserID: order.UserID, Type: "recharge", Amount: order.Amount,
RelatedOrder: &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
}
tx.Create(&model.BalanceTransaction{
UserID: req.UserID, Type: "consume", Amount: -amount,
RelatedOrder: &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))
tx.Create(&model.BalanceTransaction{
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.RelatedOrder != nil {
orderID = *t.RelatedOrder
}
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}})
}
2026-03-17 13:17:49 +08:00
// 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)
return tx.Create(&model.BalanceTransaction{
UserID: userID, Type: "admin_adjust", Amount: req.Amount,
2026-03-17 13:17:49 +08:00
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)
return tx.Create(&model.BalanceTransaction{
UserID: order.UserID, Type: "recharge", Amount: order.Amount,
RelatedOrder: &orderSn, CreatedAt: time.Now(),
}).Error
})
}