1286 lines
40 KiB
Go
1286 lines
40 KiB
Go
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 "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,
|
||
},
|
||
})
|
||
}
|