2026-03-06 17:52:52 +08:00
package handler
import (
2026-03-15 15:57:09 +08:00
"encoding/json"
2026-03-06 17:52:52 +08:00
"fmt"
"net/http"
2026-03-23 18:38:23 +08:00
"sort"
2026-03-06 17:52:52 +08:00
"strconv"
2026-03-19 18:26:45 +08:00
"strings"
2026-03-06 17:52:52 +08:00
"time"
2026-03-19 18:26:45 +08:00
"soul-api/internal/config"
2026-03-06 17:52:52 +08:00
"soul-api/internal/database"
"soul-api/internal/model"
2026-03-19 18:26:45 +08:00
"soul-api/internal/oss"
2026-03-06 17:52:52 +08:00
"github.com/gin-gonic/gin"
2026-03-22 08:34:28 +08:00
"gorm.io/gorm"
2026-03-06 17:52:52 +08:00
)
2026-03-22 08:34:28 +08:00
// 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 ) , " " )
}
2026-03-19 18:26:45 +08:00
// 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
}
2026-03-06 17:52:52 +08:00
// 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 {
2026-03-22 08:34:28 +08:00
c . JSON ( http . StatusOK , gin . H { "success" : false , "error" : "查询地址失败: " + err . Error ( ) } )
2026-03-06 17:52:52 +08:00
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
}
2026-03-07 18:57:22 +08:00
// 超级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
}
2026-03-06 17:52:52 +08:00
if type_ == "fullbook" {
2026-03-10 11:04:34 +08:00
// 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
}
2026-03-06 17:52:52 +08:00
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 != "" {
2026-03-10 11:04:34 +08:00
// 章节:需要区分普通版/增值版
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
}
}
2026-03-06 17:52:52 +08:00
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
2026-03-22 08:34:28 +08:00
str := func ( p * string ) interface { } {
if p != nil {
return * p
}
return ""
}
2026-03-19 18:26:45 +08:00
avatarVal := resolveAvatarURL ( str ( user . Avatar ) . ( string ) )
2026-03-06 17:52:52 +08:00
resp := gin . H {
2026-03-19 18:26:45 +08:00
"id" : user . ID , "openId" : user . OpenID , "nickname" : str ( user . Nickname ) , "avatar" : avatarVal ,
2026-03-06 17:52:52 +08:00
"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 {
2026-03-22 08:34:28 +08:00
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" `
2026-03-06 17:52:52 +08:00
StoryAchievement * string ` json:"storyAchievement" `
2026-03-22 08:34:28 +08:00
StoryTurning * string ` json:"storyTurning" `
HelpOffer * string ` json:"helpOffer" `
HelpNeed * string ` json:"helpNeed" `
ProjectIntro * string ` json:"projectIntro" `
2026-03-06 17:52:52 +08:00
}
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 {
2026-03-19 18:26:45 +08:00
updates [ "avatar" ] = avatarToPath ( * body . Avatar )
2026-03-06 17:52:52 +08:00
}
if body . Phone != nil {
updates [ "phone" ] = * body . Phone
}
if body . WechatID != nil {
updates [ "wechat_id" ] = * body . WechatID
}
2026-03-22 08:34:28 +08:00
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
}
2026-03-06 17:52:52 +08:00
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 {
2026-03-22 08:34:28 +08:00
str := func ( p * string ) interface { } {
if p != nil {
return * p
}
return ""
}
2026-03-19 18:26:45 +08:00
avatarVal := resolveAvatarURL ( str ( user . Avatar ) . ( string ) )
2026-03-06 17:52:52 +08:00
resp := gin . H {
2026-03-19 18:26:45 +08:00
"id" : user . ID , "openId" : user . OpenID , "nickname" : str ( user . Nickname ) , "avatar" : avatarVal ,
2026-03-06 17:52:52 +08:00
"phone" : str ( user . Phone ) , "wechatId" : str ( user . WechatID ) , "referralCode" : user . ReferralCode ,
"createdAt" : user . CreatedAt ,
2026-03-22 08:34:28 +08:00
"mbti" : str ( user . Mbti ) , "region" : str ( user . Region ) , "industry" : str ( user . Industry ) ,
2026-03-06 17:52:52 +08:00
"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 {
2026-03-19 18:26:45 +08:00
avatarVal := ""
if body . Avatar != nil {
avatarVal = resolveAvatarURL ( avatarToPath ( * body . Avatar ) )
}
2026-03-06 17:52:52 +08:00
c . JSON ( http . StatusOK , gin . H { "success" : true , "message" : "资料更新成功" , "data" : gin . H {
2026-03-19 18:26:45 +08:00
"id" : user . ID , "nickname" : body . Nickname , "avatar" : avatarVal , "phone" : body . Phone , "wechatId" : body . WechatID , "referralCode" : user . ReferralCode ,
2026-03-06 17:52:52 +08:00
} } )
}
}
// 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
}
2026-03-10 11:04:34 +08:00
// 9.9 买断:仅表示“普通版买断”,不等同 VIP( 增值版仍需 VIP 或单章购买)
2026-03-07 18:57:22 +08:00
hasFullBook := user . HasFullBook != nil && * user . HasFullBook
2026-03-06 17:52:52 +08:00
c . JSON ( http . StatusOK , gin . H { "success" : true , "data" : gin . H {
2026-03-07 18:57:22 +08:00
"hasFullBook" : hasFullBook ,
2026-03-06 17:52:52 +08:00
"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 } )
}
2026-03-23 18:38:23 +08:00
// 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
}
}
2026-03-14 17:13:06 +08:00
// 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
}
}
2026-03-06 17:52:52 +08:00
// UserReadingProgressPost POST /api/user/reading-progress
func UserReadingProgressPost ( c * gin . Context ) {
var body struct {
2026-03-14 17:13:06 +08:00
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" `
2026-03-23 18:38:23 +08:00
CompletedAt interface { } ` json:"completedAt" ` // 兼容 ISO 字符串或历史客户端误传的时间戳数字
2026-03-06 17:52:52 +08:00
}
if err := c . ShouldBindJSON ( & body ) ; err != nil {
c . JSON ( http . StatusBadRequest , gin . H { "success" : false , "error" : "缺少必要参数" } )
return
}
2026-03-14 17:13:06 +08:00
duration := parseDuration ( body . Duration )
if duration < 0 {
duration = 0
}
2026-03-06 17:52:52 +08:00
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
}
2026-03-14 17:13:06 +08:00
newDuration := existing . Duration + duration
2026-03-06 17:52:52 +08:00
newStatus := body . Status
if newStatus == "" {
newStatus = "reading"
}
2026-03-23 18:38:23 +08:00
completedAt := parseCompletedAtPtr ( body . CompletedAt )
if completedAt == nil && existing . CompletedAt != nil {
2026-03-06 17:52:52 +08:00
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"
}
2026-03-23 18:38:23 +08:00
completedAt := parseCompletedAtPtr ( body . CompletedAt )
2026-03-06 17:52:52 +08:00
db . Create ( & model . ReadingProgress {
2026-03-14 17:13:06 +08:00
UserID : body . UserID , SectionID : body . SectionID , Progress : body . Progress , Duration : duration ,
2026-03-06 17:52:52 +08:00
Status : status , CompletedAt : completedAt , FirstOpenAt : & now , LastOpenAt : & now ,
} )
}
c . JSON ( http . StatusOK , gin . H { "success" : true , "message" : "进度已保存" } )
}
2026-03-22 08:34:28 +08:00
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 "完善资料"
2026-03-24 01:22:50 +08:00
case "fill_avatar" :
return "设置头像"
2026-03-22 08:34:28 +08:00
case "visit_page" :
return "访问页面"
2026-03-24 01:22:50 +08:00
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 "搜索"
2026-03-22 08:34:28 +08:00
default :
if action == "" {
return "行为"
}
return action
}
}
2026-03-24 01:22:50 +08:00
// 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
}
}
2026-03-22 08:34:28 +08:00
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
}
2026-03-06 17:52:52 +08:00
// 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
}
2026-03-22 08:34:28 +08:00
titleMap := resolveChapterTitlesForTracks ( db , tracks )
2026-03-06 17:52:52 +08:00
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
}
2026-03-22 08:34:28 +08:00
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
}
}
2026-03-24 01:22:50 +08:00
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
}
}
2026-03-22 08:34:28 +08:00
var createdAt time . Time
if t . CreatedAt != nil {
createdAt = * t . CreatedAt
}
2026-03-06 17:52:52 +08:00
formatted = append ( formatted , gin . H {
2026-03-22 08:34:28 +08:00
"id" : t . ID ,
"action" : t . Action ,
"actionLabel" : userTrackActionLabelCN ( t . Action ) ,
"target" : target ,
"chapterTitle" : chapterTitle ,
2026-03-24 01:22:50 +08:00
"module" : module ,
"moduleLabel" : userTrackModuleLabelCN ( module ) ,
2026-03-22 08:34:28 +08:00
"createdAt" : t . CreatedAt ,
"timeAgo" : humanTimeAgoCN ( createdAt ) ,
2026-03-06 17:52:52 +08:00
} )
}
c . JSON ( http . StatusOK , gin . H { "success" : true , "tracks" : formatted , "stats" : stats , "total" : len ( formatted ) } )
}
2026-03-23 18:38:23 +08:00
// 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 ,
2026-03-24 01:22:50 +08:00
"moduleLabel" : userTrackModuleLabelCN ( module ) ,
2026-03-23 18:38:23 +08:00
"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
2026-03-24 01:22:50 +08:00
modCN := userTrackModuleLabelCN ( module )
2026-03-23 18:38:23 +08:00
if target != "" {
line = fmt . Sprintf ( "%s: %s" , label , sanitizeDisplayOneLine ( target ) )
2026-03-24 01:22:50 +08:00
} else if modCN != "" {
line = fmt . Sprintf ( "%s · %s" , label , modCN )
2026-03-23 18:38:23 +08:00
} else {
line = label
}
if t . CreatedAt != nil {
line += " · " + humanTimeAgoCN ( * t . CreatedAt )
}
lines = append ( lines , line )
}
return lines
}
2026-03-06 17:52:52 +08:00
// 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
}
2026-03-15 15:57:09 +08:00
if body . ExtraData != nil {
if raw , err := json . Marshal ( body . ExtraData ) ; err == nil {
t . ExtraData = raw
}
}
2026-03-06 17:52:52 +08:00
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" : "行为记录成功" } )
}
2026-03-17 11:44:36 +08:00
// 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
}
2026-03-17 18:22:06 +08:00
if body . ExtraData != nil {
if b , err := json . Marshal ( body . ExtraData ) ; err == nil {
t . ExtraData = b
}
}
2026-03-17 11:44:36 +08:00
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" : "记录成功" } )
}
2026-03-06 17:52:52 +08:00
// 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 {
2026-03-19 18:26:45 +08:00
updates [ "avatar" ] = avatarToPath ( * body . Avatar )
2026-03-06 17:52:52 +08:00
}
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" : "更新成功" } )
}
2026-03-10 18:06:10 +08:00
// 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
}
2026-03-23 18:38:23 +08:00
// 2. 按章节去重:已读数 = 不重复 section_id 数量;列表按「最近一次打开」倒序
type secAgg struct {
lastOpen time . Time
}
secMap := make ( map [ string ] * secAgg )
2026-03-10 18:06:10 +08:00
totalReadSeconds := 0
for _ , item := range progressList {
totalReadSeconds += item . Duration
2026-03-23 18:38:23 +08:00
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
2026-03-10 18:06:10 +08:00
}
2026-03-23 18:38:23 +08:00
} else {
secMap [ sid ] = & secAgg { lastOpen : t }
2026-03-10 18:06:10 +08:00
}
}
2026-03-23 18:38:23 +08:00
// 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 ]
}
2026-03-10 18:06:10 +08:00
// 不足 60 秒但有阅读记录时,至少显示 1 分钟
totalReadMinutes := totalReadSeconds / 60
if totalReadSeconds > 0 && totalReadMinutes == 0 {
totalReadMinutes = 1
}
2026-03-14 17:13:06 +08:00
// 异常数据保护:历史 bug 导致累加错误可能产生超大值, cap 到 99999 分钟(约 69 天)
if totalReadMinutes > 99999 {
totalReadMinutes = 99999
}
2026-03-10 18:06:10 +08:00
// 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 )
2026-03-22 08:34:28 +08:00
// 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 )
2026-03-10 18:06:10 +08:00
c . JSON ( http . StatusOK , gin . H {
"success" : true ,
"data" : gin . H {
"readCount" : readCount ,
"totalReadMinutes" : totalReadMinutes ,
"recentChapters" : recentChapters ,
"matchHistory" : matchHistory ,
"readSectionIds" : readSectionIDs ,
2026-03-22 08:34:28 +08:00
"orderCount" : orderCount ,
"giftPayCount" : giftPayCount ,
2026-03-10 18:06:10 +08:00
} ,
} )
}