Update project documentation and enhance user interaction features

- Added a new entry for user interaction habit analysis based on agent transcripts, summarizing key insights into communication styles and preferences.
- Updated project indices to reflect the latest developments, including the addition of a wallet balance feature and enhancements to the mini program's user interface for better user experience.
- Improved the handling of loading states in the chapters page, ensuring a smoother user experience during data retrieval.
- Implemented a gift payment sharing feature, allowing users to share payment requests with friends for collaborative purchases.
This commit is contained in:
Alex-larget
2026-03-17 11:44:36 +08:00
parent b971420090
commit 0d12ab1d07
65 changed files with 3836 additions and 180 deletions

View File

@@ -0,0 +1,363 @@
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}})
}
// 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
})
}

View File

@@ -613,9 +613,15 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
return out
}
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录)
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录;支持 ?limit=,最大 50
func BookHot(c *gin.Context) {
list := bookHotChaptersSorted(database.DB(), 10)
limit := 10
if l := c.Query("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 50 {
limit = n
}
}
list := bookHotChaptersSorted(database.DB(), limit)
if len(list) == 0 {
// 兜底:按 sort_order 取前 10同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})

View File

@@ -739,13 +739,14 @@ func DBUsersAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "user": u, "isNew": true, "message": "用户创建成功"})
return
}
// PUT 更新(含 VIP 手动设置is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio
// PUT 更新(含 VIP 手动设置is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_biotags 存 ckb_tags
var body struct {
ID string `json:"id"`
Nickname *string `json:"nickname"`
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
HasFullBook *bool `json:"hasFullBook"`
IsAdmin *bool `json:"isAdmin"`
Earnings *float64 `json:"earnings"`
@@ -790,6 +791,9 @@ func DBUsersAction(c *gin.Context) {
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
}
if body.Tags != nil {
updates["ckb_tags"] = *body.Tags
}
if body.HasFullBook != nil {
updates["has_full_book"] = *body.HasFullBook
}

View File

@@ -0,0 +1,382 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
const giftPayExpireHours = 24
// GiftPayCreate POST /api/miniprogram/gift-pay/create 创建代付请求
func GiftPayCreate(c *gin.Context) {
var req struct {
UserID string `json:"userId" binding:"required"`
ProductType string `json:"productType" binding:"required"`
ProductID string `json:"productId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
return
}
db := database.DB()
// 校验发起人
var initiator model.User
if err := db.Where("id = ?", req.UserID).First(&initiator).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
return
}
// 价格与商品校验
productID := req.ProductID
if productID == "" {
switch req.ProductType {
case "vip":
productID = "vip_annual"
case "match":
productID = "match"
case "fullbook":
productID = "fullbook"
}
}
amount, priceErr := getStandardPrice(db, req.ProductType, productID)
if priceErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
return
}
// 发起人若有推荐人绑定,享受好友优惠
var referrerID *string
var binding struct {
ReferrerID string `gorm:"column:referrer_id"`
}
if err := 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; err == nil && binding.ReferrerID != "" {
referrerID = &binding.ReferrerID
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if json.Unmarshal(cfg.ConfigValue, &config) == nil {
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
amount = amount * (1 - userDiscount/100)
if amount < 0.01 {
amount = 0.01
}
}
}
}
}
_ = referrerID // 分佣在 PayNotify 时按发起人计算
// 校验发起人是否已拥有
if req.ProductType == "section" && productID != "" {
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status IN ?",
req.UserID, "section", productID, []string{"paid", "completed"}).Count(&cnt)
if cnt > 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有该章节"})
return
}
}
if req.ProductType == "fullbook" || req.ProductType == "vip" {
var u model.User
db.Where("id = ?", req.UserID).Select("has_full_book", "is_vip", "vip_expire_date").First(&u)
if u.HasFullBook != nil && *u.HasFullBook {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有全书"})
return
}
if req.ProductType == "vip" && u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已是有效VIP"})
return
}
}
// 描述
desc := ""
switch req.ProductType {
case "fullbook":
desc = "《一场Soul的创业实验》全书"
case "vip":
desc = "卡若创业派对VIP年度会员365天"
case "match":
desc = "购买匹配次数"
case "section":
var ch model.Chapter
if err := db.Select("section_title").Where("id = ?", productID).First(&ch).Error; err == nil && ch.SectionTitle != "" {
desc = ch.SectionTitle
} else {
desc = fmt.Sprintf("章节-%s", productID)
}
default:
desc = fmt.Sprintf("%s-%s", req.ProductType, productID)
}
expireAt := time.Now().Add(giftPayExpireHours * time.Hour)
requestSN := "GPR" + wechat.GenerateOrderSn()
id := "gpr_" + fmt.Sprintf("%d", time.Now().UnixNano()%100000000000)
gpr := model.GiftPayRequest{
ID: id,
RequestSN: requestSN,
InitiatorUserID: req.UserID,
ProductType: req.ProductType,
ProductID: productID,
Amount: amount,
Description: desc,
Status: "pending",
ExpireAt: expireAt,
}
if err := db.Create(&gpr).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建失败"})
return
}
path := fmt.Sprintf("pages/gift-pay/detail?requestSn=%s", requestSN)
c.JSON(http.StatusOK, gin.H{
"success": true,
"requestSn": requestSN,
"path": path,
"amount": amount,
"expireAt": expireAt.Format(time.RFC3339),
})
}
// GiftPayDetail GET /api/miniprogram/gift-pay/detail?requestSn=xxx 代付详情(代付人用)
func GiftPayDetail(c *gin.Context) {
requestSn := strings.TrimSpace(c.Query("requestSn"))
if requestSn == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少代付请求号"})
return
}
db := database.DB()
var gpr model.GiftPayRequest
if err := db.Where("request_sn = ?", requestSn).First(&gpr).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
return
}
if gpr.Status != "pending" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
return
}
if time.Now().After(gpr.ExpireAt) {
db.Model(&gpr).Update("status", "expired")
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
return
}
// 发起人昵称(脱敏)
var initiator model.User
nickname := "好友"
if err := db.Where("id = ?", gpr.InitiatorUserID).Select("nickname").First(&initiator).Error; err == nil && initiator.Nickname != nil {
n := *initiator.Nickname
if len(n) > 2 {
n = string([]rune(n)[0]) + "**"
}
nickname = n
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"requestSn": gpr.RequestSN,
"productType": gpr.ProductType,
"productId": gpr.ProductID,
"amount": gpr.Amount,
"description": gpr.Description,
"initiatorNickname": nickname,
"expireAt": gpr.ExpireAt.Format(time.RFC3339),
})
}
// GiftPayPay POST /api/miniprogram/gift-pay/pay 代付人发起支付
func GiftPayPay(c *gin.Context) {
var req struct {
RequestSn string `json:"requestSn" binding:"required"`
OpenID string `json:"openId" binding:"required"`
UserID string `json:"userId"` // 代付人ID用于校验不能自己付
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
return
}
db := database.DB()
var gpr model.GiftPayRequest
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "pending").First(&gpr).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或已处理"})
return
}
if time.Now().After(gpr.ExpireAt) {
db.Model(&gpr).Update("status", "expired")
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
return
}
// 不能自己给自己代付
if req.UserID != "" && req.UserID == gpr.InitiatorUserID {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
return
}
// 获取代付人信息
var payer model.User
if err := db.Where("open_id = ?", req.OpenID).First(&payer).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
return
}
if payer.ID == gpr.InitiatorUserID {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
return
}
// 创建订单(归属发起人,记录代付信息)
orderSn := wechat.GenerateOrderSn()
status := "created"
pm := "wechat"
productID := gpr.ProductID
desc := gpr.Description
gprID := gpr.ID
payerID := payer.ID
order := model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: gpr.InitiatorUserID,
OpenID: req.OpenID,
ProductType: gpr.ProductType,
ProductID: &productID,
Amount: gpr.Amount,
Description: &desc,
Status: &status,
PaymentMethod: &pm,
GiftPayRequestID: &gprID,
PayerUserID: &payerID,
}
if err := db.Create(&order).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建订单失败"})
return
}
// 唤起微信支付attach 中 userId=发起人giftPayRequestSn=请求号
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s","giftPayRequestSn":"%s"}`,
gpr.ProductType, gpr.ProductID, gpr.InitiatorUserID, gpr.RequestSN)
totalFee := int(gpr.Amount * 100)
ctx := c.Request.Context()
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, "代付-"+gpr.Description, attach)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("支付请求失败: %v", err)})
return
}
payParams, err := wechat.GetJSAPIPayParams(prepayID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成支付参数失败"})
return
}
// 预占:更新请求状态为 paying可选防并发
// 简化不预占PayNotify 时再更新
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"orderSn": orderSn,
"prepayId": prepayID,
"payParams": payParams,
},
})
}
// GiftPayCancel POST /api/miniprogram/gift-pay/cancel 发起人取消
func GiftPayCancel(c *gin.Context) {
var req struct {
RequestSn string `json:"requestSn" binding:"required"`
UserID string `json:"userId" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
return
}
db := database.DB()
var gpr model.GiftPayRequest
if err := db.Where("request_sn = ?", req.RequestSn).First(&gpr).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
return
}
if gpr.InitiatorUserID != req.UserID {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权取消"})
return
}
if gpr.Status != "pending" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
return
}
db.Model(&gpr).Update("status", "cancelled")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已取消"})
}
// GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的
func GiftPayMyRequests(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少userId"})
return
}
db := database.DB()
var list []model.GiftPayRequest
db.Where("initiator_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
out := make([]gin.H, 0, len(list))
for _, r := range list {
out = append(out, gin.H{
"requestSn": r.RequestSN,
"productType": r.ProductType,
"productId": r.ProductID,
"amount": r.Amount,
"description": r.Description,
"status": r.Status,
"expireAt": r.ExpireAt.Format(time.RFC3339),
"createdAt": r.CreatedAt.Format(time.RFC3339),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
}
// GiftPayMyPayments GET /api/miniprogram/gift-pay/my-payments?userId= 我帮付的
func GiftPayMyPayments(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少userId"})
return
}
db := database.DB()
var list []model.GiftPayRequest
db.Where("payer_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
out := make([]gin.H, 0, len(list))
for _, r := range list {
out = append(out, gin.H{
"requestSn": r.RequestSN,
"productType": r.ProductType,
"amount": r.Amount,
"description": r.Description,
"status": r.Status,
"createdAt": r.CreatedAt.Format(time.RFC3339),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
}

View File

@@ -302,66 +302,81 @@ func miniprogramPayPost(c *gin.Context) {
db := database.DB()
// -------- V1.1 后端价格:从 DB 读取标准价,客户端传值仅用于日志对比,实际以后端计算为准 --------
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
if priceErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
return
}
// 查询用户的有效推荐人(先查 binding再查 referralCode
var finalAmount float64
var orderSn string
var referrerID *string
if req.UserID != "" {
var binding struct {
ReferrerID string `gorm:"column:referrer_id"`
}
err := 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 err == nil && binding.ReferrerID != "" {
referrerID = &binding.ReferrerID
}
}
if referrerID == nil && req.ReferralCode != "" {
var refUser model.User
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
referrerID = &refUser.ID
}
}
// 有推荐人时应用好友优惠,以后端标准价为基准计算最终金额,忽略客户端传值
finalAmount := standardPrice
if referrerID != nil {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
discountRate := userDiscount / 100
finalAmount = standardPrice * (1 - discountRate)
if finalAmount < 0.01 {
finalAmount = 0.01
if req.ProductType == "balance_recharge" {
// 充值从已创建的订单取金额productId=orderSn
var existOrder model.Order
if err := db.Where("order_sn = ? AND product_type = ? AND status = ?", req.ProductID, "balance_recharge", "created").First(&existOrder).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "充值订单不存在或已支付"})
return
}
orderSn = existOrder.OrderSN
finalAmount = existOrder.Amount
if req.UserID != "" && existOrder.UserID != req.UserID {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "订单用户不匹配"})
return
}
} else {
// -------- V1.1 后端价格:从 DB 读取标准价 --------
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
if priceErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
return
}
finalAmount = standardPrice
if req.UserID != "" {
var binding struct {
ReferrerID string `gorm:"column:referrer_id"`
}
err := 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 err == nil && binding.ReferrerID != "" {
referrerID = &binding.ReferrerID
}
}
if referrerID == nil && 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 cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
discountRate := userDiscount / 100
finalAmount = finalAmount * (1 - discountRate)
if finalAmount < 0.01 {
finalAmount = 0.01
}
}
}
}
}
}
// 记录客户端与后端金额差异(仅日志,不拦截)
if req.Amount-finalAmount > 0.05 || finalAmount-req.Amount > 0.05 {
fmt.Printf("[PayCreate] 金额差异: 客户端=%.2f 后端=%.2f productType=%s productId=%s userId=%s\n",
req.Amount, finalAmount, req.ProductType, req.ProductID, req.UserID)
if req.Amount-finalAmount > 0.05 || finalAmount-req.Amount > 0.05 {
fmt.Printf("[PayCreate] 金额差异: 客户端=%.2f 后端=%.2f productType=%s productId=%s userId=%s\n",
req.Amount, finalAmount, req.ProductType, req.ProductID, req.UserID)
}
orderSn = wechat.GenerateOrderSn()
}
// 生成订单号
orderSn := wechat.GenerateOrderSn()
totalFee := int(finalAmount * 100) // 转为分
description := req.Description
if description == "" {
if req.ProductType == "fullbook" {
if req.ProductType == "balance_recharge" {
description = fmt.Sprintf("余额充值 ¥%.2f", finalAmount)
} else if req.ProductType == "fullbook" {
description = "《一场Soul的创业实验》全书"
} else if req.ProductType == "vip" {
description = "卡若创业派对VIP年度会员365天"
@@ -378,7 +393,6 @@ func miniprogramPayPost(c *gin.Context) {
clientIP = "127.0.0.1"
}
// 插入订单到数据库
userID := req.UserID
if userID == "" {
userID = req.OpenID
@@ -396,24 +410,27 @@ func miniprogramPayPost(c *gin.Context) {
}
}
status := "created"
order := model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: userID,
OpenID: req.OpenID,
ProductType: req.ProductType,
ProductID: &productID,
Amount: finalAmount,
Description: &description,
Status: &status,
ReferrerID: referrerID,
ReferralCode: &req.ReferralCode,
}
if err := db.Create(&order).Error; err != nil {
// 订单创建失败,但不中断支付流程
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
// 充值订单已存在,不重复创建
if req.ProductType != "balance_recharge" {
status := "created"
pm := "wechat"
order := model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: userID,
OpenID: req.OpenID,
ProductType: req.ProductType,
ProductID: &productID,
Amount: finalAmount,
Description: &description,
Status: &status,
ReferrerID: referrerID,
ReferralCode: &req.ReferralCode,
PaymentMethod: &pm,
}
if err := db.Create(&order).Error; err != nil {
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
}
}
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID)
@@ -479,7 +496,7 @@ func miniprogramPayGet(c *gin.Context) {
orderPollLogf("主动同步订单已支付: %s", orderSn)
// 激活权益
if order.UserID != "" {
activateOrderBenefits(db, order.UserID, order.ProductType, now)
activateOrderBenefits(db, &order, now)
}
}
case "CLOSED", "REVOKED", "PAYERROR":
@@ -506,9 +523,10 @@ func MiniprogramPayNotify(c *gin.Context) {
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
var attach struct {
ProductType string `json:"productType"`
ProductID string `json:"productId"`
UserID string `json:"userId"`
ProductType string `json:"productType"`
ProductID string `json:"productId"`
UserID string `json:"userId"`
GiftPayRequestSn string `json:"giftPayRequestSn"`
}
if attachStr != "" {
_ = json.Unmarshal([]byte(attachStr), &attach)
@@ -564,11 +582,12 @@ func MiniprogramPayNotify(c *gin.Context) {
} else if *order.Status != "paid" {
status := "paid"
now := time.Now()
if err := db.Model(&order).Updates(map[string]interface{}{
updates := map[string]interface{}{
"status": status,
"transaction_id": transactionID,
"pay_time": now,
}).Error; err != nil {
}
if err := db.Model(&order).Updates(updates).Error; err != nil {
fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err)
return fmt.Errorf("update order: %w", err)
}
@@ -577,6 +596,25 @@ func MiniprogramPayNotify(c *gin.Context) {
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
}
// 代付订单:更新 gift_pay_request、订单 payer_user_id
if attach.GiftPayRequestSn != "" {
var payerUserID string
if openID != "" {
var payer model.User
if err := db.Where("open_id = ?", openID).First(&payer).Error; err == nil {
payerUserID = payer.ID
db.Model(&order).Update("payer_user_id", payerUserID)
}
}
db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", attach.GiftPayRequestSn).
Updates(map[string]interface{}{
"status": "paid",
"payer_user_id": payerUserID,
"order_id": orderSn,
"updated_at": time.Now(),
})
}
if buyerUserID != "" && attach.ProductType != "" {
if attach.ProductType == "fullbook" {
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
@@ -591,6 +629,12 @@ func MiniprogramPayNotify(c *gin.Context) {
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
} else if attach.ProductType == "match" {
fmt.Printf("[PayNotify] 用户购买匹配次数: %s订单 %s\n", buyerUserID, orderSn)
} else if attach.ProductType == "balance_recharge" {
if err := ConfirmBalanceRechargeByOrder(db, &order); err != nil {
fmt.Printf("[PayNotify] 余额充值确认失败: %s, err=%v\n", orderSn, err)
} else {
fmt.Printf("[PayNotify] 余额充值成功: %s, 金额 %.2f\n", buyerUserID, totalAmount)
}
} else if attach.ProductType == "section" && attach.ProductID != "" {
var count int64
db.Model(&model.Order{}).Where(
@@ -827,6 +871,45 @@ func MiniprogramQrcodeImage(c *gin.Context) {
c.Data(http.StatusOK, "image/png", imageData)
}
// GiftLinkGet GET /api/miniprogram/gift/link 代付链接(需登录,传 userId
// 返回 path、ref、scene供 gift-link 页展示与复制qrcodeImageUrl 供生成小程序码
func GiftLinkGet(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId请先登录"})
return
}
db := database.DB()
var user model.User
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
ref := getStringValue(user.ReferralCode)
if ref == "" {
suffix := userID
if len(userID) >= 6 {
suffix = userID[len(userID)-6:]
}
ref = "SOUL" + strings.ToUpper(suffix)
}
path := fmt.Sprintf("pages/gift-link/gift-link?ref=%s&gift=1", ref)
scene := fmt.Sprintf("ref_%s_gift_1", strings.ReplaceAll(ref, "&", "_"))
if len(scene) > 32 {
scene = scene[:32]
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"path": path,
"ref": ref,
"scene": scene,
})
}
// base64 编码
func base64Encode(data []byte) string {
const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
@@ -953,13 +1036,20 @@ func activateVIP(db *gorm.DB, userID string, days int, activatedAt time.Time) ti
return expireDate
}
// activateOrderBenefits 订单支付成功后激活对应权益VIP / 全书)
func activateOrderBenefits(db *gorm.DB, userID, productType string, payTime time.Time) {
// activateOrderBenefits 订单支付成功后激活对应权益VIP / 全书 / 余额充值
func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) {
if order == nil {
return
}
userID := order.UserID
productType := order.ProductType
switch productType {
case "fullbook":
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
case "vip":
activateVIP(db, userID, 365, payTime)
case "balance_recharge":
ConfirmBalanceRechargeByOrder(db, order)
}
}

View File

@@ -103,7 +103,7 @@ func OrdersList(c *gin.Context) {
return
}
// 收集订单中的 user_id、referrer_id查用户信息
// 收集订单中的 user_id、referrer_id、payer_user_id代付人,查用户信息
userIDs := make(map[string]bool)
for _, o := range orders {
if o.UserID != "" {
@@ -112,6 +112,9 @@ func OrdersList(c *gin.Context) {
if o.ReferrerID != nil && *o.ReferrerID != "" {
userIDs[*o.ReferrerID] = true
}
if o.PayerUserID != nil && *o.PayerUserID != "" {
userIDs[*o.PayerUserID] = true
}
}
ids := make([]string, 0, len(userIDs))
for id := range userIDs {
@@ -156,6 +159,14 @@ func OrdersList(c *gin.Context) {
m["referrerCode"] = getStr(u.ReferralCode)
}
}
// 代付人信息(实际付款人)
if o.PayerUserID != nil && *o.PayerUserID != "" {
if u := userMap[*o.PayerUserID]; u != nil {
m["payerNickname"] = getStr(u.Nickname)
} else {
m["payerNickname"] = ""
}
}
// 分销佣金:仅对已支付且存在推荐人的订单,按 computeOrderCommission会员 20%/10%,内容 90%
status := getStr(o.Status)
if status == "paid" && o.ReferrerID != nil && *o.ReferrerID != "" {

View File

@@ -626,6 +626,45 @@ func UserTrackPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"})
}
// MiniprogramTrackPost POST /api/miniprogram/track 小程序埋点userId 可选,支持匿名)
func MiniprogramTrackPost(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Action string `json:"action"`
Target string `json:"target"`
ExtraData interface{} `json:"extraData"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.Action == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"})
return
}
userId := body.UserID
if userId == "" {
userId = "anonymous"
}
db := database.DB()
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
chID := body.Target
if body.Action == "view_chapter" && body.Target != "" {
chID = body.Target
}
t := model.UserTrack{
ID: trackID, UserID: userId, Action: body.Action, Target: &body.Target,
}
if body.Target != "" {
t.ChapterID = &chID
}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "记录成功"})
}
// UserUpdate POST /api/user/update 更新昵称、头像、手机、微信号等
func UserUpdate(c *gin.Context) {
var body struct {