411 lines
13 KiB
Go
411 lines
13 KiB
Go
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
|
||
})
|
||
}
|