- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额 - 数据概览:移除余额统计区块(余额改在用户管理中展示) - 数据概览:恢复转化率卡片(唯一付费用户/总用户) - 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额 - 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段 - 后端:User model 添加 WalletBalance 非数据库字段 - 包含之前的小程序埋点和管理后台点击统计面板 Made-with: Cursor
424 lines
12 KiB
Go
424 lines
12 KiB
Go
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(原额 ¥%.2f,9折退回 ¥%.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,
|
||
}})
|
||
}
|