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
2026-03-08 11:34:11 +08:00
const completeProfileSQL = "((phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != ''))"
2026-03-07 22:58:43 +08:00
// 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 ) ,
} )
}