Files
soul-yongping/soul-api/internal/handler/balance.go
卡若 708547d0dd feat: 数据概览简化 + 用户管理增加余额/提现列
- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额
- 数据概览:移除余额统计区块(余额改在用户管理中展示)
- 数据概览:恢复转化率卡片(唯一付费用户/总用户)
- 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额
- 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段
- 后端:User model 添加 WalletBalance 非数据库字段
- 包含之前的小程序埋点和管理后台点击统计面板

Made-with: Cursor
2026-03-15 15:57:09 +08:00

424 lines
12 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 (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// GET /api/miniprogram/balance 小程序-查询余额
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 bal model.UserBalance
if err := db.Where("user_id = ?", userID).First(&bal).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"userId": userID, "balance": 0, "totalRecharged": 0, "totalGifted": 0, "totalRefunded": 0}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": bal})
}
// POST /api/miniprogram/balance/recharge 小程序-充值(创建充值订单)
func BalanceRecharge(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误: " + err.Error()})
return
}
db := database.DB()
orderSN := fmt.Sprintf("BAL_%d", time.Now().UnixNano())
order := model.Order{
ID: orderSN,
OrderSN: orderSN,
UserID: body.UserID,
ProductType: "balance_recharge",
Amount: body.Amount,
}
desc := fmt.Sprintf("余额充值 ¥%.2f", body.Amount)
status := "pending"
order.Description = &desc
order.Status = &status
if err := db.Create(&order).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建充值订单失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"orderSn": orderSN, "amount": body.Amount}})
}
// POST /api/miniprogram/balance/recharge/confirm 充值完成回调(内部或手动确认)
func BalanceRechargeConfirm(c *gin.Context) {
var body struct {
OrderSN string `json:"orderSn" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var order model.Order
if err := db.Where("order_sn = ? AND product_type = ?", body.OrderSN, "balance_recharge").First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "订单不存在"})
return
}
if order.Status != nil && *order.Status == "paid" {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已确认"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
paid := "paid"
now := time.Now()
if err := tx.Model(&order).Updates(map[string]interface{}{"status": paid, "pay_time": now}).Error; err != nil {
return err
}
var bal model.UserBalance
if err := tx.Where("user_id = ?", order.UserID).First(&bal).Error; err != nil {
bal = model.UserBalance{UserID: order.UserID}
tx.Create(&bal)
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", order.Amount),
"total_recharged": gorm.Expr("total_recharged + ?", order.Amount),
}).Error; err != nil {
return err
}
tx.Create(&model.BalanceTransaction{
UserID: order.UserID,
Type: "recharge",
Amount: order.Amount,
BalanceAfter: bal.Balance + order.Amount,
RelatedOrder: &order.OrderSN,
Description: fmt.Sprintf("充值 ¥%.2f", order.Amount),
})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "确认失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "充值成功"})
}
// POST /api/miniprogram/balance/gift 小程序-代付解锁(用余额帮他人解锁章节)
func BalanceGift(c *gin.Context) {
var body struct {
GiverID string `json:"giverId" binding:"required"`
SectionID string `json:"sectionId" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var chapter model.Chapter
if err := db.Where("id = ?", body.SectionID).First(&chapter).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
return
}
price := float64(1)
if chapter.Price != nil {
price = *chapter.Price
}
if price <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "该章节免费,无需代付"})
return
}
var giftCode string
err := db.Transaction(func(tx *gorm.DB) error {
var bal model.UserBalance
if err := tx.Where("user_id = ?", body.GiverID).First(&bal).Error; err != nil || bal.Balance < price {
return fmt.Errorf("余额不足,当前 ¥%.2f,需要 ¥%.2f", bal.Balance, price)
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", price),
"total_gifted": gorm.Expr("total_gifted + ?", price),
}).Error; err != nil {
return err
}
code := make([]byte, 16)
rand.Read(code)
giftCode = hex.EncodeToString(code)
tx.Create(&model.GiftUnlock{
GiftCode: giftCode,
GiverID: body.GiverID,
SectionID: body.SectionID,
Amount: price,
Status: "pending",
})
tx.Create(&model.BalanceTransaction{
UserID: body.GiverID,
Type: "gift",
Amount: -price,
BalanceAfter: bal.Balance - price,
SectionID: &body.SectionID,
Description: fmt.Sprintf("代付章节 %s (¥%.2f)", body.SectionID, price),
})
return nil
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"giftCode": giftCode,
"sectionId": body.SectionID,
"amount": price,
}})
}
// POST /api/miniprogram/balance/gift/redeem 领取代付礼物
func BalanceGiftRedeem(c *gin.Context) {
var body struct {
GiftCode string `json:"giftCode" binding:"required"`
ReceiverID string `json:"receiverId" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var gift model.GiftUnlock
if err := db.Where("gift_code = ?", body.GiftCode).First(&gift).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "礼物码无效"})
return
}
if gift.Status != "pending" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "礼物已被领取"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
tx.Model(&gift).Updates(map[string]interface{}{
"receiver_id": body.ReceiverID,
"status": "redeemed",
"redeemed_at": now,
})
orderSN := fmt.Sprintf("GIFT_%s", body.GiftCode[:8])
paid := "paid"
desc := fmt.Sprintf("来自好友的代付解锁")
tx.Create(&model.Order{
ID: orderSN,
OrderSN: orderSN,
UserID: body.ReceiverID,
ProductType: "section",
ProductID: &gift.SectionID,
Amount: 0,
Description: &desc,
Status: &paid,
PayTime: &now,
ReferrerID: &gift.GiverID,
})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "领取失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"sectionId": gift.SectionID,
"message": "解锁成功!",
}})
}
// POST /api/miniprogram/balance/refund 申请余额退款9折
func BalanceRefund(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
refundAmount := body.Amount * 0.9
err := db.Transaction(func(tx *gorm.DB) error {
var bal model.UserBalance
if err := tx.Where("user_id = ?", body.UserID).First(&bal).Error; err != nil || bal.Balance < body.Amount {
return fmt.Errorf("余额不足")
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", body.Amount),
"total_refunded": gorm.Expr("total_refunded + ?", body.Amount),
}).Error; err != nil {
return err
}
tx.Create(&model.BalanceTransaction{
UserID: body.UserID,
Type: "refund",
Amount: -body.Amount,
BalanceAfter: bal.Balance - body.Amount,
Description: fmt.Sprintf("退款 ¥%.2f(原额 ¥%.2f9折退回 ¥%.2f", body.Amount, body.Amount, refundAmount),
})
return nil
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"deducted": body.Amount,
"refundAmount": refundAmount,
"message": fmt.Sprintf("退款成功,实际退回 ¥%.2f", refundAmount),
}})
}
// GET /api/miniprogram/balance/transactions 交易记录(含余额变动 + 阅读消费订单)
func BalanceTransactions(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
var txns []model.BalanceTransaction
db.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&txns)
var orders []model.Order
db.Where("user_id = ? AND product_type = ? AND status IN ?", userID, "section", []string{"paid", "completed", "success"}).
Order("created_at DESC").Limit(50).Find(&orders)
type txRow struct {
ID string `json:"id"`
Type string `json:"type"`
Amount float64 `json:"amount"`
Description string `json:"description"`
CreatedAt interface{} `json:"createdAt"`
}
merged := make([]txRow, 0, len(txns)+len(orders))
for _, t := range txns {
merged = append(merged, txRow{
ID: fmt.Sprintf("bal_%d", t.ID), Type: t.Type, Amount: t.Amount,
Description: t.Description, CreatedAt: t.CreatedAt,
})
}
for _, o := range orders {
desc := "阅读消费"
if o.Description != nil && *o.Description != "" {
desc = *o.Description
}
merged = append(merged, txRow{
ID: o.ID, Type: "consume", Amount: -o.Amount,
Description: desc, CreatedAt: o.CreatedAt,
})
}
// 按时间倒序排列
for i := 0; i < len(merged); i++ {
for j := i + 1; j < len(merged); j++ {
ti := fmt.Sprintf("%v", merged[i].CreatedAt)
tj := fmt.Sprintf("%v", merged[j].CreatedAt)
if ti < tj {
merged[i], merged[j] = merged[j], merged[i]
}
}
}
if len(merged) > 50 {
merged = merged[:50]
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": merged})
}
// GET /api/admin/balance/summary 管理端-余额统计
func BalanceSummary(c *gin.Context) {
db := database.DB()
type Summary struct {
TotalUsers int64 `json:"totalUsers"`
TotalBalance float64 `json:"totalBalance"`
TotalRecharged float64 `json:"totalRecharged"`
TotalGifted float64 `json:"totalGifted"`
TotalRefunded float64 `json:"totalRefunded"`
GiftCount int64 `json:"giftCount"`
PendingGifts int64 `json:"pendingGifts"`
}
var s Summary
db.Model(&model.UserBalance{}).Count(&s.TotalUsers)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(balance),0)").Scan(&s.TotalBalance)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_recharged),0)").Scan(&s.TotalRecharged)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_gifted),0)").Scan(&s.TotalGifted)
db.Model(&model.UserBalance{}).Select("COALESCE(SUM(total_refunded),0)").Scan(&s.TotalRefunded)
db.Model(&model.GiftUnlock{}).Count(&s.GiftCount)
db.Model(&model.GiftUnlock{}).Where("status = ?", "pending").Count(&s.PendingGifts)
c.JSON(200, gin.H{"success": true, "data": s})
}
// GET /api/miniprogram/balance/gift/info 查询礼物码信息
func BalanceGiftInfo(c *gin.Context) {
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code"})
return
}
db := database.DB()
var gift model.GiftUnlock
if err := db.Where("gift_code = ?", code).First(&gift).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "礼物码无效"})
return
}
var chapter model.Chapter
db.Where("id = ?", gift.SectionID).First(&chapter)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"giftCode": gift.GiftCode,
"sectionId": gift.SectionID,
"sectionTitle": chapter.SectionTitle,
"amount": gift.Amount,
"status": gift.Status,
"giverId": gift.GiverID,
}})
}