sync: Gitea 同步配置、miniprogram 页面逻辑、miniprogram 页面样式、脚本与配置、soul-admin 前端、soul-admin 页面、soul-api 接口逻辑、soul-api 路由等 | 原因: 多模块开发更新
This commit is contained in:
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -82,6 +81,7 @@ func AdminDashboardOverview(c *gin.Context) {
|
||||
}
|
||||
recentOut = append(recentOut, gin.H{
|
||||
"id": o.ID, "orderSn": o.OrderSN, "userId": o.UserID, "userNickname": nickname, "userAvatar": avatar,
|
||||
"userPhone": phone,
|
||||
"amount": o.Amount, "status": ptrStr(o.Status), "productType": o.ProductType, "productId": o.ProductID, "description": o.Description,
|
||||
"referrerId": o.ReferrerID, "referralCode": referrerCode, "createdAt": o.CreatedAt, "paymentMethod": "微信",
|
||||
})
|
||||
|
||||
@@ -16,9 +16,11 @@ import (
|
||||
var excludeParts = []string{"序言", "尾声", "附录"}
|
||||
|
||||
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
|
||||
// 小程序目录页以此接口为准,与后台内容管理一致;含「2026每日派对干货」等 part 须在 chapters 表中存在且 part_title 正确。
|
||||
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
|
||||
// COALESCE 处理 sort_order 为 NULL 的旧数据,避免错位
|
||||
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
||||
// 不过滤 status,后台配置的篇章均返回,由前端展示。
|
||||
func BookAllChapters(c *gin.Context) {
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
if c.Query("excludeFixed") == "1" {
|
||||
@@ -31,7 +33,7 @@ func BookAllChapters(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
||||
}
|
||||
|
||||
// BookChapterByID GET /api/book/chapter/:id 按业务 id 查询(兼容旧链接)
|
||||
|
||||
@@ -2,7 +2,9 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -11,13 +13,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// listSelectCols 列表/导出不加载 content,大幅加速
|
||||
// listSelectCols 列表/导出不加载 content,大幅加速;含 updated_at 用于热度算法
|
||||
var listSelectCols = []string{
|
||||
"id", "section_title", "price", "is_free", "is_new",
|
||||
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
|
||||
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order", "updated_at",
|
||||
}
|
||||
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰),含点击与付款统计
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰),含点击、付款与热度排名
|
||||
type sectionListItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -30,7 +32,9 @@ type sectionListItem struct {
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
FilePath *string `json:"filePath,omitempty"`
|
||||
ClickCount int `json:"clickCount,omitempty"` // 阅读/点击次数(reading_progress 条数)
|
||||
PayCount int `json:"payCount,omitempty"` // 付款笔数(orders 已支付)
|
||||
PayCount int `json:"payCount,omitempty"` // 付款笔数(orders 已支付)
|
||||
HotScore float64 `json:"hotScore,omitempty"` // 热度积分(文章排名算法算出)
|
||||
HotRank int `json:"hotRank,omitempty"` // 热度排名(1=最高)
|
||||
}
|
||||
|
||||
// DBBookAction GET/POST/PUT /api/db/book
|
||||
@@ -51,7 +55,7 @@ func DBBookAction(c *gin.Context) {
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
// 点击量:reading_progress 按 section_id 计数
|
||||
// 点击量:与 reading_progress 表直接捆绑,按 section_id 计数(小程序打开章节会立即上报一条)
|
||||
type readCnt struct{ SectionID string `gorm:"column:section_id"`; Cnt int64 `gorm:"column:cnt"` }
|
||||
var readCounts []readCnt
|
||||
if len(ids) > 0 {
|
||||
@@ -61,18 +65,93 @@ func DBBookAction(c *gin.Context) {
|
||||
for _, x := range readCounts {
|
||||
readMap[x.SectionID] = int(x.Cnt)
|
||||
}
|
||||
// 付款笔数:orders 中 product_type=section 且 status=paid 按 product_id 计数
|
||||
// 付款笔数:与 orders 表直接捆绑,兼容 paid/completed/success 等已支付状态
|
||||
type payCnt struct{ ProductID string `gorm:"column:product_id"`; Cnt int64 `gorm:"column:cnt"` }
|
||||
var payCounts []payCnt
|
||||
if len(ids) > 0 {
|
||||
db.Table("orders").Select("product_id, COUNT(*) as cnt").
|
||||
Where("product_type = ? AND status = ? AND product_id IN ?", "section", "paid", ids).
|
||||
Where("product_type = ? AND status IN ? AND product_id IN ?", "section", []string{"paid", "completed", "success"}, ids).
|
||||
Group("product_id").Scan(&payCounts)
|
||||
}
|
||||
payMap := make(map[string]int)
|
||||
for _, x := range payCounts {
|
||||
payMap[x.ProductID] = int(x.Cnt)
|
||||
}
|
||||
// 文章排名算法:权重可从 config article_ranking_weights 读取,默认 阅读50% 新度30% 付款20%
|
||||
readWeight, recencyWeight, payWeight := 0.5, 0.3, 0.2
|
||||
var cfgRow model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfgRow).Error; err == nil && len(cfgRow.ConfigValue) > 0 {
|
||||
var m map[string]interface{}
|
||||
if json.Unmarshal(cfgRow.ConfigValue, &m) == nil {
|
||||
if v, ok := m["readWeight"]; ok {
|
||||
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
|
||||
readWeight = f
|
||||
}
|
||||
}
|
||||
if v, ok := m["recencyWeight"]; ok {
|
||||
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
|
||||
recencyWeight = f
|
||||
}
|
||||
}
|
||||
if v, ok := m["payWeight"]; ok {
|
||||
if f, ok := v.(float64); ok && f >= 0 && f <= 1 {
|
||||
payWeight = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 热度 = readWeight*阅读排名分 + recencyWeight*新度排名分 + payWeight*付款排名分
|
||||
readScore := make(map[string]float64)
|
||||
idsByRead := make([]string, len(ids))
|
||||
copy(idsByRead, ids)
|
||||
sort.Slice(idsByRead, func(i, j int) bool { return readMap[idsByRead[i]] > readMap[idsByRead[j]] })
|
||||
for i := 0; i < 20 && i < len(idsByRead); i++ {
|
||||
readScore[idsByRead[i]] = float64(20 - i)
|
||||
}
|
||||
recencyScore := make(map[string]float64)
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].UpdatedAt.After(rows[j].UpdatedAt) })
|
||||
for i := 0; i < 30 && i < len(rows); i++ {
|
||||
recencyScore[rows[i].ID] = float64(30 - i)
|
||||
}
|
||||
payScore := make(map[string]float64)
|
||||
idsByPay := make([]string, len(ids))
|
||||
copy(idsByPay, ids)
|
||||
sort.Slice(idsByPay, func(i, j int) bool { return payMap[idsByPay[i]] > payMap[idsByPay[j]] })
|
||||
for i := 0; i < 20 && i < len(idsByPay); i++ {
|
||||
payScore[idsByPay[i]] = float64(20 - i)
|
||||
}
|
||||
type idTotal struct {
|
||||
id string
|
||||
total float64
|
||||
}
|
||||
totals := make([]idTotal, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
t := readWeight*readScore[r.ID] + recencyWeight*recencyScore[r.ID] + payWeight*payScore[r.ID]
|
||||
totals = append(totals, idTotal{r.ID, t})
|
||||
}
|
||||
sort.Slice(totals, func(i, j int) bool { return totals[i].total > totals[j].total })
|
||||
hotRankMap := make(map[string]int)
|
||||
for i, t := range totals {
|
||||
hotRankMap[t.id] = i + 1
|
||||
}
|
||||
hotScoreMap := make(map[string]float64)
|
||||
for _, t := range totals {
|
||||
hotScoreMap[t.id] = t.total
|
||||
}
|
||||
// 恢复 rows 的 sort_order 顺序(上面 recency 排序打乱了)
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
soi, soj := 0, 0
|
||||
if rows[i].SortOrder != nil {
|
||||
soi = *rows[i].SortOrder
|
||||
}
|
||||
if rows[j].SortOrder != nil {
|
||||
soj = *rows[j].SortOrder
|
||||
}
|
||||
if soi != soj {
|
||||
return soi < soj
|
||||
}
|
||||
return rows[i].ID < rows[j].ID
|
||||
})
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
@@ -91,6 +170,8 @@ func DBBookAction(c *gin.Context) {
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
ClickCount: readMap[r.ID],
|
||||
PayCount: payMap[r.ID],
|
||||
HotScore: hotScoreMap[r.ID],
|
||||
HotRank: hotRankMap[r.ID],
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
|
||||
@@ -135,7 +216,8 @@ func DBBookAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var orders []model.Order
|
||||
if err := db.Where("product_type = ? AND product_id = ?", "section", id).Order("created_at DESC").Limit(200).Find(&orders).Error; err != nil {
|
||||
if err := db.Where("product_type = ? AND product_id = ? AND status IN ?", "section", id, []string{"paid", "completed", "success"}).
|
||||
Order("pay_time DESC, created_at DESC").Limit(200).Find(&orders).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "orders": []model.Order{}})
|
||||
return
|
||||
}
|
||||
@@ -297,7 +379,15 @@ func DBBookAction(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Action == "move-sections" && len(body.SectionIds) > 0 && body.TargetPartID != "" && body.TargetChapterID != "" {
|
||||
if body.Action == "move-sections" {
|
||||
if len(body.SectionIds) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "批量移动缺少 sectionIds(请先勾选要移动的节)"})
|
||||
return
|
||||
}
|
||||
if body.TargetPartID == "" || body.TargetChapterID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "批量移动缺少目标篇或目标章(targetPartId、targetChapterId)"})
|
||||
return
|
||||
}
|
||||
up := map[string]interface{}{
|
||||
"part_id": body.TargetPartID,
|
||||
"chapter_id": body.TargetChapterID,
|
||||
|
||||
@@ -18,11 +18,11 @@ const defaultFreeMatchLimit = 3
|
||||
// MatchQuota 匹配次数配额(纯计算:订单 + match_records)
|
||||
type MatchQuota struct {
|
||||
PurchasedTotal int64 `json:"purchasedTotal"`
|
||||
PurchasedUsed int64 `json:"purchasedUsed"`
|
||||
PurchasedUsed int64 `json:"purchasedUsed"`
|
||||
MatchesUsedToday int64 `json:"matchesUsedToday"`
|
||||
FreeRemainToday int64 `json:"freeRemainToday"`
|
||||
PurchasedRemain int64 `json:"purchasedRemain"`
|
||||
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
|
||||
FreeRemainToday int64 `json:"freeRemainToday"`
|
||||
PurchasedRemain int64 `json:"purchasedRemain"`
|
||||
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
|
||||
}
|
||||
|
||||
func getFreeMatchLimit(db *gorm.DB) int {
|
||||
@@ -73,7 +73,7 @@ func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
|
||||
}
|
||||
remainToday := freeRemain + purchasedRemain
|
||||
return MatchQuota{
|
||||
PurchasedTotal: purchasedTotal,
|
||||
PurchasedTotal: purchasedTotal,
|
||||
PurchasedUsed: purchasedUsed,
|
||||
MatchesUsedToday: matchesToday,
|
||||
FreeRemainToday: freeRemain,
|
||||
@@ -83,7 +83,7 @@ func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
|
||||
}
|
||||
|
||||
var defaultMatchTypes = []gin.H{
|
||||
gin.H{"id": "partner", "label": "创业合伙", "matchLabel": "创业伙伴", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
|
||||
gin.H{"id": "partner", "label": "超级个体", "matchLabel": "超级个体", "icon": "⭐", "matchFromDB": true, "showJoinAfterMatch": false, "price": 1, "enabled": true},
|
||||
gin.H{"id": "investor", "label": "资源对接", "matchLabel": "资源对接", "icon": "👥", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||
gin.H{"id": "mentor", "label": "导师顾问", "matchLabel": "导师顾问", "icon": "❤️", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||
gin.H{"id": "team", "label": "团队招募", "matchLabel": "加入项目", "icon": "🎮", "matchFromDB": false, "showJoinAfterMatch": true, "price": 1, "enabled": true},
|
||||
@@ -100,7 +100,7 @@ func MatchConfigGet(c *gin.Context) {
|
||||
"matchTypes": defaultMatchTypes,
|
||||
"freeMatchLimit": 3,
|
||||
"matchPrice": 1,
|
||||
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
|
||||
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
|
||||
},
|
||||
"source": "default",
|
||||
})
|
||||
@@ -181,10 +181,14 @@ func MatchUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 只匹配已绑定微信或手机号的用户
|
||||
// 找伙伴(partner):仅从超级个体池匹配(is_vip=1 且 vip_expire_date>NOW);其他类型:已绑定微信或手机号的用户
|
||||
var users []model.User
|
||||
q := db.Where("id != ?", body.UserID).
|
||||
Where("((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))")
|
||||
if body.MatchType == "partner" {
|
||||
// 超级个体:VIP 会员池
|
||||
q = q.Where("is_vip = 1 AND vip_expire_date > NOW()")
|
||||
}
|
||||
if err := q.Order("created_at DESC").Limit(20).Find(&users).Error; err != nil || len(users) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "暂无匹配用户", "data": nil, "code": "NO_USERS"})
|
||||
return
|
||||
@@ -212,7 +216,7 @@ func MatchUsers(c *gin.Context) {
|
||||
phone = *r.Phone
|
||||
}
|
||||
intro := "来自Soul创业派对的伙伴"
|
||||
matchLabels := map[string]string{"partner": "找伙伴", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
|
||||
matchLabels := map[string]string{"partner": "超级个体", "investor": "资源对接", "mentor": "导师顾问", "team": "团队招募"}
|
||||
tag := matchLabels[body.MatchType]
|
||||
if tag == "" {
|
||||
tag = "找伙伴"
|
||||
|
||||
@@ -11,8 +11,35 @@ import (
|
||||
)
|
||||
|
||||
// DBMatchRecordsList GET /api/db/match-records 管理端-匹配记录列表(分页、按类型筛选)
|
||||
// 当 ?stats=true 时返回汇总统计(总匹配数、今日匹配、按类型分布、独立用户数)
|
||||
func DBMatchRecordsList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
if c.Query("stats") == "true" {
|
||||
var totalMatches int64
|
||||
db.Model(&model.MatchRecord{}).Count(&totalMatches)
|
||||
var todayMatches int64
|
||||
db.Model(&model.MatchRecord{}).Where("created_at >= CURDATE()").Count(&todayMatches)
|
||||
type TypeCount struct {
|
||||
MatchType string `json:"matchType"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var byType []TypeCount
|
||||
db.Model(&model.MatchRecord{}).Select("match_type as match_type, count(*) as count").Group("match_type").Scan(&byType)
|
||||
var uniqueUsers int64
|
||||
db.Model(&model.MatchRecord{}).Distinct("user_id").Count(&uniqueUsers)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"totalMatches": totalMatches,
|
||||
"todayMatches": todayMatches,
|
||||
"byType": byType,
|
||||
"uniqueUsers": uniqueUsers,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
matchType := c.Query("matchType")
|
||||
|
||||
@@ -476,6 +476,87 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
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")
|
||||
|
||||
@@ -264,6 +264,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.GET("/user/profile", handler.UserProfileGet)
|
||||
miniprogram.POST("/user/profile", handler.UserProfilePost)
|
||||
miniprogram.GET("/user/purchase-status", handler.UserPurchaseStatus)
|
||||
miniprogram.GET("/user/dashboard-stats", handler.UserDashboardStatsGet)
|
||||
miniprogram.GET("/user/reading-progress", handler.UserReadingProgressGet)
|
||||
miniprogram.POST("/user/reading-progress", handler.UserReadingProgressPost)
|
||||
miniprogram.POST("/user/update", handler.UserUpdate)
|
||||
|
||||
Reference in New Issue
Block a user