更新小程序,新增VIP会员状态管理功能,优化章节解锁逻辑,支持VIP用户访问增值内容。调整用户详情页面,增加VIP相关字段和功能,提升用户体验。更新会议记录,反映最新讨论内容。

This commit is contained in:
Alex-larget
2026-03-10 11:04:34 +08:00
parent 30ebdb5ac7
commit 05ac60dc7e
60 changed files with 8387 additions and 1583 deletions

View File

@@ -54,6 +54,12 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.CkbLeadRecord{}); err != nil {
log.Printf("database: ckb_lead_records migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Person{}); err != nil {
log.Printf("database: persons migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
log.Printf("database: link_tags migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}

View File

@@ -86,18 +86,34 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"section": gin.H{
"id": ch.ID,
"title": ch.SectionTitle,
"price": price,
"content": ch.Content,
"isNew": ch.IsNew,
"partId": ch.PartID,
"partTitle": ch.PartTitle,
"chapterId": ch.ChapterID,
"chapterTitle": ch.ChapterTitle,
"id": ch.ID,
"title": ch.SectionTitle,
"price": price,
"content": ch.Content,
"isNew": ch.IsNew,
"partId": ch.PartID,
"partTitle": ch.PartTitle,
"chapterId": ch.ChapterID,
"chapterTitle": ch.ChapterTitle,
"editionStandard": ch.EditionStandard,
"editionPremium": ch.EditionPremium,
},
})
return
case "section-orders":
// 某章节的付款记录(管理端展示)
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
var orders []model.Order
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
}
c.JSON(http.StatusOK, gin.H{"success": true, "orders": orders})
return
case "export":
var rows []model.Chapter
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
@@ -190,6 +206,12 @@ func DBBookAction(c *gin.Context) {
// reorder新顺序支持跨篇跨章时附带 partId/chapterId
IDs []string `json:"ids"`
Items []reorderItem `json:"items"`
// move-sections批量移动节到目标篇/章
SectionIds []string `json:"sectionIds"`
TargetPartID string `json:"targetPartId"`
TargetChapterID string `json:"targetChapterId"`
TargetPartTitle string `json:"targetPartTitle"`
TargetChapterTitle string `json:"targetChapterTitle"`
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
@@ -248,6 +270,28 @@ func DBBookAction(c *gin.Context) {
return
}
}
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,
"part_title": body.TargetPartTitle,
"chapter_title": body.TargetChapterTitle,
}
if err := db.Model(&model.Chapter{}).Where("id IN ?", body.SectionIds).Updates(up).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
return
}
if body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return

View File

@@ -0,0 +1,138 @@
package handler
import (
"net/http"
"strconv"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细
// mode=submitted: ckb_submit_recordsjoin/match 提交)
// mode=contact: ckb_lead_records链接卡若留资有 phone/wechat
func DBCKBLeadList(c *gin.Context) {
db := database.DB()
mode := c.DefaultQuery("mode", "submitted")
matchType := c.Query("matchType")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
if mode == "contact" {
// ckb_lead_records链接卡若留资
q := db.Model(&model.CkbLeadRecord{})
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"userNickname": r.Nickname,
"matchType": "lead",
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
return
}
// mode=submitted: ckb_submit_records
q := db.Model(&model.CkbSubmitRecord{})
if matchType != "" {
// matchType 对应 action: join 时 type 在 params 中match 时 matchType 在 params 中
// 简化:仅按 action 过滤join 时 params 含 type
if matchType == "join" || matchType == "match" {
q = q.Where("action = ?", matchType)
}
}
var total int64
q.Count(&total)
var records []model.CkbSubmitRecord
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
userIDs := make(map[string]bool)
for _, r := range records {
if r.UserID != "" {
userIDs[r.UserID] = true
}
}
ids := make([]string, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []model.User
if len(ids) > 0 {
db.Where("id IN ?", ids).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
safeNickname := func(u *model.User) string {
if u == nil || u.Nickname == nil {
return ""
}
return *u.Nickname
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"userNickname": safeNickname(userMap[r.UserID]),
"matchType": r.Action,
"nickname": r.Nickname,
"params": r.Params,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
}
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records
func CKBPlanStats(c *gin.Context) {
db := database.DB()
type TypeStat struct {
Action string `gorm:"column:action" json:"matchType"`
Total int64 `gorm:"column:total" json:"total"`
}
var submitStats []TypeStat
db.Raw("SELECT action, COUNT(*) as total FROM ckb_submit_records GROUP BY action").Scan(&submitStats)
var submitTotal int64
db.Model(&model.CkbSubmitRecord{}).Count(&submitTotal)
var leadTotal int64
db.Model(&model.CkbLeadRecord{}).Count(&leadTotal)
withContact := leadTotal // ckb_lead_records 均有 phone 或 wechat
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"ckbTotal": submitTotal + leadTotal,
"withContact": withContact,
"byType": submitStats,
"ckbApiKey": "***",
"ckbApiUrl": "https://ckbapi.quwanzhi.com/v1/api/scenarios",
"docNotes": "",
"docContent": "",
"routes": gin.H{},
},
})
}

View File

@@ -0,0 +1,75 @@
package handler
import (
"net/http"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBLinkTagList GET /api/db/link-tags 管理端-链接标签列表
func DBLinkTagList(c *gin.Context) {
var rows []model.LinkTag
if err := database.DB().Order("label ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "linkTags": rows})
}
// DBLinkTagSave POST /api/db/link-tags 管理端-新增或更新链接标签
func DBLinkTagSave(c *gin.Context) {
var body struct {
TagID string `json:"tagId"`
Label string `json:"label"`
URL string `json:"url"`
Type string `json:"type"`
AppID string `json:"appId"`
PagePath string `json:"pagePath"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.TagID == "" || body.Label == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "tagId 和 label 必填"})
return
}
if body.Type == "" {
body.Type = "url"
}
db := database.DB()
var existing model.LinkTag
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
existing.Label = body.Label
existing.URL = body.URL
existing.Type = body.Type
existing.AppID = body.AppID
existing.PagePath = body.PagePath
db.Save(&existing)
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
return
}
t := model.LinkTag{TagID: body.TagID, Label: body.Label, URL: body.URL, Type: body.Type, AppID: body.AppID, PagePath: body.PagePath}
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, "linkTag": t})
}
// DBLinkTagDelete DELETE /api/db/link-tags?tagId=xxx 管理端-删除链接标签
func DBLinkTagDelete(c *gin.Context) {
tid := c.Query("tagId")
if tid == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 tagId"})
return
}
if err := database.DB().Where("tag_id = ?", tid).Delete(&model.LinkTag{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -0,0 +1,71 @@
package handler
import (
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// DBPersonList GET /api/db/persons 管理端-@提及人物列表
func DBPersonList(c *gin.Context) {
var rows []model.Person
if err := database.DB().Order("name ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "persons": rows})
}
// 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"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.Name == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 必填"})
return
}
if body.PersonID == "" {
body.PersonID = fmt.Sprintf("%s_%d", body.Name, time.Now().UnixMilli())
}
db := database.DB()
var existing model.Person
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
existing.Name = body.Name
existing.Label = body.Label
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}
if err := db.Create(&p).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "person": p})
}
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
func DBPersonDelete(c *gin.Context) {
pid := c.Query("personId")
if pid == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
return
}
if err := database.DB().Where("person_id = ?", pid).Delete(&model.Person{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -11,8 +11,40 @@ 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.Raw("SELECT COUNT(*) FROM match_records").Scan(&totalMatches)
var todayMatches int64
db.Raw("SELECT COUNT(*) FROM match_records WHERE created_at >= CURDATE()").Scan(&todayMatches)
type TypeCount struct {
MatchType string `json:"matchType" gorm:"column:match_type"`
Count int64 `json:"count" gorm:"column:count"`
}
var byType []TypeCount
db.Raw("SELECT match_type, COUNT(*) as count FROM match_records GROUP BY match_type").Scan(&byType)
var uniqueUsers int64
db.Raw("SELECT COUNT(DISTINCT user_id) FROM match_records WHERE user_id IS NOT NULL AND user_id != ''").Scan(&uniqueUsers)
var matchRevenue float64
db.Model(&model.Order{}).Where("product_type = ? AND status IN ?", "match", []string{"paid", "completed", "success"}).
Select("COALESCE(SUM(amount), 0)").Scan(&matchRevenue)
var paidMatchCount int64
db.Model(&model.Order{}).Where("product_type = ? AND status IN ?", "match", []string{"paid", "completed", "success"}).Count(&paidMatchCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"totalMatches": totalMatches,
"todayMatches": todayMatches,
"byType": byType,
"uniqueUsers": uniqueUsers,
"matchRevenue": matchRevenue,
"paidMatchCount": paidMatchCount,
},
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
matchType := c.Query("matchType")
@@ -94,3 +126,26 @@ func DBMatchRecordsList(c *gin.Context) {
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
})
}
// DBMatchPoolCounts GET /api/db/match-pool-counts 返回各匹配池的用户人数
func DBMatchPoolCounts(c *gin.Context) {
db := database.DB()
var vipCount int64
db.Model(&model.User{}).Where("is_vip = 1 AND vip_expire_date > NOW()").Count(&vipCount)
var completeCount int64
db.Model(&model.User{}).Where(
"(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')",
).Count(&completeCount)
var allCount int64
db.Model(&model.User{}).Where(
"((wechat_id IS NOT NULL AND wechat_id != '') OR (phone IS NOT NULL AND phone != ''))",
).Count(&allCount)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"vip": vipCount,
"complete": completeCount,
"all": allCount,
},
})
}

View File

@@ -171,18 +171,43 @@ func UserCheckPurchased(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_vip"}})
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"}})
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]}})
@@ -382,11 +407,8 @@ func UserPurchaseStatus(c *gin.Context) {
if user.PendingEarnings != nil {
pendingEarnings = *user.PendingEarnings
}
// 超级VIP管理端开通与 check-purchased 一致,视为全章可读
// 9.9 买断:仅表示“普通版买断”,不等同 VIP增值版仍需 VIP 或单章购买)
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
if !hasFullBook && user.IsVip != nil && *user.IsVip && user.VipExpireDate != nil && user.VipExpireDate.After(time.Now()) {
hasFullBook = true
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"hasFullBook": hasFullBook,
"purchasedSections": purchasedSections,

View File

@@ -45,8 +45,10 @@ func isVipFromUsers(db *gorm.DB, userID string) (bool, *time.Time) {
// isVipFromOrders 从 orders 表判断是否 VIP兜底
func isVipFromOrders(db *gorm.DB, userID string) (bool, *time.Time) {
var order model.Order
err := db.Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
userID, "paid", "completed", "fullbook", "vip").
// 注意fullbook=9.9 买断(永久权益),不等同于 VIP365天
// VIP 仅认 product_type=vip。
err := db.Where("user_id = ? AND (status = ? OR status = ?) AND product_type = ?",
userID, "paid", "completed", "vip").
Order("pay_time DESC").First(&order).Error
if err != nil || order.PayTime == nil {
return false, nil
@@ -97,7 +99,7 @@ func VipStatus(c *gin.Context) {
daysRemaining := 0
expStr := ""
if expireDate != nil {
daysRemaining = int(expireDate.Sub(time.Now()).Hours()/24) + 1
daysRemaining = int(time.Until(*expireDate).Hours()/24) + 1
if daysRemaining < 0 {
daysRemaining = 0
}

View File

@@ -0,0 +1,18 @@
package model
import "time"
// LinkTag 链接标签配置ContentPage 用)
type LinkTag struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
TagID string `gorm:"column:tag_id;size:50;uniqueIndex" json:"tagId"`
Label string `gorm:"column:label;size:200" json:"label"`
URL string `gorm:"column:url;size:500" json:"url"`
Type string `gorm:"column:type;size:20" json:"type"`
AppID string `gorm:"column:app_id;size:100" json:"appId,omitempty"`
PagePath string `gorm:"column:page_path;size:500" json:"pagePath,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (LinkTag) TableName() string { return "link_tags" }

View File

@@ -0,0 +1,15 @@
package model
import "time"
// Person @提及人物配置ContentPage 用)
type Person struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
Name string `gorm:"column:name;size:100" json:"name"`
Label string `gorm:"column:label;size:200" json:"label"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
func (Person) TableName() string { return "persons" }

View File

@@ -143,11 +143,20 @@ func Setup(cfg *config.Config) *gin.Engine {
db.PUT("/vip-roles", handler.DBVipRolesAction)
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/match-records", handler.DBMatchRecordsList)
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
db.GET("/mentors", handler.DBMentorsList)
db.POST("/mentors", handler.DBMentorsAction)
db.PUT("/mentors", handler.DBMentorsAction)
db.DELETE("/mentors", handler.DBMentorsAction)
db.GET("/mentor-consultations", handler.DBMentorConsultationsList)
db.GET("/persons", handler.DBPersonList)
db.POST("/persons", handler.DBPersonSave)
db.DELETE("/persons", handler.DBPersonDelete)
db.GET("/link-tags", handler.DBLinkTagList)
db.POST("/link-tags", handler.DBLinkTagSave)
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
}
// ----- 分销 -----