Files
soul-yongping/soul-api/internal/handler/vip.go
卡若 fa3da12b16 feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本
- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整
- soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新
- soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL
- .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle

Made-with: Cursor
2026-03-23 18:38:23 +08:00

448 lines
13 KiB
Go
Raw 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"
"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 表判断是否 VIPis_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 买断(永久权益),不等同于 VIP365天
// 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()
// 校验是否 VIPusers 或 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 首页「超级个体」横滑:必须有可展示头像 URL且展示名非微信默认占位
func vipMemberShowcaseOK(item gin.H) bool {
av, _ := item["avatar"].(string)
if strings.TrimSpace(av) == "" {
return false
}
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]
}