chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/ - 移除并忽略 .env 与小程序私有配置 - 同步小程序/管理端/API与脚本改动 Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -166,18 +167,48 @@ func UserCheckPurchased(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
|
||||
if hasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
|
||||
// 超级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]}})
|
||||
@@ -377,8 +408,10 @@ func UserPurchaseStatus(c *gin.Context) {
|
||||
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": user.HasFullBook != nil && *user.HasFullBook,
|
||||
"hasFullBook": hasFullBook,
|
||||
"purchasedSections": purchasedSections,
|
||||
"sectionMidMap": sectionMidMap,
|
||||
"purchasedCount": len(purchasedSections),
|
||||
@@ -419,20 +452,44 @@ func UserReadingProgressGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// 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 int `json:"duration"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
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 *string `json:"completedAt"`
|
||||
}
|
||||
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
|
||||
@@ -442,7 +499,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if body.Progress > newProgress {
|
||||
newProgress = body.Progress
|
||||
}
|
||||
newDuration := existing.Duration + body.Duration
|
||||
newDuration := existing.Duration + duration
|
||||
newStatus := body.Status
|
||||
if newStatus == "" {
|
||||
newStatus = "reading"
|
||||
@@ -469,94 +526,13 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
completedAt = &t
|
||||
}
|
||||
db.Create(&model.ReadingProgress{
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: body.Duration,
|
||||
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": "进度已保存"})
|
||||
}
|
||||
|
||||
// UserDashboardStatsGet GET /api/user/dashboard-stats?userId=
|
||||
// 返回我的页所需的真实统计:已读章节、阅读分钟、最近阅读、匹配次数
|
||||
func UserDashboardStatsGet(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId 参数"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
readCount := len(progressList)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalReadMinutes := totalReadSeconds / 60
|
||||
if totalReadSeconds > 0 && totalReadMinutes == 0 {
|
||||
totalReadMinutes = 1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
var matchHistory int64
|
||||
db.Model(&model.MatchRecord{}).Where("user_id = ?", userId).Count(&matchHistory)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"readCount": readCount,
|
||||
"totalReadMinutes": totalReadMinutes,
|
||||
"recentChapters": recentChapters,
|
||||
"matchHistory": matchHistory,
|
||||
"readSectionIds": readSectionIDs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UserTrackGet GET /api/user/track?userId=&limit= 从 user_tracks 表查(GORM)
|
||||
func UserTrackGet(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
@@ -644,6 +620,11 @@ func UserTrackPost(c *gin.Context) {
|
||||
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
|
||||
@@ -651,6 +632,45 @@ func UserTrackPost(c *gin.Context) {
|
||||
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 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 {
|
||||
@@ -688,3 +708,95 @@ func UserUpdate(c *gin.Context) {
|
||||
}
|
||||
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. 遍历:统计 readSectionIds / totalReadSeconds,同时去重取最近 5 个不重复章节
|
||||
readCount := len(progressList)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不足 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)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"readCount": readCount,
|
||||
"totalReadMinutes": totalReadMinutes,
|
||||
"recentChapters": recentChapters,
|
||||
"matchHistory": matchHistory,
|
||||
"readSectionIds": readSectionIDs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user