782 lines
22 KiB
Go
782 lines
22 KiB
Go
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
|
||
}
|