This commit is contained in:
Alex-larget
2026-03-24 15:44:08 +08:00
parent 346e8ab057
commit 28ad08da84
62 changed files with 814 additions and 840 deletions

View File

@@ -392,6 +392,29 @@ func AdminSuperIndividualStats(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": len(out)})
}
// AdminDashboardLeads GET /api/admin/dashboard/leads 管理端看板-存客宝线索/提交记录概览
func AdminDashboardLeads(c *gin.Context) {
db := database.DB()
var contactTotal, submitTotal, uniqueContactUsers int64
db.Model(&model.CkbLeadRecord{}).Count(&contactTotal)
db.Model(&model.CkbSubmitRecord{}).Count(&submitTotal)
db.Raw(`SELECT COUNT(DISTINCT user_id) FROM ckb_lead_records WHERE user_id IS NOT NULL AND user_id != ''`).Scan(&uniqueContactUsers)
var todayContact, todaySubmit int64
db.Raw(`SELECT COUNT(*) FROM ckb_lead_records WHERE DATE(created_at) = CURDATE()`).Scan(&todayContact)
db.Raw(`SELECT COUNT(*) FROM ckb_submit_records WHERE DATE(created_at) = CURDATE()`).Scan(&todaySubmit)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"contactLeadsTotal": contactTotal,
"submitRecordsTotal": submitTotal,
"uniqueContactUsers": uniqueContactUsers,
"todayContactLeads": todayContact,
"todaySubmitRecords": todaySubmit,
"combinedTotal": contactTotal + submitTotal,
},
})
}
func buildNewUsersOut(newUsers []model.User) []gin.H {
out := make([]gin.H, 0, len(newUsers))
for _, u := range newUsers {

View File

@@ -1075,3 +1075,31 @@ func BookStats(c *gin.Context) {
func BookSync(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步由 DB 维护"})
}
// BookRanking GET /api/miniprogram/book/ranking 内容排行榜(与 BookRecommended 同一套 computeArticleRankingSections
func BookRanking(c *gin.Context) {
limit := 50
if l := c.Query("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
limit = n
}
}
sections, err := computeArticleRankingSections(database.DB())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "排行榜计算失败"})
return
}
if len(sections) > limit {
sections = sections[:limit]
}
out := make([]gin.H, 0, len(sections))
for _, s := range sections {
out = append(out, gin.H{
"id": s.ID, "mid": s.MID, "title": s.Title,
"partTitle": s.PartTitle, "chapterTitle": s.ChapterTitle,
"hotScore": s.HotScore, "isPinned": s.IsPinned,
"price": s.Price, "isFree": s.IsFree, "isNew": s.IsNew,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}

View File

@@ -78,6 +78,22 @@ func ckbSign(params map[string]interface{}, apiKey string) string {
return hex.EncodeToString(h2[:])
}
// userHasContentPurchase 与小程序资源对接 requirePurchase 一致:已付章节或全书解锁
func userHasContentPurchase(db *gorm.DB, userID string) bool {
if strings.TrimSpace(userID) == "" {
return false
}
var u model.User
if db.Select("has_full_book").Where("id = ?", userID).First(&u).Error == nil {
if u.HasFullBook != nil && *u.HasFullBook {
return true
}
}
var n int64
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND product_type = ?", userID, "paid", "section").Count(&n)
return n > 0
}
// getCkbLeadApiKey 链接卡若密钥优先级system_config.site_settings.ckbLeadApiKey > .env CKB_LEAD_API_KEY > 代码内置 ckbAPIKey
func getCkbLeadApiKey() string {
var row model.SystemConfig
@@ -119,6 +135,16 @@ func CKBJoin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"})
return
}
if body.Type == "investor" && body.UserID != "" {
if !userHasContentPurchase(database.DB(), body.UserID) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请先购买章节或解锁全书后再使用资源对接",
"errorCode": "ERR_REQUIRE_PURCHASE",
})
return
}
}
nickname := strings.TrimSpace(body.Name)
if nickname == "" && body.UserID != "" {
var u model.User

View File

@@ -106,7 +106,7 @@ func buildMiniprogramConfig() gin.H {
}
}
}
// 好友优惠(用于 read 页展示优惠价)
// 好友优惠与分润(用于 read 页展示优惠价、分享提示分润比例
var refRow model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil {
var refVal map[string]interface{}
@@ -114,6 +114,10 @@ func buildMiniprogramConfig() gin.H {
if v, ok := refVal["userDiscount"].(float64); ok {
out["userDiscount"] = v
}
// 内容订单分润比例0-100供小程序 read/referral 页展示
if v, ok := refVal["distributorShare"].(float64); ok {
out["shareRate"] = int(v)
}
}
}
if _, has := out["userDiscount"]; !has {
@@ -218,6 +222,7 @@ func GetCoreConfig(c *gin.Context) {
"prices": full["prices"],
"features": full["features"],
"userDiscount": full["userDiscount"],
"shareRate": full["shareRate"],
"mpConfig": full["mpConfig"],
}
if out["prices"] == nil {
@@ -229,6 +234,9 @@ func GetCoreConfig(c *gin.Context) {
if out["userDiscount"] == nil {
out["userDiscount"] = float64(5)
}
if out["shareRate"] == nil {
out["shareRate"] = 90
}
if out["mpConfig"] == nil {
out["mpConfig"] = gin.H{}
}
@@ -305,6 +313,7 @@ func WarmConfigCache() {
"prices": out["prices"],
"features": out["features"],
"userDiscount": out["userDiscount"],
"shareRate": out["shareRate"],
"mpConfig": out["mpConfig"],
}
if core["prices"] == nil {
@@ -316,6 +325,9 @@ func WarmConfigCache() {
if core["userDiscount"] == nil {
core["userDiscount"] = float64(5)
}
if core["shareRate"] == nil {
core["shareRate"] = 90
}
if core["mpConfig"] == nil {
core["mpConfig"] = gin.H{}
}

View File

@@ -591,6 +591,58 @@ func DBPersonPinnedList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "persons": out})
}
// DBPersonPinnedToken GET /api/db/persons/pinned-token 当前置顶人物 token管理端预览/配置用,与置顶列表首条一致)
func DBPersonPinnedToken(c *gin.Context) {
db := database.DB()
var p model.Person
err := db.Where("is_pinned = ?", true).Order("updated_at DESC").First(&p).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"success": true, "token": ""})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "token": strings.TrimSpace(p.Token)})
}
// CKBPinnedPerson GET /api/miniprogram/ckb/pinned-person 小程序首页:当前置顶人物(无置顶时 data 为 null
func CKBPinnedPerson(c *gin.Context) {
db := database.DB()
var p model.Person
err := db.Where("is_pinned = ?", true).Order("updated_at DESC").First(&p).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return
}
nickname := strings.TrimSpace(p.Name)
avatar := strings.TrimSpace(p.Avatar)
if p.UserID != nil && *p.UserID != "" {
var u model.User
if db.Select("nickname", "avatar").Where("id = ?", *p.UserID).First(&u).Error == nil {
if v := getStringValue(u.Nickname); v != "" {
nickname = v
}
if v := getUrlValue(u.Avatar); v != "" {
avatar = v
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"nickname": nickname,
"avatar": avatar,
"token": strings.TrimSpace(p.Token),
},
})
}
// AdminCKBPlanCheck GET /api/admin/ckb/plan-check 管理端-检查存客宝计划在线状态
// 查询所有有 ckb_plan_id 的 Person对每个计划调用存客宝获取状态
func AdminCKBPlanCheck(c *gin.Context) {

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"soul-api/internal/database"
@@ -165,12 +166,22 @@ func MatchUsers(c *gin.Context) {
return
}
db := database.DB()
// 全书用户无限制,否则校验今日剩余次数
var user model.User
skipQuota := false
if err := db.Where("id = ?", body.UserID).First(&user).Error; err == nil {
skipQuota = user.HasFullBook != nil && *user.HasFullBook
if err := db.Where("id = ?", body.UserID).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户不存在"})
return
}
phoneOK := user.Phone != nil && strings.TrimSpace(*user.Phone) != ""
wechatOK := user.WechatID != nil && strings.TrimSpace(*user.WechatID) != ""
if !phoneOK && !wechatOK {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请先完善手机号或微信号后再发起匹配",
"errorCode": "ERR_PROFILE_INCOMPLETE",
})
return
}
skipQuota := user.HasFullBook != nil && *user.HasFullBook
if !skipQuota {
freeLimit := getFreeMatchLimit(db)
quota := GetMatchQuota(db, body.UserID, freeLimit)

View File

@@ -123,6 +123,13 @@ func WechatPhoneLogin(c *gin.Context) {
"referralCount": intVal(user.ReferralCount),
"createdAt": user.CreatedAt,
}
// 与 /api/miniprogram/login 一致,避免手机号登录后 VIP 引导、权益展示滞后
if user.IsVip != nil {
responseUser["isVip"] = *user.IsVip
}
if user.VipExpireDate != nil {
responseUser["vipExpireDate"] = user.VipExpireDate.Format("2006-01-02")
}
token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
c.JSON(http.StatusOK, gin.H{

View File

@@ -6,6 +6,7 @@ import (
"math"
"net/http"
"os"
"strings"
"time"
"soul-api/internal/config"
@@ -88,14 +89,20 @@ func WithdrawPost(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "用户未绑定微信"})
return
}
// 与小程序 referral 一致:须填写资料中的微信号,便于运营到账核对(不再用 openid 顶替)
if user.WechatID == nil || strings.TrimSpace(*user.WechatID) == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"needBindWechat": true,
"message": "请先到设置页绑定微信号后再提现,便于到账核对",
})
return
}
withdrawID := generateWithdrawID()
status := "pending"
// 根据 user_id 已查到的用户信息,填充提现表所需字段;仅写入表中存在的列,避免 remark 等列不存在报错
wechatID := user.WechatID
if (wechatID == nil || *wechatID == "") && user.OpenID != nil && *user.OpenID != "" {
wechatID = user.OpenID
}
withdrawal := model.Withdrawal{
ID: withdrawID,
UserID: req.UserID,

View File

@@ -35,14 +35,12 @@ type Person struct {
AddFriendInterval int `gorm:"column:add_friend_interval;default:1" json:"addFriendInterval"`
StartTime string `gorm:"column:start_time;size:10;default:'09:00'" json:"startTime"`
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"`
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"` // 置顶到小程序首页
// PersonSource 来源:空=后台手工添加vip_sync=超级个体自动同步(共用统一计划)
PersonSource string `gorm:"column:person_source;size:32;default:''" json:"personSource"`
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"` // 置顶到小程序首页
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}

View File

@@ -18,13 +18,15 @@ func Init(url string) error {
if err != nil {
return err
}
client = redis.NewClient(opt)
tmp := redis.NewClient(opt)
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
client = nil // 连接失败时清空避免后续使用超时cache 将自动降级到内存备用
if err := tmp.Ping(ctx).Err(); err != nil {
_ = tmp.Close() // 避免未关闭客户端在后台持续 dial刷屏 pool 重试日志
client = nil
log.Printf("redis: 连接失败,已降级到内存缓存(%v", err)
return err
}
client = tmp
log.Printf("redis: connected to %s", opt.Addr)
return nil
}

View File

@@ -215,7 +215,6 @@ func Setup(cfg *config.Config) *gin.Engine {
db.GET("/link-tags", handler.DBLinkTagList)
db.POST("/link-tags", handler.DBLinkTagSave)
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.PUT("/persons/pin", handler.DBPersonPin)
db.GET("/persons/pinned", handler.DBPersonPinnedList)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/ckb-person-leads", handler.DBCKBPersonLeads)