更新管理端迁移Mycontent-temp的菜单与布局规范,确保主导航收敛并优化隐藏页面入口。新增相关会议记录与文档,反映团队讨论的最新决策与实施建议。
This commit is contained in:
194
soul-api/internal/handler/admin_dashboard.go
Normal file
194
soul-api/internal/handler/admin_dashboard.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var paidStatuses = []string{"paid", "completed", "success"}
|
||||
|
||||
// AdminDashboardStats GET /api/admin/dashboard/stats
|
||||
// 轻量聚合:总用户、付费订单数、付费用户数、总营收、转化率(无订单/用户明细)
|
||||
func AdminDashboardStats(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var (
|
||||
totalUsers int64
|
||||
paidOrderCount int64
|
||||
totalRevenue float64
|
||||
paidUserCount int64
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(4)
|
||||
go func() { defer wg.Done(); db.Model(&model.User{}).Count(&totalUsers) }()
|
||||
go func() { defer wg.Done(); db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Table("orders").Where("status IN ?", paidStatuses).
|
||||
Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount)
|
||||
}()
|
||||
wg.Wait()
|
||||
conversionRate := 0.0
|
||||
if totalUsers > 0 && paidUserCount > 0 {
|
||||
conversionRate = float64(paidUserCount) / float64(totalUsers) * 100
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"totalUsers": totalUsers,
|
||||
"paidOrderCount": paidOrderCount,
|
||||
"paidUserCount": paidUserCount,
|
||||
"totalRevenue": totalRevenue,
|
||||
"conversionRate": conversionRate,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders
|
||||
func AdminDashboardRecentOrders(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var recentOrders []model.Order
|
||||
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(5).Find(&recentOrders)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "recentOrders": buildRecentOrdersOut(db, recentOrders)})
|
||||
}
|
||||
|
||||
// AdminDashboardNewUsers GET /api/admin/dashboard/new-users
|
||||
func AdminDashboardNewUsers(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var newUsers []model.User
|
||||
db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "newUsers": buildNewUsersOut(newUsers)})
|
||||
}
|
||||
|
||||
// AdminDashboardOverview GET /api/admin/dashboard/overview
|
||||
// 数据概览:总用户、付费订单数、付费用户数、总营收、转化率、最近订单、新用户
|
||||
// 优化:6 组查询并行执行,减少总耗时
|
||||
func AdminDashboardOverview(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
var (
|
||||
totalUsers int64
|
||||
paidOrderCount int64
|
||||
totalRevenue float64
|
||||
paidUserCount int64
|
||||
recentOrders []model.Order
|
||||
newUsers []model.User
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(6)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.User{}).Count(&totalUsers)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).Count(&paidOrderCount)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Order{}).Where("status IN ?", paidStatuses).
|
||||
Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Table("orders").Where("status IN ?", paidStatuses).
|
||||
Select("COUNT(DISTINCT user_id)").Scan(&paidUserCount)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Where("status IN ?", paidStatuses).
|
||||
Order("created_at DESC").Limit(5).Find(&recentOrders)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.User{}).Order("created_at DESC").Limit(10).Find(&newUsers)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
conversionRate := 0.0
|
||||
if totalUsers > 0 && paidUserCount > 0 {
|
||||
conversionRate = float64(paidUserCount) / float64(totalUsers) * 100
|
||||
}
|
||||
|
||||
recentOut := buildRecentOrdersOut(db, recentOrders)
|
||||
newOut := buildNewUsersOut(newUsers)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"totalUsers": totalUsers,
|
||||
"paidOrderCount": paidOrderCount,
|
||||
"paidUserCount": paidUserCount,
|
||||
"totalRevenue": totalRevenue,
|
||||
"conversionRate": conversionRate,
|
||||
"recentOrders": recentOut,
|
||||
"newUsers": newOut,
|
||||
})
|
||||
}
|
||||
|
||||
func dashStr(s *string) string {
|
||||
if s == nil || *s == "" {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
|
||||
if len(recentOrders) == 0 {
|
||||
return nil
|
||||
}
|
||||
userIDs := make(map[string]bool)
|
||||
for _, o := range recentOrders {
|
||||
if o.UserID != "" {
|
||||
userIDs[o.UserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
db.Where("id IN ?", ids).Find(&users)
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range users {
|
||||
userMap[users[i].ID] = &users[i]
|
||||
}
|
||||
out := make([]gin.H, 0, len(recentOrders))
|
||||
for _, o := range recentOrders {
|
||||
b, _ := json.Marshal(o)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if u := userMap[o.UserID]; u != nil {
|
||||
m["userNickname"] = dashStr(u.Nickname)
|
||||
m["userAvatar"] = dashStr(u.Avatar)
|
||||
} else {
|
||||
m["userNickname"] = ""
|
||||
m["userAvatar"] = ""
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildNewUsersOut(newUsers []model.User) []gin.H {
|
||||
out := make([]gin.H, 0, len(newUsers))
|
||||
for _, u := range newUsers {
|
||||
out = append(out, gin.H{
|
||||
"id": u.ID,
|
||||
"nickname": dashStr(u.Nickname),
|
||||
"phone": dashStr(u.Phone),
|
||||
"referralCode": dashStr(u.ReferralCode),
|
||||
"createdAt": u.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -109,8 +111,61 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
return ids
|
||||
}
|
||||
|
||||
// checkUserChapterAccess 判断 userId 是否有权读取 chapterID(VIP / 全书购买 / 单章购买)
|
||||
// isPremium=true 表示增值版,fullbook 买断不含增值版
|
||||
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
|
||||
if userID == "" {
|
||||
return false
|
||||
}
|
||||
// VIP:is_vip=1 且未过期
|
||||
var u model.User
|
||||
if err := db.Select("id", "is_vip", "vip_expire_date", "has_full_book").Where("id = ?", userID).First(&u).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
if u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
|
||||
return true
|
||||
}
|
||||
// 全书买断(不含增值版)
|
||||
if !isPremium && u.HasFullBook != nil && *u.HasFullBook {
|
||||
return true
|
||||
}
|
||||
// 全书订单(兜底)
|
||||
if !isPremium {
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = 'fullbook' AND status = 'paid'", userID).Count(&cnt)
|
||||
if cnt > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 单章购买
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid'",
|
||||
userID, chapterID,
|
||||
).Count(&cnt)
|
||||
return cnt > 0
|
||||
}
|
||||
|
||||
// previewContent 取内容的前 20%(最多前 200 个字符),并追加省略提示
|
||||
func previewContent(content string) string {
|
||||
total := utf8.RuneCountInString(content)
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
limit := total / 5
|
||||
if limit < 100 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > total {
|
||||
limit = total
|
||||
}
|
||||
runes := []rune(content)
|
||||
return string(runes[:limit]) + "\n\n……(购买后阅读完整内容)"
|
||||
}
|
||||
|
||||
// findChapterAndRespond 按条件查章节并返回统一格式
|
||||
// 免费判断优先级:system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
|
||||
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content,否则返回 previewContent
|
||||
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
var ch model.Chapter
|
||||
db := database.DB()
|
||||
@@ -122,30 +177,43 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
|
||||
isFree := isFreeFromConfig
|
||||
if !isFree && ch.IsFree != nil && *ch.IsFree {
|
||||
isFree = true
|
||||
}
|
||||
if !isFree && ch.Price != nil && *ch.Price == 0 {
|
||||
isFree = true
|
||||
}
|
||||
|
||||
// 确定返回的 content:免费直接返回,付费须校验购买权限
|
||||
userID := c.Query("userId")
|
||||
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
|
||||
var returnContent string
|
||||
if isFree {
|
||||
returnContent = ch.Content
|
||||
} else if checkUserChapterAccess(db, userID, ch.ID, isPremium) {
|
||||
returnContent = ch.Content
|
||||
} else {
|
||||
returnContent = previewContent(ch.Content)
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"data": ch,
|
||||
"content": ch.Content,
|
||||
"content": returnContent,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"partTitle": ch.PartTitle,
|
||||
"id": ch.ID,
|
||||
"mid": ch.MID,
|
||||
"sectionTitle": ch.SectionTitle,
|
||||
"isFree": isFree,
|
||||
}
|
||||
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
|
||||
if isFreeFromConfig {
|
||||
out["isFree"] = true
|
||||
out["price"] = float64(0)
|
||||
} else {
|
||||
if ch.IsFree != nil {
|
||||
out["isFree"] = *ch.IsFree
|
||||
}
|
||||
if ch.Price != nil {
|
||||
out["price"] = *ch.Price
|
||||
if *ch.Price == 0 {
|
||||
out["isFree"] = true
|
||||
}
|
||||
}
|
||||
} else if ch.Price != nil {
|
||||
out["price"] = *ch.Price
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -290,15 +290,17 @@ func CKBSync(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// CKBLead POST /api/miniprogram/ckb/lead 小程序-链接卡若:上报线索到存客宝,便于卡若添加好友
|
||||
// 请求体:phone(可选)、wechatId(可选)、name(可选)、userId(可选,用于补全昵称)
|
||||
// 至少传 phone 或 wechatId 之一;签名规则同 api_v1.md
|
||||
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)或文章@某人(点击 mention)
|
||||
// 请求体:phone/wechatId(至少一个)、userId(补全昵称)、targetUserId(被@的 personId)、targetNickname、source
|
||||
func CKBLead(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
Name string `json:"name"`
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
Name string `json:"name"`
|
||||
TargetUserID string `json:"targetUserId"` // 被@的 personId(文章 mention 场景)
|
||||
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
|
||||
Source string `json:"source"` // index_lead / article_mention
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
phone := strings.TrimSpace(body.Phone)
|
||||
@@ -308,16 +310,37 @@ func CKBLead(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
db := database.DB()
|
||||
if name == "" && body.UserID != "" {
|
||||
var u model.User
|
||||
if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
||||
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
||||
name = *u.Nickname
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = "小程序用户"
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 确定使用哪个存客宝密钥
|
||||
// 优先级:被@人物的 ckb_api_key > 全局 CKB_LEAD_API_KEY > 代码内置 ckbAPIKey
|
||||
leadKey := ckbAPIKey
|
||||
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
|
||||
leadKey = cfg.CkbLeadAPIKey
|
||||
}
|
||||
targetName := strings.TrimSpace(body.TargetNickname) // 被@人的显示名,用于成功文案
|
||||
if body.TargetUserID != "" {
|
||||
var p model.Person
|
||||
if db.Where("person_id = ?", body.TargetUserID).First(&p).Error == nil {
|
||||
if p.CkbApiKey != "" {
|
||||
leadKey = p.CkbApiKey
|
||||
}
|
||||
if targetName == "" {
|
||||
targetName = p.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去重限频:2 分钟内同一用户/手机/微信只能提交一次
|
||||
var cond []string
|
||||
var args []interface{}
|
||||
if body.UserID != "" {
|
||||
@@ -332,7 +355,6 @@ func CKBLead(c *gin.Context) {
|
||||
cond = append(cond, "wechat_id = ?")
|
||||
args = append(args, wechatId)
|
||||
}
|
||||
// 2 分钟内同一用户/手机/微信只能提交一次(与前端限频一致)
|
||||
if len(cond) > 0 {
|
||||
cutoff := time.Now().Add(-2 * time.Minute)
|
||||
var recentCount int64
|
||||
@@ -341,30 +363,32 @@ func CKBLead(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 是否曾留资过(仅用于成功后的提示文案)
|
||||
repeatedSubmit := false
|
||||
if len(cond) > 0 {
|
||||
var existCount int64
|
||||
repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0
|
||||
}
|
||||
|
||||
source := strings.TrimSpace(body.Source)
|
||||
if source == "" {
|
||||
source = "index_lead"
|
||||
}
|
||||
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
||||
"targetUserId": body.TargetUserID, "source": source,
|
||||
})
|
||||
_ = db.Create(&model.CkbLeadRecord{
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
WechatID: wechatId,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Params: string(paramsJSON),
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
WechatID: wechatId,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
TargetPersonID: body.TargetUserID,
|
||||
Source: source,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
|
||||
ts := time.Now().Unix()
|
||||
// 链接卡若:GET + query(便于浏览器测试),传参:name, phone, wechatId, apiKey, timestamp, sign
|
||||
leadKey := ckbAPIKey
|
||||
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
|
||||
leadKey = cfg.CkbLeadAPIKey
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"name": name,
|
||||
"timestamp": ts,
|
||||
@@ -399,17 +423,19 @@ func CKBLead(c *gin.Context) {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
msg := "提交成功,卡若会尽快联系您"
|
||||
if result.Message == "已存在" {
|
||||
msg = "您已留资,我们会尽快联系您"
|
||||
// 成功文案:有被@的人则用 TA 的名字,否则用"对方"
|
||||
who := targetName
|
||||
if who == "" {
|
||||
who = "对方"
|
||||
}
|
||||
msg := fmt.Sprintf("提交成功,%s 会尽快联系您", who)
|
||||
if repeatedSubmit {
|
||||
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
|
||||
msg = fmt.Sprintf("您已留资过,我们已再次通知 %s,请耐心等待添加", who)
|
||||
}
|
||||
data := gin.H{}
|
||||
if result.Data != nil {
|
||||
|
||||
@@ -101,6 +101,22 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
if _, has := out["userDiscount"]; !has {
|
||||
out["userDiscount"] = float64(5)
|
||||
}
|
||||
// 链接标签列表(小程序 onLinkTagTap 需要知道 type,用于 ckb/miniprogram 的特殊处理)
|
||||
var linkTagRows []model.LinkTag
|
||||
if err := db.Order("label ASC").Find(&linkTagRows).Error; err == nil {
|
||||
tags := make([]gin.H, 0, len(linkTagRows))
|
||||
for _, t := range linkTagRows {
|
||||
tags = append(tags, gin.H{
|
||||
"tagId": t.TagID,
|
||||
"label": t.Label,
|
||||
"url": t.URL,
|
||||
"type": t.Type,
|
||||
"pagePath": t.PagePath,
|
||||
"appId": t.AppID,
|
||||
})
|
||||
}
|
||||
out["linkTags"] = tags
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
|
||||
@@ -163,18 +163,21 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
wordCount := len(item.Content)
|
||||
status := "published"
|
||||
editionStandard, editionPremium := true, false
|
||||
ch := model.Chapter{
|
||||
ID: item.ID,
|
||||
PartID: strPtr(item.PartID, "part-1"),
|
||||
PartTitle: strPtr(item.PartTitle, "未分类"),
|
||||
ChapterID: strPtr(item.ChapterID, "chapter-1"),
|
||||
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
|
||||
SectionTitle: item.Title,
|
||||
Content: item.Content,
|
||||
WordCount: &wordCount,
|
||||
IsFree: &isFree,
|
||||
Price: &price,
|
||||
Status: &status,
|
||||
ID: item.ID,
|
||||
PartID: strPtr(item.PartID, "part-1"),
|
||||
PartTitle: strPtr(item.PartTitle, "未分类"),
|
||||
ChapterID: strPtr(item.ChapterID, "chapter-1"),
|
||||
ChapterTitle: strPtr(item.ChapterTitle, "未分类"),
|
||||
SectionTitle: item.Title,
|
||||
Content: item.Content,
|
||||
WordCount: &wordCount,
|
||||
IsFree: &isFree,
|
||||
Price: &price,
|
||||
Status: &status,
|
||||
EditionStandard: &editionStandard,
|
||||
EditionPremium: &editionPremium,
|
||||
}
|
||||
err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -212,14 +215,19 @@ func DBBookAction(c *gin.Context) {
|
||||
TargetChapterID string `json:"targetChapterId"`
|
||||
TargetPartTitle string `json:"targetPartTitle"`
|
||||
TargetChapterTitle string `json:"targetChapterTitle"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
IsNew *bool `json:"isNew"` // stitch_soul:标记最新新增
|
||||
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
|
||||
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
IsNew *bool `json:"isNew"` // stitch_soul:标记最新新增
|
||||
EditionStandard *bool `json:"editionStandard"` // 是否属于普通版
|
||||
EditionPremium *bool `json:"editionPremium"` // 是否属于增值版
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
HotScore *float64 `json:"hotScore"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -315,13 +323,88 @@ func DBBookAction(c *gin.Context) {
|
||||
if body.IsNew != nil {
|
||||
updates["is_new"] = *body.IsNew
|
||||
}
|
||||
// 默认普通版:未传时按普通版处理
|
||||
if body.EditionStandard != nil {
|
||||
updates["edition_standard"] = *body.EditionStandard
|
||||
} else if body.EditionPremium == nil {
|
||||
updates["edition_standard"] = true
|
||||
updates["edition_premium"] = false
|
||||
}
|
||||
if body.EditionPremium != nil {
|
||||
updates["edition_premium"] = *body.EditionPremium
|
||||
}
|
||||
err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
|
||||
if body.HotScore != nil {
|
||||
updates["hot_score"] = *body.HotScore
|
||||
}
|
||||
if body.PartID != "" {
|
||||
updates["part_id"] = body.PartID
|
||||
}
|
||||
if body.PartTitle != "" {
|
||||
updates["part_title"] = body.PartTitle
|
||||
}
|
||||
if body.ChapterID != "" {
|
||||
updates["chapter_id"] = body.ChapterID
|
||||
}
|
||||
if body.ChapterTitle != "" {
|
||||
updates["chapter_title"] = body.ChapterTitle
|
||||
}
|
||||
var existing model.Chapter
|
||||
err := db.Where("id = ?", body.ID).First(&existing).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 新建:Create
|
||||
partID := body.PartID
|
||||
if partID == "" {
|
||||
partID = "part-1"
|
||||
}
|
||||
partTitle := body.PartTitle
|
||||
if partTitle == "" {
|
||||
partTitle = "未分类"
|
||||
}
|
||||
chapterID := body.ChapterID
|
||||
if chapterID == "" {
|
||||
chapterID = "chapter-1"
|
||||
}
|
||||
chapterTitle := body.ChapterTitle
|
||||
if chapterTitle == "" {
|
||||
chapterTitle = "未分类"
|
||||
}
|
||||
editionStandard, editionPremium := true, false
|
||||
if body.EditionPremium != nil && *body.EditionPremium {
|
||||
editionStandard, editionPremium = false, true
|
||||
} else if body.EditionStandard != nil {
|
||||
editionStandard = *body.EditionStandard
|
||||
}
|
||||
status := "published"
|
||||
ch := model.Chapter{
|
||||
ID: body.ID,
|
||||
PartID: partID,
|
||||
PartTitle: partTitle,
|
||||
ChapterID: chapterID,
|
||||
ChapterTitle: chapterTitle,
|
||||
SectionTitle: body.Title,
|
||||
Content: body.Content,
|
||||
WordCount: &wordCount,
|
||||
IsFree: &isFree,
|
||||
Price: &price,
|
||||
Status: &status,
|
||||
EditionStandard: &editionStandard,
|
||||
EditionPremium: &editionPremium,
|
||||
}
|
||||
if body.IsNew != nil {
|
||||
ch.IsNew = body.IsNew
|
||||
}
|
||||
if err := db.Create(&ch).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
err = db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -24,9 +24,10 @@ func DBPersonList(c *gin.Context) {
|
||||
// DBPersonSave POST /api/db/persons 管理端-新增或更新人物
|
||||
func DBPersonSave(c *gin.Context) {
|
||||
var body struct {
|
||||
PersonID string `json:"personId"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
PersonID string `json:"personId"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
CkbApiKey string `json:"ckbApiKey"` // 存客宝密钥,留空则 fallback 全局 Key
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -44,11 +45,12 @@ func DBPersonSave(c *gin.Context) {
|
||||
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
|
||||
existing.Name = body.Name
|
||||
existing.Label = body.Label
|
||||
existing.CkbApiKey = body.CkbApiKey
|
||||
db.Save(&existing)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
|
||||
return
|
||||
}
|
||||
p := model.Person{PersonID: body.PersonID, Name: body.Name, Label: body.Label}
|
||||
p := model.Person{PersonID: body.PersonID, Name: body.Name, Label: body.Label, CkbApiKey: body.CkbApiKey}
|
||||
if err := db.Create(&p).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -220,6 +220,13 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// -------- V1.1 后端价格:从 DB 读取标准价,客户端传值仅用于日志对比,实际以后端计算为准 --------
|
||||
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询用户的有效推荐人(先查 binding,再查 referralCode)
|
||||
var referrerID *string
|
||||
if req.UserID != "" {
|
||||
@@ -244,8 +251,8 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 有推荐人时应用好友优惠(无论是 binding 还是 referralCode)
|
||||
finalAmount := req.Amount
|
||||
// 有推荐人时应用好友优惠,以后端标准价为基准计算最终金额,忽略客户端传值
|
||||
finalAmount := standardPrice
|
||||
if referrerID != nil {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
@@ -253,7 +260,7 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
discountRate := userDiscount / 100
|
||||
finalAmount = req.Amount * (1 - discountRate)
|
||||
finalAmount = standardPrice * (1 - discountRate)
|
||||
if finalAmount < 0.01 {
|
||||
finalAmount = 0.01
|
||||
}
|
||||
@@ -261,6 +268,11 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 记录客户端与后端金额差异(仅日志,不拦截)
|
||||
if req.Amount-finalAmount > 0.05 || finalAmount-req.Amount > 0.05 {
|
||||
fmt.Printf("[PayCreate] 金额差异: 客户端=%.2f 后端=%.2f productType=%s productId=%s userId=%s\n",
|
||||
req.Amount, finalAmount, req.ProductType, req.ProductID, req.UserID)
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
@@ -372,7 +384,7 @@ func miniprogramPayGet(c *gin.Context) {
|
||||
switch tradeState {
|
||||
case "SUCCESS":
|
||||
status = "paid"
|
||||
// 若微信已支付,主动同步到本地 orders(不等 PayNotify),便于购买次数即时生效
|
||||
// V1.3 修复:主动同步到本地 orders,并激活对应权益(VIP/全书),避免等待 PayNotify 延迟
|
||||
db := database.DB()
|
||||
var order model.Order
|
||||
if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err == nil && order.Status != nil && *order.Status != "paid" {
|
||||
@@ -382,7 +394,13 @@ func miniprogramPayGet(c *gin.Context) {
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
})
|
||||
order.Status = strToPtr("paid")
|
||||
order.PayTime = &now
|
||||
orderPollLogf("主动同步订单已支付: %s", orderSn)
|
||||
// 激活权益
|
||||
if order.UserID != "" {
|
||||
activateOrderBenefits(db, order.UserID, order.ProductType, now)
|
||||
}
|
||||
}
|
||||
case "CLOSED", "REVOKED", "PAYERROR":
|
||||
status = "failed"
|
||||
@@ -484,17 +502,12 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
|
||||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
|
||||
} else if attach.ProductType == "vip" {
|
||||
// VIP 支付成功:更新 users.is_vip、vip_expire_date、vip_activated_at(排序:后付款在前)
|
||||
expireDate := time.Now().AddDate(0, 0, 365)
|
||||
// V4.2 修复:续费时累加剩余天数(从 max(now, vip_expire_date) 加 365 天)
|
||||
vipActivatedAt := time.Now()
|
||||
if order.PayTime != nil {
|
||||
vipActivatedAt = *order.PayTime
|
||||
}
|
||||
db.Model(&model.User{}).Where("id = ?", buyerUserID).Updates(map[string]interface{}{
|
||||
"is_vip": true,
|
||||
"vip_expire_date": expireDate,
|
||||
"vip_activated_at": vipActivatedAt,
|
||||
})
|
||||
expireDate := activateVIP(db, buyerUserID, 365, vipActivatedAt)
|
||||
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
|
||||
} else if attach.ProductType == "match" {
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", buyerUserID, orderSn)
|
||||
@@ -781,9 +794,12 @@ func MiniprogramUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
|
||||
return
|
||||
}
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
|
||||
id, "paid", "fullbook", "vip").Count(&cnt)
|
||||
// V4.1 修复:is_vip 同时校验过期时间(is_vip=1 且 vip_expire_date>NOW),而非仅凭订单数量
|
||||
isVipActive, _ := isVipFromUsers(db, id)
|
||||
if !isVipActive {
|
||||
// 兜底:orders 表有有效 VIP 订单
|
||||
isVipActive, _ = isVipFromOrders(db, id)
|
||||
}
|
||||
// 用户信息与会员资料(vip*)、P3 资料扩展,供会员详情页完整展示
|
||||
item := gin.H{
|
||||
"id": user.ID,
|
||||
@@ -808,7 +824,7 @@ func MiniprogramUsers(c *gin.Context) {
|
||||
"helpOffer": getStringValue(user.HelpOffer),
|
||||
"helpNeed": getStringValue(user.HelpNeed),
|
||||
"projectIntro": getStringValue(user.ProjectIntro),
|
||||
"is_vip": cnt > 0,
|
||||
"is_vip": isVipActive,
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
|
||||
return
|
||||
@@ -819,15 +835,101 @@ func MiniprogramUsers(c *gin.Context) {
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)",
|
||||
u.ID, "paid", "fullbook", "vip").Count(&cnt)
|
||||
// V4.1:is_vip 同时校验过期时间
|
||||
uvip, _ := isVipFromUsers(db, u.ID)
|
||||
if !uvip {
|
||||
uvip, _ = isVipFromOrders(db, u.ID)
|
||||
}
|
||||
list = append(list, gin.H{
|
||||
"id": u.ID,
|
||||
"nickname": getStringValue(u.Nickname),
|
||||
"avatar": getStringValue(u.Avatar),
|
||||
"is_vip": cnt > 0,
|
||||
"is_vip": uvip,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// strToPtr 返回字符串指针(辅助函数)
|
||||
func strToPtr(s string) *string { return &s }
|
||||
|
||||
// activateVIP 为用户激活 VIP:续费时从 max(now, vip_expire_date) 累加 days 天
|
||||
// 返回最终过期时间
|
||||
func activateVIP(db *gorm.DB, userID string, days int, activatedAt time.Time) time.Time {
|
||||
var u model.User
|
||||
db.Select("id", "is_vip", "vip_expire_date").Where("id = ?", userID).First(&u)
|
||||
base := activatedAt
|
||||
if u.VipExpireDate != nil && u.VipExpireDate.After(base) {
|
||||
base = *u.VipExpireDate // 续费累加
|
||||
}
|
||||
expireDate := base.AddDate(0, 0, days)
|
||||
db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"is_vip": true,
|
||||
"vip_expire_date": expireDate,
|
||||
"vip_activated_at": activatedAt,
|
||||
})
|
||||
return expireDate
|
||||
}
|
||||
|
||||
// activateOrderBenefits 订单支付成功后激活对应权益(VIP / 全书)
|
||||
func activateOrderBenefits(db *gorm.DB, userID, productType string, payTime time.Time) {
|
||||
switch productType {
|
||||
case "fullbook":
|
||||
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
|
||||
case "vip":
|
||||
activateVIP(db, userID, 365, payTime)
|
||||
}
|
||||
}
|
||||
|
||||
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
|
||||
// productType: fullbook / vip / section / match
|
||||
// productId: 章节购买时为章节 ID
|
||||
func getStandardPrice(db *gorm.DB, productType, productID string) (float64, error) {
|
||||
switch productType {
|
||||
case "fullbook", "vip", "match":
|
||||
// 从 system_config 读取
|
||||
configKey := "chapter_config"
|
||||
if productType == "vip" {
|
||||
configKey = "vip_config"
|
||||
}
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", configKey).First(&row).Error; err == nil {
|
||||
var cfg map[string]interface{}
|
||||
if json.Unmarshal(row.ConfigValue, &cfg) == nil {
|
||||
fieldMap := map[string]string{
|
||||
"fullbook": "fullbookPrice",
|
||||
"vip": "price",
|
||||
"match": "matchPrice",
|
||||
}
|
||||
if v, ok := cfg[fieldMap[productType]].(float64); ok && v > 0 {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 兜底默认值
|
||||
defaults := map[string]float64{"fullbook": 9.9, "vip": 1980, "match": 68}
|
||||
if p, ok := defaults[productType]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return 0, fmt.Errorf("未知商品类型: %s", productType)
|
||||
|
||||
case "section":
|
||||
if productID == "" {
|
||||
return 0, fmt.Errorf("单章购买缺少 productId")
|
||||
}
|
||||
var ch model.Chapter
|
||||
if err := db.Select("id", "price", "is_free").Where("id = ?", productID).First(&ch).Error; err != nil {
|
||||
return 0, fmt.Errorf("章节不存在: %s", productID)
|
||||
}
|
||||
if ch.IsFree != nil && *ch.IsFree {
|
||||
return 0, fmt.Errorf("该章节为免费章节,无需支付")
|
||||
}
|
||||
if ch.Price == nil || *ch.Price <= 0 {
|
||||
return 0, fmt.Errorf("章节价格未配置: %s", productID)
|
||||
}
|
||||
return *ch.Price, nil
|
||||
|
||||
default:
|
||||
return 0, fmt.Errorf("未知商品类型: %s", productType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,17 @@ func ReferralBind(c *gin.Context) {
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// V3.1 修复:若同时提供了 openId 和 userId,校验 userId 对应的用户确实拥有该 openId
|
||||
// 防止攻击者伪造他人 userId 来绑定推荐关系
|
||||
if req.UserID != "" && req.OpenID != "" {
|
||||
var cnt int64
|
||||
db.Model(&model.User{}).Where("id = ? AND open_id = ?", req.UserID, req.OpenID).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户身份校验失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
bindingDays := defaultBindingDays
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
|
||||
@@ -69,6 +69,7 @@ func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model
|
||||
return base * vipOrderShareNonVip
|
||||
}
|
||||
// 内容订单:若有推荐人且 userDiscount>0,反推原价;否则按实付
|
||||
// 设计意图:推广者拿的是折前原价的佣金,好友折扣由平台承担,不影响推广者收益
|
||||
commissionBase := order.Amount
|
||||
if userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || (order.ReferralCode != nil && *order.ReferralCode != "")) {
|
||||
if (1 - userDiscount) > 0 {
|
||||
|
||||
@@ -639,3 +639,91 @@ 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
|
||||
}
|
||||
|
||||
// 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