444 lines
13 KiB
Go
444 lines
13 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"soul-api/internal/database"
|
||
"soul-api/internal/model"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 默认 VIP 价格与权益(与 next-project 一致)
|
||
const defaultVipPrice = 1980
|
||
|
||
var defaultVipRights = []string{
|
||
"智能纪要 - 每天推送派对精华",
|
||
"会议纪要库 - 所有场次会议纪要",
|
||
"案例库 - 30-100个创业项目案例",
|
||
"链接资源 - 进群聊天链接资源",
|
||
"解锁全部章节内容(365天)",
|
||
"匹配所有创业伙伴",
|
||
"创业老板排行榜展示",
|
||
"专属VIP标识",
|
||
}
|
||
|
||
// isVipFromUsers 从 users 表判断是否 VIP(is_vip=1 且 vip_expire_date>NOW)
|
||
func isVipFromUsers(db *gorm.DB, userID string) (bool, *time.Time) {
|
||
var u struct {
|
||
IsVip *bool
|
||
VipExpireDate *time.Time
|
||
}
|
||
err := db.Table("users").Select("is_vip", "vip_expire_date").Where("id = ?", userID).First(&u).Error
|
||
if err != nil || u.IsVip == nil || !*u.IsVip || u.VipExpireDate == nil {
|
||
return false, nil
|
||
}
|
||
if u.VipExpireDate.Before(time.Now()) {
|
||
return false, nil
|
||
}
|
||
return true, u.VipExpireDate
|
||
}
|
||
|
||
// isVipFromOrders 从 orders 表判断是否 VIP(兜底)
|
||
func isVipFromOrders(db *gorm.DB, userID string) (bool, *time.Time) {
|
||
var order model.Order
|
||
// 注意:fullbook=9.9 买断(永久权益),不等同于 VIP(365天)。
|
||
// VIP 仅认 product_type=vip。
|
||
err := db.Where("user_id = ? AND (status = ? OR status = ?) AND product_type = ?",
|
||
userID, "paid", "completed", "vip").
|
||
Order("pay_time DESC").First(&order).Error
|
||
if err != nil || order.PayTime == nil {
|
||
return false, nil
|
||
}
|
||
exp := order.PayTime.AddDate(0, 0, 365)
|
||
if exp.Before(time.Now()) {
|
||
return false, nil
|
||
}
|
||
return true, &exp
|
||
}
|
||
|
||
// VipStatus GET /api/miniprogram/vip/status 小程序-查询用户 VIP 状态
|
||
// 优先 users 表(is_vip、vip_expire_date),无则从 orders 兜底
|
||
func VipStatus(c *gin.Context) {
|
||
userID := c.Query("userId")
|
||
if userID == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
|
||
// 1. 优先 users 表
|
||
isVip, expireDate := isVipFromUsers(db, userID)
|
||
if !isVip {
|
||
// 2. 兜底:从 orders 查
|
||
isVip, expireDate = isVipFromOrders(db, userID)
|
||
if !isVip {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": gin.H{
|
||
"isVip": false,
|
||
"daysRemaining": 0,
|
||
"expireDate": "",
|
||
"profile": gin.H{"vipName": "", "vipProject": "", "vipContact": "", "vipAvatar": "", "vipBio": ""},
|
||
"price": float64(defaultVipPrice),
|
||
"rights": defaultVipRights,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
}
|
||
|
||
// 查用户 VIP 资料(profile)
|
||
var user model.User
|
||
_ = db.Where("id = ?", userID).First(&user).Error
|
||
profile := buildVipProfile(&user)
|
||
|
||
daysRemaining := 0
|
||
expStr := ""
|
||
if expireDate != nil {
|
||
daysRemaining = int(time.Until(*expireDate).Hours()/24) + 1
|
||
if daysRemaining < 0 {
|
||
daysRemaining = 0
|
||
}
|
||
expStr = expireDate.Format("2006-01-02")
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": gin.H{
|
||
"isVip": true,
|
||
"daysRemaining": daysRemaining,
|
||
"expireDate": expStr,
|
||
"profile": profile,
|
||
"price": float64(defaultVipPrice),
|
||
"rights": defaultVipRights,
|
||
},
|
||
})
|
||
}
|
||
|
||
// buildVipProfile 仅从 vip_* 字段构建会员资料,不混入用户信息(nickname/avatar/phone/wechat_id)
|
||
// 返回字段与 users 表 vip_* 对应,统一 vipName/vipProject/vipContact/vipAvatar/vipBio
|
||
func buildVipProfile(u *model.User) gin.H {
|
||
return gin.H{
|
||
"vipName": getStr(u.VipName),
|
||
"vipProject": getStr(u.VipProject),
|
||
"vipContact": getStr(u.VipContact),
|
||
"vipAvatar": resolveAvatarURL(getStr(u.VipAvatar)),
|
||
"vipBio": getStr(u.VipBio),
|
||
}
|
||
}
|
||
|
||
func getStr(s *string) string {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
return *s
|
||
}
|
||
|
||
// VipProfileGet GET /api/miniprogram/vip/profile 小程序-获取 VIP 资料
|
||
func VipProfileGet(c *gin.Context) {
|
||
userID := c.Query("userId")
|
||
if userID == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
var user model.User
|
||
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"vipName": "", "vipProject": "", "vipContact": "", "vipAvatar": "", "vipBio": ""}})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": buildVipProfile(&user),
|
||
})
|
||
}
|
||
|
||
// VipProfilePost POST /api/miniprogram/vip/profile 小程序-更新 VIP 资料
|
||
// 请求/响应字段与 users 表 vip_* 一致:vipName/vipProject/vipContact/vipAvatar/vipBio
|
||
func VipProfilePost(c *gin.Context) {
|
||
var req struct {
|
||
UserID string `json:"userId" binding:"required"`
|
||
VipName string `json:"vipName"`
|
||
VipProject string `json:"vipProject"`
|
||
VipContact string `json:"vipContact"`
|
||
VipAvatar string `json:"vipAvatar"`
|
||
VipBio string `json:"vipBio"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
|
||
// 校验是否 VIP(users 或 orders)
|
||
isVip, _ := isVipFromUsers(db, req.UserID)
|
||
if !isVip {
|
||
isVip, _ = isVipFromOrders(db, req.UserID)
|
||
}
|
||
if !isVip {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅VIP会员可填写资料"})
|
||
return
|
||
}
|
||
|
||
updates := map[string]interface{}{}
|
||
if req.VipName != "" {
|
||
updates["vip_name"] = req.VipName
|
||
}
|
||
if req.VipProject != "" {
|
||
updates["vip_project"] = req.VipProject
|
||
}
|
||
if req.VipContact != "" {
|
||
updates["vip_contact"] = req.VipContact
|
||
}
|
||
if req.VipAvatar != "" {
|
||
updates["vip_avatar"] = avatarToPath(req.VipAvatar)
|
||
}
|
||
if req.VipBio != "" {
|
||
updates["vip_bio"] = req.VipBio
|
||
}
|
||
if len(updates) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无更新内容"})
|
||
return
|
||
}
|
||
|
||
if err := db.Model(&model.User{}).Where("id = ?", req.UserID).Updates(updates).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "更新失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料已更新"})
|
||
}
|
||
|
||
// VipMembers GET /api/miniprogram/vip/members 小程序-VIP 会员列表(无 id 返回列表;有 id 返回单个)
|
||
// 优先 users 表(is_vip=1 且 vip_expire_date>NOW),无则从 orders 兜底
|
||
func VipMembers(c *gin.Context) {
|
||
id := c.Query("id")
|
||
limit := 20
|
||
if l := c.Query("limit"); l != "" {
|
||
if n, err := parseInt(l); err == nil && n > 0 && n <= 100 {
|
||
limit = n
|
||
}
|
||
}
|
||
db := database.DB()
|
||
|
||
if id != "" {
|
||
// 单个:优先 users 表
|
||
var user model.User
|
||
if err := db.Where("id = ?", id).First(&user).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||
return
|
||
}
|
||
isVip, _ := isVipFromUsers(db, id)
|
||
if !isVip {
|
||
isVip, _ = isVipFromOrders(db, id)
|
||
}
|
||
if !isVip {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "会员不存在或已过期"})
|
||
return
|
||
}
|
||
item := formatVipMember(db, &user, true)
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
|
||
return
|
||
}
|
||
|
||
// 列表:超级个体横滑仅展示「有头像 + 非微信默认昵称」的会员;多取若干条再过滤以尽量凑满 limit
|
||
fetchLimit := limit * 8
|
||
if fetchLimit < 48 {
|
||
fetchLimit = 48
|
||
}
|
||
if fetchLimit > 250 {
|
||
fetchLimit = 250
|
||
}
|
||
|
||
var users []model.User
|
||
err := db.Table("users").
|
||
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort").
|
||
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
|
||
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
|
||
Limit(fetchLimit).
|
||
Find(&users).Error
|
||
|
||
if err != nil || len(users) == 0 {
|
||
// 兜底:从 orders 查
|
||
var userIDs []string
|
||
db.Model(&model.Order{}).Select("DISTINCT user_id").
|
||
Where("(status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", "paid", "completed", "fullbook", "vip").
|
||
Pluck("user_id", &userIDs)
|
||
if len(userIDs) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "total": 0})
|
||
return
|
||
}
|
||
db.Where("id IN ?", userIDs).Limit(fetchLimit).Find(&users)
|
||
}
|
||
|
||
list := make([]gin.H, 0, limit)
|
||
for i := range users {
|
||
item := formatVipMember(db, &users[i], true)
|
||
if !vipMemberShowcaseOK(item) {
|
||
continue
|
||
}
|
||
list = append(list, item)
|
||
if len(list) >= limit {
|
||
break
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
||
}
|
||
|
||
// isWechatDefaultNickname 微信未改昵称时的占位(如「微信用户」+ 后缀)
|
||
func isWechatDefaultNickname(s string) bool {
|
||
s = strings.TrimSpace(s)
|
||
return s != "" && strings.HasPrefix(s, "微信用户")
|
||
}
|
||
|
||
// vipMemberShowcaseOK 首页「超级个体」横滑:展示名非微信默认占位即可;无头像时小程序用首字/MBTI 映射图(后台可配 mbti_avatars)
|
||
func vipMemberShowcaseOK(item gin.H) bool {
|
||
name, _ := item["name"].(string)
|
||
name = strings.TrimSpace(name)
|
||
if name == "" || isWechatDefaultNickname(name) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// personLeadTokenByUserID 返回 persons 中与用户绑定的 token,供小程序 CKBLead 的 targetUserId(与文章 @ 一致)
|
||
func personLeadTokenByUserID(db *gorm.DB, userID string) string {
|
||
if db == nil || strings.TrimSpace(userID) == "" {
|
||
return ""
|
||
}
|
||
var p model.Person
|
||
if err := db.Select("token").Where("user_id = ?", userID).First(&p).Error; err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(p.Token)
|
||
}
|
||
|
||
// formatVipMember 构建会员展示数据;超级个体头像和昵称用用户资料(随「我的」修改实时生效)
|
||
// 优先 nickname/avatar,无则回退 vip_name/vip_avatar;用于首页超级个体、会员详情页等
|
||
func formatVipMember(db *gorm.DB, u *model.User, isVip bool) gin.H {
|
||
name := ""
|
||
if u.Nickname != nil && *u.Nickname != "" {
|
||
nn := strings.TrimSpace(*u.Nickname)
|
||
if !isWechatDefaultNickname(nn) {
|
||
name = nn
|
||
}
|
||
}
|
||
if name == "" && u.VipName != nil && *u.VipName != "" {
|
||
name = strings.TrimSpace(*u.VipName)
|
||
}
|
||
if name == "" {
|
||
name = "创业者"
|
||
}
|
||
name = sanitizeDisplayOneLine(name)
|
||
if name == "" {
|
||
name = "创业者"
|
||
}
|
||
avatar := getUrlValue(u.Avatar)
|
||
if avatar == "" {
|
||
avatar = getUrlValue(u.VipAvatar)
|
||
}
|
||
if avatar == "" && u.Mbti != nil && *u.Mbti != "" {
|
||
avatar = getMbtiAvatar(db, strings.ToUpper(strings.TrimSpace(*u.Mbti)))
|
||
}
|
||
avatar = resolveAvatarURL(avatar)
|
||
project := getStringValue(u.VipProject)
|
||
if project == "" {
|
||
project = getStringValue(u.ProjectIntro)
|
||
}
|
||
bio := ""
|
||
if u.VipBio != nil {
|
||
bio = *u.VipBio
|
||
}
|
||
contact := ""
|
||
if u.VipContact != nil {
|
||
contact = *u.VipContact
|
||
}
|
||
if contact == "" {
|
||
contact = getStringValue(u.Phone)
|
||
}
|
||
vipRole := ""
|
||
if u.VipRole != nil {
|
||
vipRole = *u.VipRole
|
||
}
|
||
vipSort := 0
|
||
if u.VipSort != nil {
|
||
vipSort = *u.VipSort
|
||
}
|
||
ckbLeadToken := ""
|
||
if db != nil {
|
||
ckbLeadToken = personLeadTokenByUserID(db, u.ID)
|
||
}
|
||
return gin.H{
|
||
"id": u.ID,
|
||
"name": name,
|
||
"nickname": name,
|
||
"avatar": avatar,
|
||
"vip_name": name,
|
||
"vipName": name,
|
||
"vipRole": vipRole,
|
||
"vip_avatar": avatar,
|
||
"vipAvatar": avatar,
|
||
"vipProject": project,
|
||
"vip_project": project,
|
||
"vipContact": contact,
|
||
"vip_contact": contact,
|
||
"vipBio": bio,
|
||
"wechatId": getStringValue(u.WechatID),
|
||
"wechat_id": getStringValue(u.WechatID),
|
||
"phone": getStringValue(u.Phone),
|
||
"mbti": getStringValue(u.Mbti),
|
||
"region": getStringValue(u.Region),
|
||
"industry": getStringValue(u.Industry),
|
||
"position": getStringValue(u.Position),
|
||
"businessScale": getStringValue(u.BusinessScale),
|
||
"business_scale": getStringValue(u.BusinessScale),
|
||
"skills": getStringValue(u.Skills),
|
||
"storyBestMonth": getStringValue(u.StoryBestMonth),
|
||
"story_best_month": getStringValue(u.StoryBestMonth),
|
||
"storyAchievement": getStringValue(u.StoryAchievement),
|
||
"story_achievement": getStringValue(u.StoryAchievement),
|
||
"storyTurning": getStringValue(u.StoryTurning),
|
||
"story_turning": getStringValue(u.StoryTurning),
|
||
"helpOffer": getStringValue(u.HelpOffer),
|
||
"help_offer": getStringValue(u.HelpOffer),
|
||
"helpNeed": getStringValue(u.HelpNeed),
|
||
"help_need": getStringValue(u.HelpNeed),
|
||
"projectIntro": getStringValue(u.ProjectIntro),
|
||
"project_intro": getStringValue(u.ProjectIntro),
|
||
"vipSort": vipSort,
|
||
"vip_sort": vipSort,
|
||
"is_vip": isVip,
|
||
"ckbLeadToken": ckbLeadToken,
|
||
}
|
||
}
|
||
|
||
func parseInt(s string) (int, error) {
|
||
return strconv.Atoi(s)
|
||
}
|
||
|
||
var _mbtiAvatarCache map[string]string
|
||
var _mbtiAvatarCacheTs int64
|
||
|
||
func getMbtiAvatar(db *gorm.DB, mbti string) string {
|
||
now := time.Now().Unix()
|
||
if _mbtiAvatarCache != nil && now-_mbtiAvatarCacheTs < 300 {
|
||
return _mbtiAvatarCache[mbti]
|
||
}
|
||
var cfg model.SystemConfig
|
||
if err := db.Where("config_key = ?", "mbti_avatars").First(&cfg).Error; err != nil {
|
||
return ""
|
||
}
|
||
m := make(map[string]string)
|
||
if err := json.Unmarshal([]byte(cfg.ConfigValue), &m); err != nil {
|
||
return ""
|
||
}
|
||
_mbtiAvatarCache = m
|
||
_mbtiAvatarCacheTs = now
|
||
return m[mbti]
|
||
}
|