package handler import ( "encoding/json" "fmt" "net/http" "sort" "strconv" "strings" "time" "soul-api/internal/config" "soul-api/internal/database" "soul-api/internal/model" "soul-api/internal/oss" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // sanitizeDisplayOneLine 展示用单行:去掉粘贴/HTML 带入的换行与多余空白(轨迹标题、订单名、昵称等) func sanitizeDisplayOneLine(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } s = strings.ReplaceAll(s, "\r\n", " ") s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\r", " ") return strings.Join(strings.Fields(s), " ") } // avatarToPath 从头像 URL 提取路径(不含域名),用于保存到 DB func avatarToPath(s string) string { s = strings.TrimSpace(s) if s == "" { return s } if idx := strings.Index(s, "/uploads/"); idx >= 0 { return s[idx:] } if strings.HasPrefix(s, "/") { return s } return s } // resolveAvatarURL 将路径解析为完整可访问 URL(返回时使用) func resolveAvatarURL(s string) string { s = strings.TrimSpace(s) if s == "" { return s } // 已是完整 URL,直接返回 if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") { return s } path := s if !strings.HasPrefix(path, "/") { path = "/" + path } // OSS 存储:用 OSS 公网 URL if oss.IsEnabled() { if u := oss.PublicURL(path); u != "" { return u } } // 本地存储:用 BaseURL 拼接 if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" { return cfg.BaseURLJoin(path) } return path } // UserAddressesGet GET /api/user/addresses?userId= func UserAddressesGet(c *gin.Context) { userId := c.Query("userId") if userId == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少 userId"}) return } var list []model.UserAddress if err := database.DB().Where("user_id = ?", userId).Order("is_default DESC, updated_at DESC").Find(&list).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "查询地址失败: " + err.Error()}) return } out := make([]gin.H, 0, len(list)) for _, r := range list { full := r.Province + r.City + r.District + r.Detail out = append(out, gin.H{ "id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone, "province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail, "isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt, }) } c.JSON(http.StatusOK, gin.H{"success": true, "list": out}) } // UserAddressesPost POST /api/user/addresses func UserAddressesPost(c *gin.Context) { var body struct { UserID string `json:"userId" binding:"required"` Name string `json:"name" binding:"required"` Phone string `json:"phone" binding:"required"` Province string `json:"province"` City string `json:"city"` District string `json:"district"` Detail string `json:"detail" binding:"required"` IsDefault bool `json:"isDefault"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少必填项:userId, name, phone, detail"}) return } id := fmt.Sprintf("addr_%d", time.Now().UnixNano()%100000000000) db := database.DB() if body.IsDefault { db.Model(&model.UserAddress{}).Where("user_id = ?", body.UserID).Update("is_default", false) } addr := model.UserAddress{ ID: id, UserID: body.UserID, Name: body.Name, Phone: body.Phone, Province: body.Province, City: body.City, District: body.District, Detail: body.Detail, IsDefault: body.IsDefault, } if err := db.Create(&addr).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "添加地址失败"}) return } c.JSON(http.StatusOK, gin.H{"success": true, "id": id, "message": "添加成功"}) } // UserAddressesByID GET/PUT/DELETE /api/user/addresses/:id func UserAddressesByID(c *gin.Context) { id := c.Param("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少地址 id"}) return } db := database.DB() switch c.Request.Method { case "GET": var r model.UserAddress if err := db.Where("id = ?", id).First(&r).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"}) return } full := r.Province + r.City + r.District + r.Detail c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ "id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone, "province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail, "isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt, }}) case "PUT": var r model.UserAddress if err := db.Where("id = ?", id).First(&r).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "地址不存在"}) return } var body struct { Name *string `json:"name"` Phone *string `json:"phone"` Province *string `json:"province"` City *string `json:"city"` District *string `json:"district"` Detail *string `json:"detail"` IsDefault *bool `json:"isDefault"` } _ = c.ShouldBindJSON(&body) updates := make(map[string]interface{}) if body.Name != nil { updates["name"] = *body.Name } if body.Phone != nil { updates["phone"] = *body.Phone } if body.Province != nil { updates["province"] = *body.Province } if body.City != nil { updates["city"] = *body.City } if body.District != nil { updates["district"] = *body.District } if body.Detail != nil { updates["detail"] = *body.Detail } if body.IsDefault != nil { updates["is_default"] = *body.IsDefault if *body.IsDefault { db.Model(&model.UserAddress{}).Where("user_id = ?", r.UserID).Update("is_default", false) } } if len(updates) > 0 { updates["updated_at"] = time.Now() db.Model(&r).Updates(updates) } db.Where("id = ?", id).First(&r) full := r.Province + r.City + r.District + r.Detail c.JSON(http.StatusOK, gin.H{"success": true, "item": gin.H{ "id": r.ID, "userId": r.UserID, "name": r.Name, "phone": r.Phone, "province": r.Province, "city": r.City, "district": r.District, "detail": r.Detail, "isDefault": r.IsDefault, "fullAddress": full, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt, }, "message": "更新成功"}) case "DELETE": if err := db.Where("id = ?", id).Delete(&model.UserAddress{}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "删除失败"}) return } c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"}) } } // UserCheckPurchased GET /api/user/check-purchased?userId=&type=section|fullbook&productId= func UserCheckPurchased(c *gin.Context) { userId := c.Query("userId") type_ := c.Query("type") productId := c.Query("productId") if userId == "" { c.JSON(http.StatusBadRequest, 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.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) return } // 超级VIP(管理端开通):is_vip=1 且 vip_expire_date>NOW 时,所有文章阅读免费,无需再查订单 if user.IsVip != nil && *user.IsVip && user.VipExpireDate != nil && user.VipExpireDate.After(time.Now()) { c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_vip"}}) return } if type_ == "fullbook" { // 9.9 买断:永久权益,写入 users.has_full_book;兜底再查订单 if user.HasFullBook != nil && *user.HasFullBook { c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}}) return } var count int64 db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userId, "fullbook", "paid").Count(&count) c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "fullbook_order_exists"}[count > 0]}}) return } if type_ == "section" && productId != "" { // 章节:需要区分普通版/增值版 var ch model.Chapter // 不加载 content,避免大字段 _ = db.Select("id", "is_free", "price", "edition_standard", "edition_premium").Where("id = ?", productId).First(&ch).Error // 免费章节:直接可读 if ch.ID != "" { if (ch.IsFree != nil && *ch.IsFree) || (ch.Price != nil && *ch.Price == 0) { c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "free_section"}}) return } } isPremium := ch.ID != "" && ch.EditionPremium != nil && *ch.EditionPremium // 默认普通版:未明确标记增值版时,按普通版处理 isStandard := !isPremium // 普通版:买断可读;增值版:买断不包含 if isStandard { if user.HasFullBook != nil && *user.HasFullBook { c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}}) return } } var count int64 db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status = ?", userId, "section", productId, "paid").Count(&count) c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": count > 0, "reason": map[bool]string{true: "section_order_exists"}[count > 0]}}) return } c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": false, "reason": nil}}) } // UserProfileGet GET /api/user/profile?userId= 或 openId= func UserProfileGet(c *gin.Context) { userId := c.Query("userId") openId := c.Query("openId") if userId == "" && openId == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"}) return } db := database.DB() var user model.User q := db.Select("id", "open_id", "nickname", "avatar", "phone", "wechat_id", "referral_code", "has_full_book", "earnings", "pending_earnings", "referral_count", "created_at", "mbti", "region", "industry", "position", "business_scale", "skills", "story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro") if userId != "" { q = q.Where("id = ?", userId) } else { q = q.Where("open_id = ?", openId) } if err := q.First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) return } profileComplete := (user.Phone != nil && *user.Phone != "") || (user.WechatID != nil && *user.WechatID != "") hasAvatar := user.Avatar != nil && *user.Avatar != "" && len(*user.Avatar) > 0 str := func(p *string) interface{} { if p != nil { return *p } return "" } avatarVal := resolveAvatarURL(str(user.Avatar).(string)) resp := gin.H{ "id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": avatarVal, "phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode, "hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings, "referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar, "createdAt": user.CreatedAt, // P3 资料扩展:统一返回所有表单字段,空值用 "" 便于前端回显 "mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry), "position": str(user.Position), "businessScale": str(user.BusinessScale), "skills": str(user.Skills), "storyBestMonth": str(user.StoryBestMonth), "storyAchievement": str(user.StoryAchievement), "storyTurning": str(user.StoryTurning), "helpOffer": str(user.HelpOffer), "helpNeed": str(user.HelpNeed), "projectIntro": str(user.ProjectIntro), } c.JSON(http.StatusOK, gin.H{"success": true, "data": resp}) } // UserProfilePost POST /api/user/profile 更新用户资料 func UserProfilePost(c *gin.Context) { var body struct { UserID string `json:"userId"` OpenID string `json:"openId"` Nickname *string `json:"nickname"` Avatar *string `json:"avatar"` Phone *string `json:"phone"` WechatID *string `json:"wechatId"` Mbti *string `json:"mbti"` Region *string `json:"region"` Industry *string `json:"industry"` Position *string `json:"position"` BusinessScale *string `json:"businessScale"` Skills *string `json:"skills"` StoryBestMonth *string `json:"storyBestMonth"` StoryAchievement *string `json:"storyAchievement"` StoryTurning *string `json:"storyTurning"` HelpOffer *string `json:"helpOffer"` HelpNeed *string `json:"helpNeed"` ProjectIntro *string `json:"projectIntro"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"}) return } identifier := body.UserID byID := true if identifier == "" { identifier = body.OpenID byID = false } if identifier == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"}) return } db := database.DB() var user model.User if byID { db = db.Where("id = ?", identifier) } else { db = db.Where("open_id = ?", identifier) } if err := db.First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) return } updates := make(map[string]interface{}) if body.Nickname != nil { updates["nickname"] = *body.Nickname } if body.Avatar != nil { updates["avatar"] = avatarToPath(*body.Avatar) } if body.Phone != nil { updates["phone"] = *body.Phone } if body.WechatID != nil { updates["wechat_id"] = *body.WechatID } if body.Mbti != nil { updates["mbti"] = *body.Mbti } if body.Region != nil { updates["region"] = *body.Region } if body.Industry != nil { updates["industry"] = *body.Industry } if body.Position != nil { updates["position"] = *body.Position } if body.BusinessScale != nil { updates["business_scale"] = *body.BusinessScale } if body.Skills != nil { updates["skills"] = *body.Skills } if body.StoryBestMonth != nil { updates["story_best_month"] = *body.StoryBestMonth } if body.StoryAchievement != nil { updates["story_achievement"] = *body.StoryAchievement } if body.StoryTurning != nil { updates["story_turning"] = *body.StoryTurning } if body.HelpOffer != nil { updates["help_offer"] = *body.HelpOffer } if body.HelpNeed != nil { updates["help_need"] = *body.HelpNeed } if body.ProjectIntro != nil { updates["project_intro"] = *body.ProjectIntro } if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "没有需要更新的字段"}) return } updates["updated_at"] = time.Now() db.Model(&user).Updates(updates) // 重新查询并返回与 GET 一致的完整资料结构,空值统一为 "" profileCols := []string{"id", "open_id", "nickname", "avatar", "phone", "wechat_id", "referral_code", "created_at", "mbti", "region", "industry", "position", "business_scale", "skills", "story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro"} if err := database.DB().Select(profileCols).Where("id = ?", user.ID).First(&user).Error; err == nil { str := func(p *string) interface{} { if p != nil { return *p } return "" } avatarVal := resolveAvatarURL(str(user.Avatar).(string)) resp := gin.H{ "id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": avatarVal, "phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode, "createdAt": user.CreatedAt, "mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry), "position": str(user.Position), "businessScale": str(user.BusinessScale), "skills": str(user.Skills), "storyBestMonth": str(user.StoryBestMonth), "storyAchievement": str(user.StoryAchievement), "storyTurning": str(user.StoryTurning), "helpOffer": str(user.HelpOffer), "helpNeed": str(user.HelpNeed), "projectIntro": str(user.ProjectIntro), } c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": resp}) } else { avatarVal := "" if body.Avatar != nil { avatarVal = resolveAvatarURL(avatarToPath(*body.Avatar)) } c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": gin.H{ "id": user.ID, "nickname": body.Nickname, "avatar": avatarVal, "phone": body.Phone, "wechatId": body.WechatID, "referralCode": user.ReferralCode, }}) } } // UserPurchaseStatus GET /api/user/purchase-status?userId= func UserPurchaseStatus(c *gin.Context) { userId := c.Query("userId") if userId == "" { c.JSON(http.StatusBadRequest, 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.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) return } var orderRows []struct { ProductID string MID int } db.Raw(`SELECT DISTINCT o.product_id, c.mid FROM orders o LEFT JOIN chapters c ON c.id = o.product_id WHERE o.user_id = ? AND o.status = ? AND o.product_type = ?`, userId, "paid", "section").Scan(&orderRows) purchasedSections := make([]string, 0, len(orderRows)) sectionMidMap := make(map[string]int) for _, r := range orderRows { if r.ProductID != "" { purchasedSections = append(purchasedSections, r.ProductID) if r.MID > 0 { sectionMidMap[r.ProductID] = r.MID } } } // 是否有推荐人(被推荐绑定,可享好友优惠) var refCount int64 db.Model(&model.ReferralBinding{}).Where("referee_id = ? AND status = ?", userId, "active"). Where("expiry_date > ?", time.Now()).Count(&refCount) hasReferrer := refCount > 0 // 匹配次数配额:纯计算(订单 + match_records) freeLimit := getFreeMatchLimit(db) matchQuota := GetMatchQuota(db, userId, freeLimit) earnings := 0.0 if user.Earnings != nil { earnings = *user.Earnings } pendingEarnings := 0.0 if user.PendingEarnings != nil { pendingEarnings = *user.PendingEarnings } // 9.9 买断:仅表示“普通版买断”,不等同 VIP(增值版仍需 VIP 或单章购买) hasFullBook := user.HasFullBook != nil && *user.HasFullBook c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ "hasFullBook": hasFullBook, "purchasedSections": purchasedSections, "sectionMidMap": sectionMidMap, "purchasedCount": len(purchasedSections), "hasReferrer": hasReferrer, "matchCount": matchQuota.PurchasedTotal, "matchQuota": gin.H{ "purchasedTotal": matchQuota.PurchasedTotal, "purchasedUsed": matchQuota.PurchasedUsed, "matchesUsedToday": matchQuota.MatchesUsedToday, "freeRemainToday": matchQuota.FreeRemainToday, "purchasedRemain": matchQuota.PurchasedRemain, "remainToday": matchQuota.RemainToday, }, "earnings": earnings, "pendingEarnings": pendingEarnings, }}) } // UserReadingProgressGet GET /api/user/reading-progress?userId= func UserReadingProgressGet(c *gin.Context) { userId := c.Query("userId") if userId == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"}) return } var list []model.ReadingProgress if err := database.DB().Where("user_id = ?", userId).Order("last_open_at DESC").Find(&list).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}}) return } out := make([]gin.H, 0, len(list)) for _, r := range list { out = append(out, gin.H{ "section_id": r.SectionID, "progress": r.Progress, "duration": r.Duration, "status": r.Status, "completed_at": r.CompletedAt, "first_open_at": r.FirstOpenAt, "last_open_at": r.LastOpenAt, }) } c.JSON(http.StatusOK, gin.H{"success": true, "data": out}) } // parseCompletedAtPtr 解析 completedAt:RFC3339 字符串、毫秒/秒时间戳(float64) func parseCompletedAtPtr(v interface{}) *time.Time { if v == nil { return nil } switch x := v.(type) { case string: s := strings.TrimSpace(x) if s == "" { return nil } if t, err := time.Parse(time.RFC3339, s); err == nil { return &t } if t, err := time.Parse(time.RFC3339Nano, s); err == nil { return &t } return nil case float64: if x <= 0 { return nil } var t time.Time if x >= 1e12 { t = time.UnixMilli(int64(x)) } else { t = time.Unix(int64(x), 0) } return &t case int64: if x <= 0 { return nil } t := time.UnixMilli(x) return &t default: return nil } } // parseDuration 从 JSON 解析 duration,兼容数字与字符串(防止客户端传字符串导致累加异常) func parseDuration(v interface{}) int { if v == nil { return 0 } switch x := v.(type) { case float64: return int(x) case int: return x case int64: return int(x) case string: n, _ := strconv.Atoi(x) return n default: return 0 } } // UserReadingProgressPost POST /api/user/reading-progress func UserReadingProgressPost(c *gin.Context) { var body struct { UserID string `json:"userId" binding:"required"` SectionID string `json:"sectionId" binding:"required"` Progress int `json:"progress"` Duration interface{} `json:"duration"` // 兼容 int/float64/string,防止字符串导致累加异常 Status string `json:"status"` CompletedAt interface{} `json:"completedAt"` // 兼容 ISO 字符串或历史客户端误传的时间戳数字 } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"}) return } duration := parseDuration(body.Duration) if duration < 0 { duration = 0 } db := database.DB() now := time.Now() var existing model.ReadingProgress err := db.Where("user_id = ? AND section_id = ?", body.UserID, body.SectionID).First(&existing).Error if err == nil { newProgress := existing.Progress if body.Progress > newProgress { newProgress = body.Progress } newDuration := existing.Duration + duration newStatus := body.Status if newStatus == "" { newStatus = "reading" } completedAt := parseCompletedAtPtr(body.CompletedAt) if completedAt == nil && existing.CompletedAt != nil { completedAt = existing.CompletedAt } db.Model(&existing).Updates(map[string]interface{}{ "progress": newProgress, "duration": newDuration, "status": newStatus, "completed_at": completedAt, "last_open_at": now, "updated_at": now, }) } else { status := body.Status if status == "" { status = "reading" } completedAt := parseCompletedAtPtr(body.CompletedAt) db.Create(&model.ReadingProgress{ UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: duration, Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now, }) } c.JSON(http.StatusOK, gin.H{"success": true, "message": "进度已保存"}) } func userTrackActionLabelCN(action string) string { switch action { case "view_chapter": return "浏览章节" case "purchase": return "购买" case "match": return "派对匹配" case "login": return "登录" case "register": return "注册" case "share": return "分享" case "bind_phone": return "绑定手机" case "bind_wechat": return "绑定微信" case "fill_profile": return "完善资料" case "visit_page": return "访问页面" default: if action == "" { return "行为" } return action } } func humanTimeAgoCN(t time.Time) string { if t.IsZero() { return "" } d := time.Since(t) if d < time.Minute { return "刚刚" } if d < time.Hour { return fmt.Sprintf("%d分钟前", int(d.Minutes())) } if d < 24*time.Hour { return fmt.Sprintf("%d小时前", int(d.Hours())) } if d < 30*24*time.Hour { return fmt.Sprintf("%d天前", int(d.Hours()/24)) } return t.Format("2006-01-02") } // resolveChapterTitlesForTracks 批量解析章节/小节标题(id 或 chapter_id 命中) func resolveChapterTitlesForTracks(db *gorm.DB, tracks []model.UserTrack) map[string]string { keys := map[string]struct{}{} for _, t := range tracks { if t.ChapterID != nil && strings.TrimSpace(*t.ChapterID) != "" { keys[strings.TrimSpace(*t.ChapterID)] = struct{}{} } if t.Target != nil && strings.TrimSpace(*t.Target) != "" { keys[strings.TrimSpace(*t.Target)] = struct{}{} } } if len(keys) == 0 { return map[string]string{} } ids := make([]string, 0, len(keys)) for k := range keys { ids = append(ids, k) } var chaps []model.Chapter if err := db.Where("id IN ? OR chapter_id IN ?", ids, ids).Find(&chaps).Error; err != nil { return map[string]string{} } out := make(map[string]string) for _, c := range chaps { title := sanitizeDisplayOneLine(c.SectionTitle) if title == "" { title = sanitizeDisplayOneLine(c.ChapterTitle) } if title == "" { title = c.ID } out[c.ID] = title if strings.TrimSpace(c.ChapterID) != "" { out[strings.TrimSpace(c.ChapterID)] = title } } return out } // UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查(GORM) func UserTrackGet(c *gin.Context) { userId := c.Query("userId") phone := c.Query("phone") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) if limit < 1 || limit > 100 { limit = 50 } if userId == "" && phone == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"}) return } db := database.DB() if userId == "" && phone != "" { var u model.User if err := db.Where("phone = ?", phone).First(&u).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"}) return } userId = u.ID } var tracks []model.UserTrack if err := db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": true, "tracks": []interface{}{}, "stats": gin.H{}, "total": 0}) return } titleMap := resolveChapterTitlesForTracks(db, tracks) stats := make(map[string]int) formatted := make([]gin.H, 0, len(tracks)) for _, t := range tracks { stats[t.Action]++ target := "" if t.Target != nil { target = *t.Target } if t.ChapterID != nil && target == "" { target = *t.ChapterID } chapterTitle := "" if t.ChapterID != nil { if v, ok := titleMap[strings.TrimSpace(*t.ChapterID)]; ok { chapterTitle = v } } if chapterTitle == "" && target != "" { if v, ok := titleMap[strings.TrimSpace(target)]; ok { chapterTitle = v } } var createdAt time.Time if t.CreatedAt != nil { createdAt = *t.CreatedAt } formatted = append(formatted, gin.H{ "id": t.ID, "action": t.Action, "actionLabel": userTrackActionLabelCN(t.Action), "target": target, "chapterTitle": chapterTitle, "createdAt": t.CreatedAt, "timeAgo": humanTimeAgoCN(createdAt), }) } c.JSON(http.StatusOK, gin.H{"success": true, "tracks": formatted, "stats": stats, "total": len(formatted)}) } // DBUserTracksList GET /api/db/users/tracks?userId=xxx&limit=20 管理端查看某用户行为轨迹 func DBUserTracksList(c *gin.Context) { userId := c.Query("userId") if userId == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要 userId"}) return } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) if limit < 1 || limit > 100 { limit = 20 } db := database.DB() var tracks []model.UserTrack db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks) titleMap := resolveChapterTitlesForTracks(db, tracks) out := make([]gin.H, 0, len(tracks)) for _, t := range tracks { target := "" if t.Target != nil { target = *t.Target } chTitle := "" if t.ChapterID != nil { chTitle = titleMap[strings.TrimSpace(*t.ChapterID)] } if chTitle == "" && target != "" { chTitle = titleMap[strings.TrimSpace(target)] } var extra map[string]interface{} if len(t.ExtraData) > 0 { _ = json.Unmarshal(t.ExtraData, &extra) } module := "" if extra != nil { if m, ok := extra["module"].(string); ok { module = m } } var createdAt time.Time if t.CreatedAt != nil { createdAt = *t.CreatedAt } out = append(out, gin.H{ "id": t.ID, "action": t.Action, "actionLabel": userTrackActionLabelCN(t.Action), "target": target, "chapterTitle": chTitle, "module": module, "createdAt": t.CreatedAt, "timeAgo": humanTimeAgoCN(createdAt), }) } c.JSON(http.StatusOK, gin.H{"success": true, "tracks": out, "total": len(out)}) } // GetUserRecentTracks 内部复用:获取用户最近 N 条有效行为的可读文字(用于 webhook 等) func GetUserRecentTracks(db *gorm.DB, userId string, limit int) []string { if userId == "" || limit < 1 { return nil } var tracks []model.UserTrack db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks) titleMap := resolveChapterTitlesForTracks(db, tracks) lines := make([]string, 0, len(tracks)) for _, t := range tracks { label := userTrackActionLabelCN(t.Action) target := "" if t.ChapterID != nil { if v := titleMap[strings.TrimSpace(*t.ChapterID)]; v != "" { target = v } } if target == "" && t.Target != nil { target = *t.Target if v := titleMap[strings.TrimSpace(target)]; v != "" { target = v } } var extra map[string]interface{} if len(t.ExtraData) > 0 { _ = json.Unmarshal(t.ExtraData, &extra) } module := "" if extra != nil { if m, ok := extra["module"].(string); ok { module = m } } var line string if target != "" { line = fmt.Sprintf("%s: %s", label, sanitizeDisplayOneLine(target)) } else if module != "" { line = fmt.Sprintf("%s (%s)", label, module) } else { line = label } if t.CreatedAt != nil { line += " · " + humanTimeAgoCN(*t.CreatedAt) } lines = append(lines, line) } return lines } // UserTrackPost POST /api/user/track 记录行为(GORM) func UserTrackPost(c *gin.Context) { var body struct { UserID string `json:"userId"` Phone string `json:"phone"` Action string `json:"action"` Target string `json:"target"` ExtraData interface{} `json:"extraData"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"}) return } if body.UserID == "" && body.Phone == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要用户ID或手机号"}) return } if body.Action == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"}) return } db := database.DB() userId := body.UserID if userId == "" { var u model.User if err := db.Where("phone = ?", body.Phone).First(&u).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"}) return } userId = u.ID } trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000) chID := body.Target if body.Action == "view_chapter" { chID = body.Target } t := model.UserTrack{ ID: trackID, UserID: userId, Action: body.Action, Target: &body.Target, } if body.Target != "" { t.ChapterID = &chID } if body.ExtraData != nil { if raw, err := json.Marshal(body.ExtraData); err == nil { t.ExtraData = raw } } if err := db.Create(&t).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"}) } // MiniprogramTrackPost POST /api/miniprogram/track 小程序埋点(userId 可选,支持匿名) func MiniprogramTrackPost(c *gin.Context) { var body struct { UserID string `json:"userId"` Action string `json:"action"` Target string `json:"target"` ExtraData interface{} `json:"extraData"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"}) return } if body.Action == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"}) return } userId := body.UserID if userId == "" { userId = "anonymous" } db := database.DB() trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000) chID := body.Target if body.Action == "view_chapter" && body.Target != "" { chID = body.Target } t := model.UserTrack{ ID: trackID, UserID: userId, Action: body.Action, Target: &body.Target, } if body.Target != "" { t.ChapterID = &chID } if body.ExtraData != nil { if b, err := json.Marshal(body.ExtraData); err == nil { t.ExtraData = b } } if err := db.Create(&t).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "记录成功"}) } // UserUpdate POST /api/user/update 更新昵称、头像、手机、微信号等 func UserUpdate(c *gin.Context) { var body struct { UserID string `json:"userId" binding:"required"` Nickname *string `json:"nickname"` Avatar *string `json:"avatar"` Phone *string `json:"phone"` Wechat *string `json:"wechat"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少用户ID"}) return } updates := make(map[string]interface{}) if body.Nickname != nil { updates["nickname"] = *body.Nickname } if body.Avatar != nil { updates["avatar"] = avatarToPath(*body.Avatar) } if body.Phone != nil { updates["phone"] = *body.Phone } if body.Wechat != nil { updates["wechat_id"] = *body.Wechat } if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "没有需要更新的字段"}) return } updates["updated_at"] = time.Now() if err := database.DB().Model(&model.User{}).Where("id = ?", body.UserID).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新失败"}) return } c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"}) } // UserDashboardStats GET /api/miniprogram/user/dashboard-stats?userId= // 小程序「我的」页聚合统计:已读章节列表、最近阅读、总阅读时长、匹配历史数 func UserDashboardStats(c *gin.Context) { userID := c.Query("userId") if userID == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"}) return } db := database.DB() // 1. 拉取该用户所有阅读进度记录,按最近打开时间倒序 var progressList []model.ReadingProgress if err := db.Where("user_id = ?", userID).Order("last_open_at DESC").Find(&progressList).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取阅读统计失败"}) return } // 2. 按章节去重:已读数 = 不重复 section_id 数量;列表按「最近一次打开」倒序 type secAgg struct { lastOpen time.Time } secMap := make(map[string]*secAgg) totalReadSeconds := 0 for _, item := range progressList { totalReadSeconds += item.Duration sid := strings.TrimSpace(item.SectionID) if sid == "" { continue } var t time.Time if item.LastOpenAt != nil { t = *item.LastOpenAt } else if !item.UpdatedAt.IsZero() { t = item.UpdatedAt } else { t = item.CreatedAt } if agg, ok := secMap[sid]; ok { if t.After(agg.lastOpen) { agg.lastOpen = t } } else { secMap[sid] = &secAgg{lastOpen: t} } } // 2b. 已购买的章节(orders 表)也计入已读;用 pay_time 作为 lastOpen var purchasedRows []struct { ProductID string PayTime *time.Time } db.Model(&model.Order{}). Select("product_id, pay_time"). Where("user_id = ? AND product_type = 'section' AND status IN ? AND product_id IS NOT NULL AND product_id != ''", userID, []string{"paid", "completed", "success"}). Scan(&purchasedRows) for _, row := range purchasedRows { sid := strings.TrimSpace(row.ProductID) if sid == "" { continue } var pt time.Time if row.PayTime != nil { pt = *row.PayTime } if agg, ok := secMap[sid]; ok { if !pt.IsZero() && pt.After(agg.lastOpen) { agg.lastOpen = pt } } else { secMap[sid] = &secAgg{lastOpen: pt} } } readCount := len(secMap) sortedSectionIDs := make([]string, 0, len(secMap)) for sid := range secMap { sortedSectionIDs = append(sortedSectionIDs, sid) } sort.Slice(sortedSectionIDs, func(i, j int) bool { return secMap[sortedSectionIDs[i]].lastOpen.After(secMap[sortedSectionIDs[j]].lastOpen) }) readSectionIDs := sortedSectionIDs recentIDs := sortedSectionIDs if len(recentIDs) > 5 { recentIDs = recentIDs[:5] } // 不足 60 秒但有阅读记录时,至少显示 1 分钟 totalReadMinutes := totalReadSeconds / 60 if totalReadSeconds > 0 && totalReadMinutes == 0 { totalReadMinutes = 1 } // 异常数据保护:历史 bug 导致累加错误可能产生超大值, cap 到 99999 分钟(约 69 天) if totalReadMinutes > 99999 { totalReadMinutes = 99999 } // 3. 批量查 chapters 获取真实标题与 mid chapterMap := make(map[string]model.Chapter) if len(recentIDs) > 0 { var chapters []model.Chapter if err := db.Select("id", "mid", "section_title").Where("id IN ?", recentIDs).Find(&chapters).Error; err == nil { for _, ch := range chapters { chapterMap[ch.ID] = ch } } } // 按最近阅读顺序组装,标题 fallback 为 section_id recentChapters := make([]gin.H, 0, len(recentIDs)) for _, id := range recentIDs { ch, ok := chapterMap[id] title := id mid := 0 if ok { if ch.SectionTitle != "" { title = ch.SectionTitle } mid = ch.MID } recentChapters = append(recentChapters, gin.H{ "id": id, "mid": mid, "title": title, }) } // 4. 匹配历史数(该用户发起的匹配次数) var matchHistory int64 db.Model(&model.MatchRecord{}).Where("user_id = ?", userID).Count(&matchHistory) // 5. 订单数 + 代付数 var orderCount int64 db.Model(&model.Order{}).Where("user_id = ? AND status IN ?", userID, []string{"paid", "completed", "success"}).Count(&orderCount) var giftPayCount int64 db.Model(&model.Order{}).Where("user_id = ? AND product_type IN ? AND status IN ?", userID, []string{"gift_pay", "gift_pay_batch"}, []string{"paid", "completed", "success"}).Count(&giftPayCount) c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "readCount": readCount, "totalReadMinutes": totalReadMinutes, "recentChapters": recentChapters, "matchHistory": matchHistory, "readSectionIds": readSectionIDs, "orderCount": orderCount, "giftPayCount": giftPayCount, }, }) }