sync: Gitea 同步配置、miniprogram 页面逻辑、miniprogram 页面样式、脚本与配置、soul-admin 前端、soul-admin 页面、soul-api 接口逻辑、soul-api 路由等 | 原因: 多模块开发更新

This commit is contained in:
卡若
2026-03-08 08:00:39 +08:00
parent b7c35a89b0
commit 66cd90e511
43 changed files with 2559 additions and 809 deletions

View File

@@ -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": "微信",
})

View File

@@ -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 查询(兼容旧链接)

View File

@@ -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,

View File

@@ -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 = "找伙伴"

View File

@@ -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")

View File

@@ -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")

View File

@@ -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)