Files

782 lines
22 KiB
Go
Raw Permalink 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"
"math/rand"
"net/http"
"sort"
"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
var cfgMap map[string]interface{}
if err := db.Where("config_key = ?", "match_config").First(&cfg).Error; err == nil {
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(50).Find(&users).Error; err != nil || len(users) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "暂无匹配用户", "data": nil, "code": "NO_USERS"})
return
}
// 读取 algorithmLayers 配置
defaultLayers := []matchAlgLayer{
{ID: "tag_match", Enabled: true, Weight: 30},
{ID: "mbti_complement", Enabled: true, Weight: 20},
{ID: "gender_match", Enabled: true, Weight: 10},
{ID: "random", Enabled: true, Weight: 5},
}
layers := defaultLayers
if cfgMap != nil {
if al, ok := cfgMap["algorithmLayers"].([]interface{}); ok && len(al) > 0 {
parsed := make([]matchAlgLayer, 0, len(al))
for _, item := range al {
if m, ok := item.(map[string]interface{}); ok {
l := matchAlgLayer{Enabled: true, Weight: 10}
if id, ok := m["id"].(string); ok {
l.ID = id
}
if en, ok := m["enabled"].(bool); ok {
l.Enabled = en
}
if w, ok := m["weight"].(float64); ok {
l.Weight = w
}
parsed = append(parsed, l)
}
}
if len(parsed) > 0 {
layers = parsed
}
}
}
r := scoreAndSelectMatch(user, users, layers)
nickname := "微信用户"
if r.Nickname != nil {
nickname = *r.Nickname
}
avatar := ""
if r.Avatar != nil {
avatar = resolveAvatarURLWithRequest(c, *r.Avatar)
}
if avatar == "" && r.Mbti != nil {
mbti := strings.ToUpper(strings.TrimSpace(*r.Mbti))
if mbti != "" {
avatar = resolveAvatarURLWithRequest(c, 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 = "找伙伴"
}
matchScore := computeMatchScore(user, r, nil)
scoreInt := int(matchScore)
// 写入匹配记录(含发起者的 phone/wechat_id 便于后续联系)
rec := model.MatchRecord{
ID: fmt.Sprintf("mr_%d", time.Now().UnixNano()),
UserID: body.UserID,
MatchedUserID: r.ID,
MatchType: body.MatchType,
MatchScore: &scoreInt,
}
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)
}
// 后端兜底:匹配成功自动上报 CKB小程序也会上报CKBMatch 内部有 5 分钟去重)
// 必须用「发起人」联系方式写入 ckb_lead_records误用被匹配人 r 会导致 user_id 与手机号不一致或整列为空
go func() {
initPhone := strings.TrimSpace(body.Phone)
initWechat := strings.TrimSpace(body.WechatID)
if initPhone == "" && user.Phone != nil {
initPhone = strings.TrimSpace(*user.Phone)
}
if initWechat == "" && user.WechatID != nil {
initWechat = strings.TrimSpace(*user.WechatID)
}
autoCKBReport(db, body.UserID, body.MatchType, initPhone, initWechat)
}()
commonInterests := buildCommonInterests(user, r)
dynamicTags := buildDynamicTags(r, tag)
concept := buildConcept(r)
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": dynamicTags,
"matchScore": matchScore,
"commonInterests": commonInterests,
"concept": concept,
},
"totalUsers": len(users),
})
}
// --- 匹配算法 ---
type matchAlgLayer struct {
ID string
Enabled bool
Weight float64
}
var mbtiComplements = map[string]string{
"INTJ": "ENFP", "ENFP": "INTJ",
"INTP": "ENTJ", "ENTJ": "INTP",
"INFJ": "ENTP", "ENTP": "INFJ",
"INFP": "ENFJ", "ENFJ": "INFP",
"ISTJ": "ESFP", "ESFP": "ISTJ",
"ISFJ": "ESTP", "ESTP": "ISFJ",
"ISTP": "ESFJ", "ESFJ": "ISTP",
"ISFP": "ESTJ", "ESTJ": "ISFP",
}
type scoredUser struct {
user model.User
score float64
}
func scoreAndSelectMatch(self model.User, candidates []model.User, layers []matchAlgLayer) model.User {
if len(candidates) == 1 {
return candidates[0]
}
scored := make([]scoredUser, len(candidates))
for i, c := range candidates {
scored[i] = scoredUser{user: c, score: computeMatchScore(self, c, layers)}
}
sort.Slice(scored, func(i, j int) bool { return scored[i].score > scored[j].score })
topN := 3
if topN > len(scored) {
topN = len(scored)
}
pick := rand.Intn(topN)
return scored[pick].user
}
func computeMatchScore(self, candidate model.User, layers []matchAlgLayer) float64 {
if len(layers) == 0 {
layers = []matchAlgLayer{
{ID: "tag_match", Enabled: true, Weight: 30},
{ID: "mbti_complement", Enabled: true, Weight: 20},
{ID: "gender_match", Enabled: true, Weight: 10},
{ID: "random", Enabled: true, Weight: 5},
}
}
totalWeight := 0.0
for _, l := range layers {
if l.Enabled {
totalWeight += l.Weight
}
}
if totalWeight == 0 {
return 50
}
rawScore := 0.0
for _, l := range layers {
if !l.Enabled {
continue
}
var layerScore float64
switch l.ID {
case "tag_match":
layerScore = tagMatchScore(self, candidate)
case "mbti_complement":
layerScore = mbtiScore(self, candidate)
case "gender_match":
layerScore = 0.5
case "journey_match":
layerScore = journeyMatchScore(self, candidate)
case "behavior_match":
layerScore = behaviorMatchScore(self, candidate)
case "random":
layerScore = rand.Float64()
default:
layerScore = 0.5
}
rawScore += layerScore * l.Weight
}
pct := (rawScore / totalWeight) * 100
if pct < 60 {
pct = 60 + pct*0.2
}
if pct > 99 {
pct = 99
}
return float64(int(pct))
}
func tagMatchScore(self, candidate model.User) float64 {
selfTokens := extractTokens(self)
candTokens := extractTokens(candidate)
if len(selfTokens) == 0 || len(candTokens) == 0 {
return 0.3
}
common := 0
for t := range selfTokens {
if candTokens[t] {
common++
}
}
total := len(selfTokens) + len(candTokens) - common
if total == 0 {
return 0.3
}
return float64(common) / float64(total)
}
func extractTokens(u model.User) map[string]bool {
tokens := map[string]bool{}
fields := []*string{u.HelpOffer, u.HelpNeed, u.Industry, u.Skills, u.Tags}
for _, f := range fields {
if f == nil {
continue
}
parts := strings.FieldsFunc(*f, func(r rune) bool {
return r == ',' || r == '' || r == '、' || r == '/' || r == ' ' || r == ';' || r == ''
})
for _, p := range parts {
t := strings.TrimSpace(p)
if len(t) > 0 {
tokens[strings.ToLower(t)] = true
}
}
}
return tokens
}
func mbtiScore(self, candidate model.User) float64 {
if self.Mbti == nil || candidate.Mbti == nil {
return 0.3
}
sm := strings.ToUpper(strings.TrimSpace(*self.Mbti))
cm := strings.ToUpper(strings.TrimSpace(*candidate.Mbti))
if sm == "" || cm == "" {
return 0.3
}
if complement, ok := mbtiComplements[sm]; ok && complement == cm {
return 1.0
}
shared := 0
if len(sm) == 4 && len(cm) == 4 {
for i := 0; i < 4; i++ {
if sm[i] == cm[i] {
shared++
}
}
}
return 0.3 + float64(shared)*0.15
}
func buildCommonInterests(self, candidate model.User) []gin.H {
interests := []gin.H{}
selfTokens := extractTokens(self)
candTokens := extractTokens(candidate)
for t := range selfTokens {
if candTokens[t] && len(interests) < 3 {
interests = append(interests, gin.H{"icon": "🎯", "text": "都关注:" + t})
}
}
if self.Mbti != nil && candidate.Mbti != nil {
sm := strings.ToUpper(strings.TrimSpace(*self.Mbti))
cm := strings.ToUpper(strings.TrimSpace(*candidate.Mbti))
if sm != "" && cm != "" && len(interests) < 3 {
if comp, ok := mbtiComplements[sm]; ok && comp == cm {
interests = append(interests, gin.H{"icon": "✨", "text": "MBTI 性格互补"})
}
}
}
if self.Industry != nil && candidate.Industry != nil {
if strings.EqualFold(strings.TrimSpace(*self.Industry), strings.TrimSpace(*candidate.Industry)) && len(interests) < 3 {
interests = append(interests, gin.H{"icon": "💼", "text": "同行业:" + strings.TrimSpace(*self.Industry)})
}
}
fallbacks := []gin.H{
{"icon": "🚀", "text": "创业方向相近"},
{"icon": "📚", "text": "都在读《创业派对》"},
{"icon": "🤝", "text": "乐于资源互换"},
}
fi := 0
for len(interests) < 2 && fi < len(fallbacks) {
interests = append(interests, fallbacks[fi])
fi++
}
return interests
}
func buildDynamicTags(u model.User, typeLabel string) []string {
tags := []string{typeLabel}
if u.Industry != nil {
if v := strings.TrimSpace(*u.Industry); v != "" {
tags = append(tags, v)
}
}
if u.Skills != nil {
parts := strings.FieldsFunc(*u.Skills, func(r rune) bool {
return r == ',' || r == '' || r == '、' || r == '/' || r == ';' || r == ''
})
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" && len(tags) < 4 {
tags = append(tags, t)
}
}
}
if len(tags) < 2 {
tags = append(tags, "创业者")
}
return tags
}
func buildConcept(u model.User) string {
if u.HelpOffer != nil {
v := strings.TrimSpace(*u.HelpOffer)
if v != "" {
return v
}
}
if u.HelpNeed != nil {
v := strings.TrimSpace(*u.HelpNeed)
if v != "" {
return "希望获得:" + v
}
}
return ""
}
// journeyMatchScore 旅程阶段相似度:注册时长相近 + 购买状态一致得分高
func journeyMatchScore(self, candidate model.User) float64 {
score := 0.0
// 注册时长相似度:差距越小越高
if !self.CreatedAt.IsZero() && !candidate.CreatedAt.IsZero() {
daysSelf := time.Since(self.CreatedAt).Hours() / 24
daysCand := time.Since(candidate.CreatedAt).Hours() / 24
diff := daysSelf - daysCand
if diff < 0 {
diff = -diff
}
if diff < 7 {
score += 0.5
} else if diff < 30 {
score += 0.3
} else if diff < 90 {
score += 0.15
}
}
// 购买状态一致性
selfBook := self.HasFullBook != nil && *self.HasFullBook
candBook := candidate.HasFullBook != nil && *candidate.HasFullBook
if selfBook == candBook {
score += 0.5
} else {
score += 0.1
}
return score
}
// behaviorMatchScore 活跃度匹配:推广活跃度相近得分高
func behaviorMatchScore(self, candidate model.User) float64 {
getEarnings := func(u model.User) float64 {
if u.Earnings != nil {
return *u.Earnings
}
return 0
}
getReferral := func(u model.User) int {
if u.ReferralCount != nil {
return *u.ReferralCount
}
return 0
}
se := getEarnings(self)
ce := getEarnings(candidate)
sr := getReferral(self)
cr := getReferral(candidate)
score := 0.0
// 收益差距:差距小得分高
maxE := se
if ce > maxE {
maxE = ce
}
if maxE > 0 {
diff := se - ce
if diff < 0 {
diff = -diff
}
ratio := diff / maxE
score += (1.0 - ratio) * 0.5
} else {
score += 0.25
}
// 推荐人数差距
maxR := sr
if cr > maxR {
maxR = cr
}
if maxR > 0 {
diff := sr - cr
if diff < 0 {
diff = -diff
}
ratio := float64(diff) / float64(maxR)
score += (1.0 - ratio) * 0.5
} else {
score += 0.25
}
return score
}
// autoCKBReport 后端兜底上报 CKB 留资5 分钟内同用户同类型去重由 ckb_lead_records 幂等保障)
func autoCKBReport(db *gorm.DB, userID, matchType, phone, wechat string) {
if phone == "" && wechat == "" {
return
}
source := "match_" + matchType
if matchType == "" {
source = "match_partner"
}
var exists int64
db.Model(&model.CkbLeadRecord{}).
Where("user_id = ? AND source = ? AND created_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)", userID, source).
Count(&exists)
if exists > 0 {
return
}
rec := model.CkbLeadRecord{
UserID: userID,
Action: "match",
Source: source,
Phone: phone,
WechatID: wechat,
}
_ = db.Create(&rec).Error
}