feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本
- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整 - soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新 - soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL - .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle Made-with: Cursor
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -550,6 +551,46 @@ func UserReadingProgressGet(c *gin.Context) {
|
||||
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 {
|
||||
@@ -578,7 +619,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
Progress int `json:"progress"`
|
||||
Duration interface{} `json:"duration"` // 兼容 int/float64/string,防止字符串导致累加异常
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
CompletedAt interface{} `json:"completedAt"` // 兼容 ISO 字符串或历史客户端误传的时间戳数字
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"})
|
||||
@@ -602,11 +643,8 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if newStatus == "" {
|
||||
newStatus = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
} else if existing.CompletedAt != nil {
|
||||
completedAt := parseCompletedAtPtr(body.CompletedAt)
|
||||
if completedAt == nil && existing.CompletedAt != nil {
|
||||
completedAt = existing.CompletedAt
|
||||
}
|
||||
db.Model(&existing).Updates(map[string]interface{}{
|
||||
@@ -618,11 +656,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if status == "" {
|
||||
status = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
}
|
||||
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,
|
||||
@@ -786,6 +820,106 @@ func UserTrackGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": formatted, "stats": stats, "total": len(formatted)})
|
||||
}
|
||||
|
||||
// DBUserTracksList GET /api/db/users/tracks?userId=xxx&limit=20 管理端查看某用户行为轨迹
|
||||
func DBUserTracksList(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要 userId"})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
db := database.DB()
|
||||
var tracks []model.UserTrack
|
||||
db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks)
|
||||
titleMap := resolveChapterTitlesForTracks(db, tracks)
|
||||
out := make([]gin.H, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
target := ""
|
||||
if t.Target != nil {
|
||||
target = *t.Target
|
||||
}
|
||||
chTitle := ""
|
||||
if t.ChapterID != nil {
|
||||
chTitle = titleMap[strings.TrimSpace(*t.ChapterID)]
|
||||
}
|
||||
if chTitle == "" && target != "" {
|
||||
chTitle = titleMap[strings.TrimSpace(target)]
|
||||
}
|
||||
var extra map[string]interface{}
|
||||
if len(t.ExtraData) > 0 {
|
||||
_ = json.Unmarshal(t.ExtraData, &extra)
|
||||
}
|
||||
module := ""
|
||||
if extra != nil {
|
||||
if m, ok := extra["module"].(string); ok {
|
||||
module = m
|
||||
}
|
||||
}
|
||||
var createdAt time.Time
|
||||
if t.CreatedAt != nil {
|
||||
createdAt = *t.CreatedAt
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": t.ID, "action": t.Action, "actionLabel": userTrackActionLabelCN(t.Action),
|
||||
"target": target, "chapterTitle": chTitle, "module": module,
|
||||
"createdAt": t.CreatedAt, "timeAgo": humanTimeAgoCN(createdAt),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": out, "total": len(out)})
|
||||
}
|
||||
|
||||
// GetUserRecentTracks 内部复用:获取用户最近 N 条有效行为的可读文字(用于 webhook 等)
|
||||
func GetUserRecentTracks(db *gorm.DB, userId string, limit int) []string {
|
||||
if userId == "" || limit < 1 {
|
||||
return nil
|
||||
}
|
||||
var tracks []model.UserTrack
|
||||
db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks)
|
||||
titleMap := resolveChapterTitlesForTracks(db, tracks)
|
||||
lines := make([]string, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
label := userTrackActionLabelCN(t.Action)
|
||||
target := ""
|
||||
if t.ChapterID != nil {
|
||||
if v := titleMap[strings.TrimSpace(*t.ChapterID)]; v != "" {
|
||||
target = v
|
||||
}
|
||||
}
|
||||
if target == "" && t.Target != nil {
|
||||
target = *t.Target
|
||||
if v := titleMap[strings.TrimSpace(target)]; v != "" {
|
||||
target = v
|
||||
}
|
||||
}
|
||||
var extra map[string]interface{}
|
||||
if len(t.ExtraData) > 0 {
|
||||
_ = json.Unmarshal(t.ExtraData, &extra)
|
||||
}
|
||||
module := ""
|
||||
if extra != nil {
|
||||
if m, ok := extra["module"].(string); ok {
|
||||
module = m
|
||||
}
|
||||
}
|
||||
var line string
|
||||
if target != "" {
|
||||
line = fmt.Sprintf("%s: %s", label, sanitizeDisplayOneLine(target))
|
||||
} else if module != "" {
|
||||
line = fmt.Sprintf("%s (%s)", label, module)
|
||||
} else {
|
||||
line = label
|
||||
}
|
||||
if t.CreatedAt != nil {
|
||||
line += " · " + humanTimeAgoCN(*t.CreatedAt)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// UserTrackPost POST /api/user/track 记录行为(GORM)
|
||||
func UserTrackPost(c *gin.Context) {
|
||||
var body struct {
|
||||
@@ -940,22 +1074,75 @@ func UserDashboardStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 遍历:统计 readSectionIds / totalReadSeconds,同时去重取最近 5 个不重复章节
|
||||
readCount := len(progressList)
|
||||
// 2. 按章节去重:已读数 = 不重复 section_id 数量;列表按「最近一次打开」倒序
|
||||
type secAgg struct {
|
||||
lastOpen time.Time
|
||||
}
|
||||
secMap := make(map[string]*secAgg)
|
||||
totalReadSeconds := 0
|
||||
recentIDs := make([]string, 0, 5)
|
||||
seenRecent := make(map[string]bool)
|
||||
readSectionIDs := make([]string, 0, len(progressList))
|
||||
for _, item := range progressList {
|
||||
totalReadSeconds += item.Duration
|
||||
if item.SectionID != "" {
|
||||
readSectionIDs = append(readSectionIDs, item.SectionID)
|
||||
// 去重:同一章节只保留最近一次
|
||||
if !seenRecent[item.SectionID] && len(recentIDs) < 5 {
|
||||
seenRecent[item.SectionID] = true
|
||||
recentIDs = append(recentIDs, item.SectionID)
|
||||
}
|
||||
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 分钟
|
||||
|
||||
Reference in New Issue
Block a user