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 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) 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}, 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": 3, "matchPrice": 1, "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 := 3 if v, ok := config["freeMatchLimit"].(float64); ok { freeMatchLimit = int(v) } matchPrice := 1 if v, ok := config["matchPrice"].(float64); ok { matchPrice = int(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, "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 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 } } // 读取 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 = *r.Avatar } 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, "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), }) }