新增匹配次数管理功能,优化用户匹配体验。通过服务端计算用户的匹配配额,更新用户状态以反映剩余匹配次数。同时,调整匹配页面逻辑,确保在匹配次数用尽时提示用户购买更多次数。更新相关API以支持匹配记录的存储与查询,提升系统稳定性。

This commit is contained in:
2026-02-11 16:53:17 +08:00
parent ecee1bb2bb
commit a1dcf599ee
18 changed files with 422 additions and 229 deletions

View File

@@ -2,14 +2,86 @@ package handler
import (
"encoding/json"
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const defaultFreeMatchLimit = 3
// MatchQuota 匹配次数配额(纯计算:订单 + match_records
type MatchQuota struct {
PurchasedTotal int64 `json:"purchasedTotal"`
PurchasedUsed int64 `json:"purchasedUsed"`
MatchesUsedToday int64 `json:"matchesUsedToday"`
FreeRemainToday int64 `json:"freeRemainToday"`
PurchasedRemain int64 `json:"purchasedRemain"`
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
}
func getFreeMatchLimit(db *gorm.DB) int {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err != nil {
return defaultFreeMatchLimit
}
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err != nil {
return defaultFreeMatchLimit
}
if v, ok := config["freeMatchLimit"].(float64); ok && v > 0 {
return int(v)
}
return defaultFreeMatchLimit
}
// GetMatchQuota 根据订单和 match_records 纯计算用户匹配配额
func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
if freeLimit <= 0 {
freeLimit = defaultFreeMatchLimit
}
var purchasedTotal int64
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userID, "match", "paid").Count(&purchasedTotal)
var matchesToday int64
db.Model(&model.MatchRecord{}).Where("user_id = ? AND created_at >= CURDATE()", userID).Count(&matchesToday)
// 历史每日超出免费部分之和 = 已消耗的购买次数
var purchasedUsed int64
db.Raw(`
SELECT COALESCE(SUM(cnt - ?), 0) FROM (
SELECT DATE(created_at) AS d, COUNT(*) AS cnt
FROM match_records WHERE user_id = ?
GROUP BY DATE(created_at)
HAVING cnt > ?
) t
`, freeLimit, userID, freeLimit).Scan(&purchasedUsed)
freeUsed := matchesToday
if freeUsed > int64(freeLimit) {
freeUsed = int64(freeLimit)
}
freeRemain := int64(freeLimit) - freeUsed
if freeRemain < 0 {
freeRemain = 0
}
purchasedRemain := purchasedTotal - purchasedUsed
if purchasedRemain < 0 {
purchasedRemain = 0
}
remainToday := freeRemain + purchasedRemain
return MatchQuota{
PurchasedTotal: purchasedTotal,
PurchasedUsed: purchasedUsed,
MatchesUsedToday: matchesToday,
FreeRemainToday: freeRemain,
PurchasedRemain: purchasedRemain,
RemainToday: remainToday,
}
}
var defaultMatchTypes = []gin.H{
gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
gin.H{"id": "investor", "label": "资源对接", "matchLabel": "资源对接", "icon": "👥", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
@@ -83,17 +155,38 @@ func MatchUsers(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
MatchType string `json:"matchType"`
Phone string `json:"phone"`
WechatID string `json:"wechatId"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"})
return
}
db := database.DB()
// 全书用户无限制,否则校验今日剩余次数
var user model.User
skipQuota := false
if err := db.Where("id = ?", body.UserID).First(&user).Error; err == nil {
skipQuota = user.HasFullBook != nil && *user.HasFullBook
}
if !skipQuota {
freeLimit := getFreeMatchLimit(db)
quota := GetMatchQuota(db, body.UserID, freeLimit)
if quota.RemainToday <= 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "今日匹配次数已用完,请购买更多次数",
"code": "QUOTA_EXCEEDED",
})
return
}
}
// 只匹配已绑定微信或手机号的用户
var users []model.User
q := database.DB().Where("id != ?", body.UserID).
q := db.Where("id != ?", body.UserID).
Where("((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))")
if err := q.Order("created_at DESC").Limit(20).Find(&users).Error; err != nil || len(users) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "暂无匹配用户", "data": nil})
c.JSON(http.StatusOK, gin.H{"success": false, "message": "暂无匹配用户", "data": nil, "code": "NO_USERS"})
return
}
// 随机选一个
@@ -124,6 +217,25 @@ func MatchUsers(c *gin.Context) {
if tag == "" {
tag = "找伙伴"
}
// 写入匹配记录(含发起者的 phone/wechat_id 便于后续联系)
rec := model.MatchRecord{
ID: fmt.Sprintf("mr_%d", time.Now().UnixNano()),
UserID: body.UserID,
MatchedUserID: r.ID,
MatchType: body.MatchType,
}
if body.MatchType == "" {
rec.MatchType = "partner"
}
if body.Phone != "" {
rec.Phone = &body.Phone
}
if body.WechatID != "" {
rec.WechatID = &body.WechatID
}
if err := db.Create(&rec).Error; err != nil {
fmt.Printf("[MatchUsers] 写入 match_records 失败: %v\n", err)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{

View File

@@ -219,6 +219,8 @@ func miniprogramPayPost(c *gin.Context) {
if description == "" {
if req.ProductType == "fullbook" {
description = "《一场Soul的创业实验》全书"
} else if req.ProductType == "match" {
description = "购买匹配次数"
} else {
description = fmt.Sprintf("章节购买-%s", req.ProductID)
}
@@ -311,7 +313,7 @@ func miniprogramPayPost(c *gin.Context) {
})
}
// GET - 查询订单状态
// GET - 查询订单状态(并主动同步:若微信已支付但本地未标记,则更新本地订单,便于配额即时生效)
func miniprogramPayGet(c *gin.Context) {
orderSn := c.Query("orderSn")
if orderSn == "" {
@@ -336,6 +338,18 @@ func miniprogramPayGet(c *gin.Context) {
switch tradeState {
case "SUCCESS":
status = "paid"
// 若微信已支付,主动同步到本地 orders不等 PayNotify便于购买次数即时生效
db := database.DB()
var order model.Order
if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err == nil && order.Status != nil && *order.Status != "paid" {
now := time.Now()
db.Model(&order).Updates(map[string]interface{}{
"status": "paid",
"transaction_id": transactionID,
"pay_time": now,
})
fmt.Printf("[PayGet] 主动同步订单已支付: %s\n", orderSn)
}
case "CLOSED", "REVOKED", "PAYERROR":
status = "failed"
case "REFUND":
@@ -429,6 +443,8 @@ func MiniprogramPayNotify(c *gin.Context) {
if attach.ProductType == "fullbook" {
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
} else if attach.ProductType == "match" {
fmt.Printf("[PayNotify] 用户购买匹配次数: %s订单 %s\n", buyerUserID, orderSn)
} else if attach.ProductType == "section" && attach.ProductID != "" {
var count int64
db.Model(&model.Order{}).Where(

View File

@@ -276,6 +276,9 @@ func UserPurchaseStatus(c *gin.Context) {
purchasedSections = append(purchasedSections, r.ProductID)
}
}
// 匹配次数配额:纯计算(订单 + match_records
freeLimit := getFreeMatchLimit(db)
matchQuota := GetMatchQuota(db, userId, freeLimit)
earnings := 0.0
if user.Earnings != nil {
earnings = *user.Earnings
@@ -288,8 +291,17 @@ func UserPurchaseStatus(c *gin.Context) {
"hasFullBook": user.HasFullBook != nil && *user.HasFullBook,
"purchasedSections": purchasedSections,
"purchasedCount": len(purchasedSections),
"earnings": earnings,
"pendingEarnings": pendingEarnings,
"matchCount": matchQuota.PurchasedTotal,
"matchQuota": gin.H{
"purchasedTotal": matchQuota.PurchasedTotal,
"purchasedUsed": matchQuota.PurchasedUsed,
"matchesUsedToday": matchQuota.MatchesUsedToday,
"freeRemainToday": matchQuota.FreeRemainToday,
"purchasedRemain": matchQuota.PurchasedRemain,
"remainToday": matchQuota.RemainToday,
},
"earnings": earnings,
"pendingEarnings": pendingEarnings,
}})
}