Files
卡若 b5f5180654 chore: 以本地工作区为准全量快照同步 GitHub
包含小程序、管理端、soul-api、脚本与静态资源等当前本地全部已跟踪与新增文件(排除 .DS_Store 与 .obsidian)。

Made-with: Cursor
2026-04-13 11:49:38 +08:00

484 lines
15 KiB
Go
Raw Permalink 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=365 读书会权益(订单 product_type 仍为 fullbook不等同于 VIP 年度会员。
// 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
}
// isVipExplicitlyRevoked 后台将 users.is_vip 置为 false 时,视为管理员显式收回权益,
// 不再用「曾 paid 的 VIP 订单」兜底(否则取消超级个体后 /vip/status 仍返回 isVip=true
func isVipExplicitlyRevoked(db *gorm.DB, userID string) bool {
var u struct {
IsVip *bool `gorm:"column:is_vip"`
}
err := db.Table("users").Select("is_vip").Where("id = ?", userID).First(&u).Error
if err != nil {
return false
}
if u.IsVip == nil {
return false
}
return !*u.IsVip
}
// isVipEffective 是否仍视为 VIP先 users 有效 VIP未显式关闭 is_vip 时可用订单兜底
func isVipEffective(db *gorm.DB, userID string) (bool, *time.Time) {
if isVipExplicitlyRevoked(db, userID) {
return false, nil
}
if ok, exp := isVipFromUsers(db, userID); ok {
return true, exp
}
return isVipFromOrders(db, userID)
}
// VipStatus GET /api/miniprogram/vip/status 小程序-查询用户 VIP 状态
// 优先 users 表is_vip、vip_expire_date无则从 orders 兜底(若 is_vip 未显式为 false
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()
isVip, expireDate := isVipEffective(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与 VipStatus 一致:显式关闭 is_vip 时不认订单兜底)
isVip, _ := isVipEffective(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, _ := isVipEffective(db, id)
if !isVip {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "会员不存在或已过期"})
return
}
item := formatVipMember(c, db, &user, true)
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
return
}
// 列表:横滑优先展示「非微信默认昵称」的会员;多取若干条过滤;若过滤后为空则仍返回 VIP 列表(昵称/头像由 formatVipMember 兜底)
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(c, db, &users[i], true)
if !vipMemberShowcaseOK(item) {
continue
}
list = append(list, item)
if len(list) >= limit {
break
}
}
// 过滤过严导致横滑为空时:仍返回有效 VIPformatVipMember 已兜底昵称「创业者」与 MBTI 头像),避免首页「超级个体」整块空白
if len(list) == 0 && len(users) > 0 {
for i := range users {
if len(list) >= limit {
break
}
list = append(list, formatVipMember(c, db, &users[i], true))
}
}
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(c *gin.Context, 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 = resolveAvatarURLWithRequest(c, 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
// defaultMbtiPngPath returns the static PNG avatar path for the given MBTI type.
func defaultMbtiPngPath(mbti string) string {
mbti = strings.ToUpper(strings.TrimSpace(mbti))
if mbti == "" {
return ""
}
return "/static/mbti-avatars-png-male/" + mbti + ".png"
}
func getMbtiAvatar(db *gorm.DB, mbti string) string {
now := time.Now().Unix()
if _mbtiAvatarCache != nil && now-_mbtiAvatarCacheTs < 300 {
if url := _mbtiAvatarCache[mbti]; url != "" {
return url
}
return defaultMbtiPngPath(mbti)
}
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "mbti_avatars").First(&cfg).Error; err != nil {
return defaultMbtiPngPath(mbti)
}
m := make(map[string]string)
if err := json.Unmarshal([]byte(cfg.ConfigValue), &m); err != nil {
return defaultMbtiPngPath(mbti)
}
_mbtiAvatarCache = m
_mbtiAvatarCacheTs = now
if url := m[mbti]; url != "" {
return url
}
return defaultMbtiPngPath(mbti)
}