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:
卡若
2026-03-23 18:38:23 +08:00
parent cb6e2bff56
commit fa3da12b16
82 changed files with 5621 additions and 2723 deletions

View File

@@ -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 解析 completedAtRFC3339 字符串、毫秒/秒时间戳float64
func parseCompletedAtPtr(v interface{}) *time.Time {
if v == nil {
return nil
}
switch x := v.(type) {
case string:
s := strings.TrimSpace(x)
if s == "" {
return nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return &t
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return &t
}
return nil
case float64:
if x <= 0 {
return nil
}
var t time.Time
if x >= 1e12 {
t = time.UnixMilli(int64(x))
} else {
t = time.Unix(int64(x), 0)
}
return &t
case int64:
if x <= 0 {
return nil
}
t := time.UnixMilli(x)
return &t
default:
return nil
}
}
// parseDuration 从 JSON 解析 duration兼容数字与字符串防止客户端传字符串导致累加异常
func parseDuration(v interface{}) int {
if v == nil {
@@ -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 分钟