Files
Mycontent/soul-api/internal/handler/match.go
Alex-larget 360bba697b refactor: update match configuration and user matching logic
- Changed the daily free match limit to a lifetime limit, aligning with backend configurations.
- Updated the match price display to include original pricing for better user clarity.
- Refactored match quota handling to sync with the server, ensuring accurate match counts and purchase statuses.
- Enhanced UI messages to reflect changes in match availability and pricing.
- Removed deprecated logic related to daily match counts, streamlining the matching process.
2026-04-01 17:09:45 +08:00

387 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 (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const defaultFreeMatchLimit = 1
const completeProfileSQL = "((phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != ''))"
// MatchQuota 匹配次数配额(纯计算:订单 + match_records
// 免费次数为「终身」额度不按自然日重置RemainToday JSON 字段名历史遗留,语义为「当前剩余可匹配次数」。
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"` // 当前剩余可匹配次数(免费剩余 + 已购剩余)
}
// normalizeFreeMatchLimit 产品规则:终身免费匹配仅 1 次(不按日重置);配置大于 1 时按 1 生效
func normalizeFreeMatchLimit(n int) int {
if n <= 0 {
return defaultFreeMatchLimit
}
if n > 1 {
return 1
}
return n
}
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 normalizeFreeMatchLimit(int(v))
}
return defaultFreeMatchLimit
}
// GetMatchQuota 根据订单和 match_records 纯计算用户匹配配额(免费次数为终身额度,不按日重置)
func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
freeLimit = normalizeFreeMatchLimit(freeLimit)
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 lifetimeMatches int64
db.Model(&model.MatchRecord{}).Where("user_id = ?", userID).Count(&lifetimeMatches)
fl := int64(freeLimit)
beyondFree := lifetimeMatches - fl
if beyondFree < 0 {
beyondFree = 0
}
purchasedUsed := beyondFree
if purchasedUsed > purchasedTotal {
purchasedUsed = purchasedTotal
}
freeRemain := fl - lifetimeMatches
if freeRemain < 0 {
freeRemain = 0
}
purchasedRemain := purchasedTotal - purchasedUsed
if purchasedRemain < 0 {
purchasedRemain = 0
}
remainTotal := freeRemain + purchasedRemain
if remainTotal < 0 {
remainTotal = 0
}
return MatchQuota{
PurchasedTotal: purchasedTotal,
PurchasedUsed: purchasedUsed,
MatchesUsedToday: matchesToday,
FreeRemainToday: freeRemain,
PurchasedRemain: purchasedRemain,
RemainToday: remainTotal,
}
}
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},
gin.H{"id": "mentor", "label": "导师顾问", "matchLabel": "导师顾问", "icon": "❤️", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
}
// MatchConfigGet GET /api/match/config
func MatchConfigGet(c *gin.Context) {
db := database.DB()
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"matchTypes": defaultMatchTypes,
"freeMatchLimit": 1,
"matchPrice": 1,
"matchPriceOriginal": 9.9,
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
},
"source": "default",
})
return
}
var config map[string]interface{}
_ = json.Unmarshal(cfg.ConfigValue, &config)
matchTypes := defaultMatchTypes
if v, ok := config["matchTypes"].([]interface{}); ok && len(v) > 0 {
matchTypes = make([]gin.H, 0, len(v))
for _, t := range v {
if m, ok := t.(map[string]interface{}); ok {
enabled := true
if e, ok := m["enabled"].(bool); ok && !e {
enabled = false
}
if enabled {
matchTypes = append(matchTypes, gin.H(m))
}
}
}
if len(matchTypes) == 0 {
matchTypes = defaultMatchTypes
}
}
freeMatchLimit := defaultFreeMatchLimit
if v, ok := config["freeMatchLimit"].(float64); ok && int(v) > 0 {
freeMatchLimit = normalizeFreeMatchLimit(int(v))
}
matchPrice := 1
if v, ok := config["matchPrice"].(float64); ok {
matchPrice = int(v)
}
matchPriceOriginal := 9.9
if v, ok := config["matchPriceOriginal"].(float64); ok && v > 0 {
matchPriceOriginal = v
}
settings := gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10}
if s, ok := config["settings"].(map[string]interface{}); ok {
for k, v := range s {
settings[k] = v
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"matchTypes": matchTypes,
"freeMatchLimit": freeMatchLimit,
"matchPrice": matchPrice,
"matchPriceOriginal": matchPriceOriginal,
"settings": settings,
}, "source": "database"})
}
// MatchConfigPost POST /api/match/config
func MatchConfigPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MatchUsers POST /api/match/users
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
if err := db.Where("id = ?", body.UserID).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户不存在"})
return
}
phoneOK := user.Phone != nil && strings.TrimSpace(*user.Phone) != ""
wechatOK := user.WechatID != nil && strings.TrimSpace(*user.WechatID) != ""
if !phoneOK && !wechatOK {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请先完善手机号或微信号后再发起匹配",
"errorCode": "ERR_PROFILE_INCOMPLETE",
})
return
}
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
}
}
// 读取 poolSettings 配置决定匹配范围
var cfg model.SystemConfig
poolSources := []string{"vip"}
requirePhone := true
requireNickname := false
requireAvatar := false
requireBusiness := false
if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err == nil {
var cfgMap map[string]interface{}
if json.Unmarshal(cfg.ConfigValue, &cfgMap) == nil {
if ps, ok := cfgMap["poolSettings"].(map[string]interface{}); ok {
if arr, ok := ps["poolSource"].([]interface{}); ok && len(arr) > 0 {
poolSources = make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
poolSources = append(poolSources, s)
}
}
} else if v, ok := ps["poolSource"].(string); ok {
poolSources = []string{v}
}
if v, ok := ps["requirePhone"].(bool); ok {
requirePhone = v
}
if v, ok := ps["requireNickname"].(bool); ok {
requireNickname = v
}
if v, ok := ps["requireAvatar"].(bool); ok {
requireAvatar = v
}
if v, ok := ps["requireBusiness"].(bool); ok {
requireBusiness = v
}
}
}
}
hasSource := func(s string) bool {
for _, v := range poolSources {
if v == s {
return true
}
}
return false
}
// 排除当天已匹配过的用户
var todayMatchedIDs []string
db.Model(&model.MatchRecord{}).Where("user_id = ? AND created_at >= CURDATE()", body.UserID).
Pluck("matched_user_id", &todayMatchedIDs)
var users []model.User
q := db.Where("id != ?", body.UserID)
if len(todayMatchedIDs) > 0 {
q = q.Where("id NOT IN ?", todayMatchedIDs)
}
// 按池子来源筛选(多选取并集)
if hasSource("all") {
q = q.Where("((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))")
} else {
var orConds []string
if hasSource("vip") {
orConds = append(orConds, "(is_vip = 1 AND vip_expire_date > NOW())")
}
if hasSource("complete") {
orConds = append(orConds, completeProfileSQL)
}
if len(orConds) > 0 {
combined := "(" + orConds[0]
for i := 1; i < len(orConds); i++ {
combined += " OR " + orConds[i]
}
combined += ")"
q = q.Where(combined)
} else {
q = q.Where("is_vip = 1 AND vip_expire_date > NOW()")
}
}
// partner 类型强制 VIP
if body.MatchType == "partner" && !hasSource("vip") && !hasSource("all") {
q = q.Where("is_vip = 1 AND vip_expire_date > NOW()")
}
// 按完善程度筛选
if requirePhone {
q = q.Where("phone IS NOT NULL AND phone != ''")
}
if requireNickname {
q = q.Where("nickname IS NOT NULL AND nickname != ''")
}
if requireAvatar {
q = q.Where("avatar IS NOT NULL AND avatar != ''")
}
if requireBusiness {
q = q.Where("(help_offer IS NOT NULL AND help_offer != '') OR (help_need IS NOT NULL AND help_need != '')")
}
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, "code": "NO_USERS"})
return
}
// 随机选一个
idx := 0
if len(users) > 1 {
idx = int(users[0].CreatedAt.Unix() % int64(len(users)))
}
r := users[idx]
nickname := "微信用户"
if r.Nickname != nil {
nickname = *r.Nickname
}
avatar := ""
if r.Avatar != nil {
avatar = resolveAvatarURL(*r.Avatar)
}
if avatar == "" && r.Mbti != nil {
mbti := strings.ToUpper(strings.TrimSpace(*r.Mbti))
if mbti != "" {
avatar = resolveAvatarURL(getMbtiAvatar(db, mbti))
}
}
mbtiOut := ""
if r.Mbti != nil {
mbtiOut = strings.TrimSpace(*r.Mbti)
}
wechat := ""
if r.WechatID != nil {
wechat = *r.WechatID
}
phone := ""
if r.Phone != nil {
phone = *r.Phone
}
intro := "来自Soul创业派对的伙伴"
matchLabels := map[string]string{"partner": "找伙伴", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
tag := matchLabels[body.MatchType]
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{
"id": r.ID, "nickname": nickname, "avatar": avatar, "mbti": mbtiOut, "wechat": wechat, "phone": phone,
"introduction": intro, "tags": []string{"创业者", tag},
"matchScore": 80 + (r.CreatedAt.Unix() % 20),
"commonInterests": []gin.H{
gin.H{"icon": "📚", "text": "都在读《创业派对》"},
gin.H{"icon": "💼", "text": "对创业感兴趣"},
gin.H{"icon": "🎯", "text": "相似的发展方向"},
},
},
"totalUsers": len(users),
})
}