2026-03-07 22:58:43 +08:00
|
|
|
|
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"`
|
2026-03-08 08:00:39 +08:00
|
|
|
|
PurchasedUsed int64 `json:"purchasedUsed"`
|
2026-03-07 22:58:43 +08:00
|
|
|
|
MatchesUsedToday int64 `json:"matchesUsedToday"`
|
2026-03-08 08:00:39 +08:00
|
|
|
|
FreeRemainToday int64 `json:"freeRemainToday"`
|
|
|
|
|
|
PurchasedRemain int64 `json:"purchasedRemain"`
|
|
|
|
|
|
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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{
|
2026-03-08 08:00:39 +08:00
|
|
|
|
PurchasedTotal: purchasedTotal,
|
2026-03-07 22:58:43 +08:00
|
|
|
|
PurchasedUsed: purchasedUsed,
|
|
|
|
|
|
MatchesUsedToday: matchesToday,
|
|
|
|
|
|
FreeRemainToday: freeRemain,
|
|
|
|
|
|
PurchasedRemain: purchasedRemain,
|
|
|
|
|
|
RemainToday: remainToday,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var defaultMatchTypes = []gin.H{
|
2026-03-08 11:27:25 +08:00
|
|
|
|
gin.H{"id": "partner", "label": "找伙伴", "matchLabel": "找伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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,
|
2026-03-08 08:00:39 +08:00
|
|
|
|
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
|
2026-03-07 22:58:43 +08:00
|
|
|
|
},
|
|
|
|
|
|
"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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-08 10:27:09 +08:00
|
|
|
|
// 读取 poolSettings 配置决定匹配范围
|
|
|
|
|
|
var cfg model.SystemConfig
|
2026-03-08 11:26:20 +08:00
|
|
|
|
poolSources := []string{"vip"}
|
2026-03-08 10:27:09 +08:00
|
|
|
|
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 {
|
2026-03-08 11:26:20 +08:00
|
|
|
|
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}
|
2026-03-08 10:27:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-08 11:26:20 +08:00
|
|
|
|
hasSource := func(s string) bool {
|
|
|
|
|
|
for _, v := range poolSources {
|
|
|
|
|
|
if v == s {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-03-08 10:27:09 +08:00
|
|
|
|
|
2026-03-08 11:06:57 +08:00
|
|
|
|
// 排除当天已匹配过的用户
|
|
|
|
|
|
var todayMatchedIDs []string
|
|
|
|
|
|
db.Model(&model.MatchRecord{}).Where("user_id = ? AND created_at >= CURDATE()", body.UserID).
|
|
|
|
|
|
Pluck("matched_user_id", &todayMatchedIDs)
|
|
|
|
|
|
|
2026-03-07 22:58:43 +08:00
|
|
|
|
var users []model.User
|
2026-03-08 10:27:09 +08:00
|
|
|
|
q := db.Where("id != ?", body.UserID)
|
2026-03-08 11:06:57 +08:00
|
|
|
|
if len(todayMatchedIDs) > 0 {
|
|
|
|
|
|
q = q.Where("id NOT IN ?", todayMatchedIDs)
|
|
|
|
|
|
}
|
2026-03-08 11:26:31 +08:00
|
|
|
|
// 按池子来源筛选(多选取并集)
|
|
|
|
|
|
if hasSource("all") {
|
2026-03-08 10:27:09 +08:00
|
|
|
|
q = q.Where("((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))")
|
2026-03-08 11:26:31 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
var orConds []string
|
|
|
|
|
|
if hasSource("vip") {
|
|
|
|
|
|
orConds = append(orConds, "(is_vip = 1 AND vip_expire_date > NOW())")
|
|
|
|
|
|
}
|
|
|
|
|
|
if hasSource("complete") {
|
2026-03-08 11:34:05 +08:00
|
|
|
|
orConds = append(orConds, completeProfileSQL)
|
2026-03-08 11:26:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
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()")
|
|
|
|
|
|
}
|
2026-03-08 10:27:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
// partner 类型强制 VIP
|
2026-03-08 11:26:31 +08:00
|
|
|
|
if body.MatchType == "partner" && !hasSource("vip") && !hasSource("all") {
|
2026-03-08 08:00:39 +08:00
|
|
|
|
q = q.Where("is_vip = 1 AND vip_expire_date > NOW()")
|
|
|
|
|
|
}
|
2026-03-08 10:27:09 +08:00
|
|
|
|
// 按完善程度筛选
|
|
|
|
|
|
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 != '')")
|
|
|
|
|
|
}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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创业派对的伙伴"
|
2026-03-08 11:27:30 +08:00
|
|
|
|
matchLabels := map[string]string{"partner": "找伙伴", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|