Files
soul-yongping/soul-api/internal/handler/user.go
2026-03-24 01:22:50 +08:00

1286 lines
40 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"
"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 解析 completedAtRFC3339 字符串、毫秒/秒时间戳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 "fill_avatar":
return "设置头像"
case "visit_page":
return "访问页面"
case "first_pay":
return "首次付款"
case "vip_activate":
return "开通会员"
case "click_super":
return "点击超级个体"
case "lead_submit":
return "提交留资"
case "withdraw":
return "申请提现"
case "referral_bind":
return "绑定推荐人"
case "card_click":
return "点击名片"
case "btn_click":
return "按钮点击"
case "tab_click":
return "切换标签"
case "nav_click":
return "导航点击"
case "page_view":
return "页面浏览"
case "search":
return "搜索"
default:
if action == "" {
return "行为"
}
return action
}
}
// userTrackModuleLabelCN 埋点 module 英文字段 → 中文位置(与用户旅程、群播报一致)
func userTrackModuleLabelCN(module string) string {
m := strings.TrimSpace(strings.ToLower(module))
switch m {
case "":
return ""
case "home", "index":
return "首页"
case "chapters":
return "目录"
case "match":
return "找伙伴"
case "my":
return "我的"
case "read", "reading":
return "阅读"
case "vip":
return "会员中心"
case "referral":
return "推广中心"
case "member_detail", "member-detail", "memberdetail":
return "超级个体详情"
case "profile", "profile_show", "profile-show":
return "个人资料"
case "search":
return "搜索"
case "wallet":
return "钱包"
case "settings":
return "设置"
default:
return module
}
}
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 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
}
formatted = append(formatted, gin.H{
"id": t.ID,
"action": t.Action,
"actionLabel": userTrackActionLabelCN(t.Action),
"target": target,
"chapterTitle": chapterTitle,
"module": module,
"moduleLabel": userTrackModuleLabelCN(module),
"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,
"moduleLabel": userTrackModuleLabelCN(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
modCN := userTrackModuleLabelCN(module)
if target != "" {
line = fmt.Sprintf("%s: %s", label, sanitizeDisplayOneLine(target))
} else if modCN != "" {
line = fmt.Sprintf("%s · %s", label, modCN)
} 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,
},
})
}