chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/ - 移除并忽略 .env 与小程序私有配置 - 同步小程序/管理端/API与脚本改动 Made-with: Cursor
This commit is contained in:
@@ -2,7 +2,9 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
@@ -11,9 +13,29 @@ import (
|
||||
|
||||
// AdminChaptersList GET /api/admin/chapters 从 chapters 表组树:part -> chapters -> sections
|
||||
func AdminChaptersList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
|
||||
var list []model.Chapter
|
||||
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"structure": []interface{}{}, "stats": nil}})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"structure": []interface{}{},
|
||||
"records": []interface{}{},
|
||||
"stats": nil,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"totalPages": 0,
|
||||
"total": 0,
|
||||
}})
|
||||
return
|
||||
}
|
||||
type section struct {
|
||||
@@ -25,6 +47,19 @@ func AdminChaptersList(c *gin.Context) {
|
||||
EditionStandard *bool `json:"editionStandard,omitempty"`
|
||||
EditionPremium *bool `json:"editionPremium,omitempty"`
|
||||
}
|
||||
type sectionRecord struct {
|
||||
ID string `json:"id"`
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree bool `json:"isFree"`
|
||||
Status string `json:"status"`
|
||||
EditionStandard *bool `json:"editionStandard,omitempty"`
|
||||
EditionPremium *bool `json:"editionPremium,omitempty"`
|
||||
}
|
||||
type chapter struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -38,6 +73,7 @@ func AdminChaptersList(c *gin.Context) {
|
||||
}
|
||||
partMap := make(map[string]*part)
|
||||
chapterMap := make(map[string]map[string]*chapter)
|
||||
records := make([]sectionRecord, 0, len(list))
|
||||
for _, row := range list {
|
||||
if partMap[row.PartID] == nil {
|
||||
partMap[row.PartID] = &part{ID: row.PartID, Title: row.PartTitle, Type: "part", Chapters: []chapter{}}
|
||||
@@ -66,6 +102,19 @@ func AdminChaptersList(c *gin.Context) {
|
||||
ID: row.ID, Title: row.SectionTitle, Price: price, IsFree: isFree, Status: st,
|
||||
EditionStandard: row.EditionStandard, EditionPremium: row.EditionPremium,
|
||||
})
|
||||
records = append(records, sectionRecord{
|
||||
ID: row.ID,
|
||||
PartID: row.PartID,
|
||||
PartTitle: row.PartTitle,
|
||||
ChapterID: row.ChapterID,
|
||||
ChapterTitle: row.ChapterTitle,
|
||||
Title: row.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: isFree,
|
||||
Status: st,
|
||||
EditionStandard: row.EditionStandard,
|
||||
EditionPremium: row.EditionPremium,
|
||||
})
|
||||
}
|
||||
structure := make([]part, 0, len(partMap))
|
||||
for _, p := range partMap {
|
||||
@@ -73,9 +122,29 @@ func AdminChaptersList(c *gin.Context) {
|
||||
}
|
||||
var total int64
|
||||
database.DB().Model(&model.Chapter{}).Count(&total)
|
||||
totalPages := 0
|
||||
if pageSize > 0 {
|
||||
totalPages = (int(total) + pageSize - 1) / pageSize
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start > len(records) {
|
||||
start = len(records)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(records) {
|
||||
end = len(records)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"structure": structure, "stats": gin.H{"totalSections": total}},
|
||||
"data": gin.H{
|
||||
"structure": structure,
|
||||
"records": records[start:end],
|
||||
"stats": gin.H{"totalSections": total},
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"totalPages": totalPages,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,6 +200,7 @@ func AdminChaptersAction(c *gin.Context) {
|
||||
if body.Action == "delete" {
|
||||
id := resolveID()
|
||||
if id != "" {
|
||||
cache.InvalidateChapterContentByID(id)
|
||||
db.Where("id = ?", id).Delete(&model.Chapter{})
|
||||
}
|
||||
}
|
||||
@@ -156,5 +226,7 @@ func AdminChaptersAction(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -1,115 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"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(10).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
|
||||
db.Model(&model.User{}).Count(&totalUsers)
|
||||
var (
|
||||
totalUsers int64
|
||||
paidOrderCount int64
|
||||
totalRevenue float64
|
||||
paidUserCount int64
|
||||
recentOrders []model.Order
|
||||
newUsers []model.User
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(6)
|
||||
|
||||
var paidOrderCount int64
|
||||
db.Model(&model.Order{}).Where("status IN ?", []string{"paid", "completed", "success"}).Count(&paidOrderCount)
|
||||
|
||||
var paidUserCount int64
|
||||
db.Model(&model.Order{}).Where("status IN ?", []string{"paid", "completed", "success"}).Distinct("user_id").Count(&paidUserCount)
|
||||
|
||||
var totalRevenue float64
|
||||
db.Model(&model.Order{}).Where("status IN ?", []string{"paid", "completed", "success"}).Select("COALESCE(SUM(amount),0)").Scan(&totalRevenue)
|
||||
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
|
||||
}
|
||||
|
||||
var recentOrders []model.Order
|
||||
db.Where("status IN ?", []string{"paid", "completed", "success"}).Order("created_at DESC").Limit(5).Find(&recentOrders)
|
||||
|
||||
userIDs := make(map[string]bool)
|
||||
for _, o := range recentOrders {
|
||||
userIDs[o.UserID] = true
|
||||
if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
userIDs[*o.ReferrerID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var orderUsers []model.User
|
||||
if len(ids) > 0 {
|
||||
db.Where("id IN ?", ids).Find(&orderUsers)
|
||||
}
|
||||
userMap := make(map[string]*model.User)
|
||||
for i := range orderUsers {
|
||||
userMap[orderUsers[i].ID] = &orderUsers[i]
|
||||
}
|
||||
|
||||
recentOut := make([]gin.H, 0, len(recentOrders))
|
||||
for _, o := range recentOrders {
|
||||
u := userMap[o.UserID]
|
||||
nickname := ""
|
||||
phone := ""
|
||||
avatar := ""
|
||||
if u != nil {
|
||||
if u.Nickname != nil {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
if u.Phone != nil {
|
||||
phone = *u.Phone
|
||||
}
|
||||
if u.Avatar != nil {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
}
|
||||
referrerCode := ""
|
||||
if o.ReferralCode != nil {
|
||||
referrerCode = *o.ReferralCode
|
||||
} else if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
if ru := userMap[*o.ReferrerID]; ru != nil && ru.ReferralCode != nil {
|
||||
referrerCode = *ru.ReferralCode
|
||||
}
|
||||
}
|
||||
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": "微信",
|
||||
})
|
||||
}
|
||||
|
||||
var newUsers []model.User
|
||||
db.Order("created_at DESC").Limit(5).Find(&newUsers)
|
||||
newUsersOut := make([]gin.H, 0, len(newUsers))
|
||||
for _, u := range newUsers {
|
||||
nickname := ""
|
||||
if u.Nickname != nil {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
phone := ""
|
||||
if u.Phone != nil {
|
||||
phone = *u.Phone
|
||||
}
|
||||
newUsersOut = append(newUsersOut, gin.H{
|
||||
"id": u.ID, "nickname": nickname, "phone": phone, "referralCode": u.ReferralCode, "createdAt": u.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 匹配统计
|
||||
var totalMatches int64
|
||||
db.Raw("SELECT COUNT(*) FROM match_records").Scan(&totalMatches)
|
||||
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)
|
||||
recentOut := buildRecentOrdersOut(db, recentOrders)
|
||||
newOut := buildNewUsersOut(newUsers)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@@ -119,15 +132,84 @@ func AdminDashboardOverview(c *gin.Context) {
|
||||
"totalRevenue": totalRevenue,
|
||||
"conversionRate": conversionRate,
|
||||
"recentOrders": recentOut,
|
||||
"newUsers": newUsersOut,
|
||||
"totalMatches": totalMatches,
|
||||
"matchRevenue": matchRevenue,
|
||||
"newUsers": newOut,
|
||||
})
|
||||
}
|
||||
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
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
|
||||
}
|
||||
|
||||
// AdminDashboardMerchantBalance GET /api/admin/dashboard/merchant-balance
|
||||
// 查询微信商户号实时余额(可用余额、待结算余额),用于看板展示
|
||||
// 注意:普通商户可能需向微信申请开通权限,未开通时返回 error
|
||||
func AdminDashboardMerchantBalance(c *gin.Context) {
|
||||
bal, err := wechat.QueryMerchantBalance("BASIC")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
"message": "查询商户余额失败,可能未开通权限(请联系微信支付运营申请)",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"availableAmount": bal.AvailableAmount, // 单位:分
|
||||
"pendingAmount": bal.PendingAmount, // 单位:分
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
206
soul-api/internal/handler/admin_linked_mp.go
Normal file
206
soul-api/internal/handler/admin_linked_mp.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const linkedMpConfigKey = "linked_miniprograms"
|
||||
|
||||
// LinkedMpItem 关联小程序项,key 为 32 位密钥,链接标签存 key,小程序端用 key 查 appId
|
||||
type LinkedMpItem struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Sort int `json:"sort"`
|
||||
}
|
||||
|
||||
// AdminLinkedMpList GET /api/admin/linked-miniprograms 管理端-关联小程序列表
|
||||
func AdminLinkedMpList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []LinkedMpItem{}})
|
||||
return
|
||||
}
|
||||
var list []LinkedMpItem
|
||||
if err := json.Unmarshal(row.ConfigValue, &list); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []LinkedMpItem{}})
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []LinkedMpItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// AdminLinkedMpCreate POST /api/admin/linked-miniprograms 管理端-新增关联小程序
|
||||
func AdminLinkedMpCreate(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
AppID string `json:"appId" binding:"required"`
|
||||
Path string `json:"path"`
|
||||
Sort int `json:"sort"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请填写小程序名称和 AppID"})
|
||||
return
|
||||
}
|
||||
body.Name = trimSpace(body.Name)
|
||||
body.AppID = trimSpace(body.AppID)
|
||||
if body.Name == "" || body.AppID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "小程序名称和 AppID 不能为空"})
|
||||
return
|
||||
}
|
||||
key, err := genMpKey()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成密钥失败"})
|
||||
return
|
||||
}
|
||||
item := LinkedMpItem{Key: key, Name: body.Name, AppID: body.AppID, Path: body.Path, Sort: body.Sort}
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
var list []LinkedMpItem
|
||||
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
|
||||
list = []LinkedMpItem{}
|
||||
} else {
|
||||
_ = json.Unmarshal(row.ConfigValue, &list)
|
||||
if list == nil {
|
||||
list = []LinkedMpItem{}
|
||||
}
|
||||
}
|
||||
list = append(list, item)
|
||||
valBytes, _ := json.Marshal(list)
|
||||
desc := "关联小程序列表,用于 wx.navigateToMiniProgram 跳转"
|
||||
if row.ConfigKey == "" {
|
||||
row = model.SystemConfig{ConfigKey: linkedMpConfigKey, ConfigValue: valBytes, Description: &desc}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
row.ConfigValue = valBytes
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
|
||||
}
|
||||
|
||||
// AdminLinkedMpUpdate PUT /api/admin/linked-miniprograms 管理端-编辑关联小程序
|
||||
func AdminLinkedMpUpdate(c *gin.Context) {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
AppID string `json:"appId" binding:"required"`
|
||||
Path string `json:"path"`
|
||||
Sort int `json:"sort"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
body.Name = trimSpace(body.Name)
|
||||
body.AppID = trimSpace(body.AppID)
|
||||
if body.Name == "" || body.AppID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "小程序名称和 AppID 不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到该记录"})
|
||||
return
|
||||
}
|
||||
var list []LinkedMpItem
|
||||
if err := json.Unmarshal(row.ConfigValue, &list); err != nil || list == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "数据格式错误"})
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for i := range list {
|
||||
if list[i].Key == body.Key {
|
||||
list[i].Name = body.Name
|
||||
list[i].AppID = body.AppID
|
||||
list[i].Path = body.Path
|
||||
list[i].Sort = body.Sort
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到该记录"})
|
||||
return
|
||||
}
|
||||
valBytes, _ := json.Marshal(list)
|
||||
row.ConfigValue = valBytes
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// AdminLinkedMpDelete DELETE /api/admin/linked-miniprograms/:id 管理端-删除(:id 实际传 key)
|
||||
func AdminLinkedMpDelete(c *gin.Context) {
|
||||
key := c.Param("id")
|
||||
if key == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少密钥"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", linkedMpConfigKey).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "未找到该记录"})
|
||||
return
|
||||
}
|
||||
var list []LinkedMpItem
|
||||
if err := json.Unmarshal(row.ConfigValue, &list); err != nil || list == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "数据格式错误"})
|
||||
return
|
||||
}
|
||||
newList := make([]LinkedMpItem, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item.Key != key {
|
||||
newList = append(newList, item)
|
||||
}
|
||||
}
|
||||
valBytes, _ := json.Marshal(newList)
|
||||
row.ConfigValue = valBytes
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "删除失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// genMpKey 生成 32 位英文+数字密钥,供链接标签引用
|
||||
func genMpKey() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// base64 编码后取 32 位,去掉 +/= 仅保留字母数字
|
||||
s := base64.URLEncoding.EncodeToString(b)
|
||||
s = strings.ReplaceAll(s, "+", "")
|
||||
s = strings.ReplaceAll(s, "/", "")
|
||||
s = strings.ReplaceAll(s, "=", "")
|
||||
if len(s) >= 32 {
|
||||
return s[:32], nil
|
||||
}
|
||||
return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil
|
||||
}
|
||||
@@ -304,3 +304,70 @@ func DBUsersJourneyStats(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "stats": stats})
|
||||
}
|
||||
|
||||
// journeyUserItem 用户旅程列表项
|
||||
type journeyUserItem struct {
|
||||
ID string `json:"id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Phone string `json:"phone"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// DBUsersJourneyUsers GET /api/db/users/journey-users?stage=xxx&limit=20 — 按阶段查用户
|
||||
func DBUsersJourneyUsers(c *gin.Context) {
|
||||
db := database.DB()
|
||||
stage := strings.TrimSpace(c.Query("stage"))
|
||||
limitStr := c.DefaultQuery("limit", "20")
|
||||
limit := 20
|
||||
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
switch stage {
|
||||
case "register":
|
||||
db.Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "browse":
|
||||
subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "view_chapter").Distinct("user_id")
|
||||
db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "bind_phone":
|
||||
db.Where("phone IS NOT NULL AND phone != ''").Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "first_pay":
|
||||
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
|
||||
Where("status IN ?", []string{"paid", "success", "completed"})).
|
||||
Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "fill_profile":
|
||||
db.Where("mbti IS NOT NULL OR industry IS NOT NULL").Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "match":
|
||||
subq := db.Table("user_tracks").Select("user_id").Where("action = ?", "match").Distinct("user_id")
|
||||
db.Where("id IN (?)", subq).Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "vip":
|
||||
db.Where("is_vip = ?", true).Order("created_at DESC").Limit(limit).Find(&users)
|
||||
case "distribution":
|
||||
db.Where("referral_code IS NOT NULL AND referral_code != ''").Where("COALESCE(earnings, 0) > ?", 0).
|
||||
Order("created_at DESC").Limit(limit).Find(&users)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的 stage 参数"})
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]journeyUserItem, 0, len(users))
|
||||
for _, u := range users {
|
||||
nick, phone := "", ""
|
||||
if u.Nickname != nil {
|
||||
nick = *u.Nickname
|
||||
}
|
||||
if u.Phone != nil {
|
||||
phone = *u.Phone
|
||||
}
|
||||
list = append(list, journeyUserItem{
|
||||
ID: u.ID,
|
||||
Nickname: nick,
|
||||
Phone: phone,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "users": list})
|
||||
}
|
||||
|
||||
102
soul-api/internal/handler/admin_track.go
Normal file
102
soul-api/internal/handler/admin_track.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminTrackStats GET /api/admin/track/stats 管理端-按钮/标签点击统计(按模块+action聚合)
|
||||
func AdminTrackStats(c *gin.Context) {
|
||||
period := c.DefaultQuery("period", "today")
|
||||
db := database.DB()
|
||||
|
||||
now := time.Now()
|
||||
var since time.Time
|
||||
switch period {
|
||||
case "today":
|
||||
since = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
case "week":
|
||||
since = now.AddDate(0, 0, -7)
|
||||
case "month":
|
||||
since = now.AddDate(0, -1, 0)
|
||||
default:
|
||||
since = time.Time{}
|
||||
}
|
||||
|
||||
type rawRow struct {
|
||||
Action string `gorm:"column:action"`
|
||||
Target string `gorm:"column:target"`
|
||||
ExtraData []byte `gorm:"column:extra_data"`
|
||||
}
|
||||
|
||||
query := db.Table("user_tracks").Select("action, COALESCE(target, '') as target, extra_data")
|
||||
if !since.IsZero() {
|
||||
query = query.Where("created_at >= ?", since)
|
||||
}
|
||||
query = query.Where("action NOT LIKE '%union%' AND action NOT LIKE '%jndi%' AND action NOT LIKE '%SLEEP%'")
|
||||
|
||||
var rawRows []rawRow
|
||||
query.Find(&rawRows)
|
||||
|
||||
type statKey struct {
|
||||
Module string
|
||||
Action string
|
||||
Target string
|
||||
}
|
||||
type statItem struct {
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
Module string `json:"module"`
|
||||
Page string `json:"page"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
aggregated := make(map[statKey]*statItem)
|
||||
total := int64(0)
|
||||
|
||||
for _, r := range rawRows {
|
||||
module := "other"
|
||||
page := ""
|
||||
if len(r.ExtraData) > 0 {
|
||||
var extra map[string]interface{}
|
||||
if json.Unmarshal(r.ExtraData, &extra) == nil {
|
||||
if m, ok := extra["module"].(string); ok && m != "" {
|
||||
module = m
|
||||
}
|
||||
if p, ok := extra["page"].(string); ok && p != "" {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
}
|
||||
key := statKey{Module: module, Action: r.Action, Target: r.Target}
|
||||
if existing, ok := aggregated[key]; ok {
|
||||
existing.Count++
|
||||
} else {
|
||||
aggregated[key] = &statItem{
|
||||
Action: r.Action,
|
||||
Target: r.Target,
|
||||
Module: module,
|
||||
Page: page,
|
||||
Count: 1,
|
||||
}
|
||||
}
|
||||
total++
|
||||
}
|
||||
|
||||
byModule := make(map[string][]statItem)
|
||||
for _, item := range aggregated {
|
||||
byModule[item.Module] = append(byModule[item.Module], *item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"period": period,
|
||||
"total": total,
|
||||
"byModule": byModule,
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,21 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MiniprogramUserRules GET /api/miniprogram/user-rules(小程序端,只返回启用的规则)
|
||||
func MiniprogramUserRules(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var rules []model.UserRule
|
||||
if err := db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&rules).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
out = append(out, gin.H{"id": r.ID, "title": r.Title, "description": r.Description, "trigger": r.Trigger, "sort": r.Sort})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": out})
|
||||
}
|
||||
|
||||
// DBUserRulesList GET /api/db/user-rules
|
||||
func DBUserRulesList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
@@ -22,6 +37,17 @@ func DBUserRulesList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
}
|
||||
|
||||
// MiniprogramUserRulesGet GET /api/miniprogram/user-rules 小程序规则引擎:返回启用的规则,无需鉴权
|
||||
func MiniprogramUserRulesGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var rules []model.UserRule
|
||||
if err := db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&rules).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
}
|
||||
|
||||
// DBUserRulesAction POST/PUT/DELETE /api/db/user-rules
|
||||
func DBUserRulesAction(c *gin.Context) {
|
||||
db := database.DB()
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminAppUsersList GET /api/admin/users 普通用户列表兼容入口(转发到 DBUsersList)
|
||||
func AdminAppUsersList(c *gin.Context) {
|
||||
DBUsersList(c)
|
||||
}
|
||||
|
||||
// AdminUsersList GET /api/admin/users 管理员用户列表(仅 super_admin)
|
||||
func AdminUsersList(c *gin.Context) {
|
||||
claims := middleware.GetAdminClaims(c)
|
||||
|
||||
236
soul-api/internal/handler/autolink.go
Normal file
236
soul-api/internal/handler/autolink.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// sanitizeNameOrLabel 去除括号及内容,如 南风(管理) -> 南风
|
||||
func sanitizeNameOrLabel(s string) string {
|
||||
re := regexp.MustCompile(`\s*[((][^))]*(\)|))?`)
|
||||
return strings.TrimSpace(re.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
// isValidNameOrLabel 仅允许包含至少一个汉字/字母/数字,纯符号(如 "、,、!)则跳过,不创建
|
||||
func isValidNameOrLabel(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// 至少包含一个 Unicode 字母或数字
|
||||
re := regexp.MustCompile(`[\p{L}\p{N}]`)
|
||||
return re.MatchString(s)
|
||||
}
|
||||
|
||||
// ensurePersonByName 按名称确保 Person 存在,不存在则创建(含存客宝计划),返回 token
|
||||
func ensurePersonByName(db *gorm.DB, name string) (token string, err error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", nil
|
||||
}
|
||||
clean := sanitizeNameOrLabel(name)
|
||||
if clean == "" || !isValidNameOrLabel(clean) {
|
||||
return "", nil
|
||||
}
|
||||
var p model.Person
|
||||
if db.Where("name = ?", clean).First(&p).Error == nil {
|
||||
return p.Token, nil
|
||||
}
|
||||
// 按净化后的名字再查一次
|
||||
if db.Where("name = ?", name).First(&p).Error == nil {
|
||||
return p.Token, nil
|
||||
}
|
||||
created, err := createPersonMinimal(db, clean)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return created.Token, nil
|
||||
}
|
||||
|
||||
// getPersonNameByToken 按 token 查 Person 返回 name,用于修复已损坏的 mention(显示 token 而非名字)
|
||||
func getPersonNameByToken(db *gorm.DB, token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
var p model.Person
|
||||
if db.Select("name").Where("token = ?", token).First(&p).Error != nil {
|
||||
return ""
|
||||
}
|
||||
return p.Name
|
||||
}
|
||||
|
||||
// ensureLinkTagByLabel 按 label 确保 LinkTag 存在,不存在则创建
|
||||
func ensureLinkTagByLabel(db *gorm.DB, label string) (tagID string, err error) {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" {
|
||||
return "", nil
|
||||
}
|
||||
clean := sanitizeNameOrLabel(label)
|
||||
if clean == "" || !isValidNameOrLabel(clean) {
|
||||
return "", nil
|
||||
}
|
||||
var t model.LinkTag
|
||||
if db.Where("label = ?", clean).First(&t).Error == nil {
|
||||
return t.TagID, nil
|
||||
}
|
||||
if db.Where("label = ?", label).First(&t).Error == nil {
|
||||
return t.TagID, nil
|
||||
}
|
||||
t = model.LinkTag{TagID: clean, Label: clean, Type: "url"}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return t.TagID, nil
|
||||
}
|
||||
|
||||
// ParseAutoLinkContent 解析 content 中的 @人物 和 #标签,确保存在并转为带 data-id 的 span,后端统一处理
|
||||
func ParseAutoLinkContent(content string) (string, error) {
|
||||
if content == "" || (!strings.Contains(content, "@") && !strings.Contains(content, "#")) {
|
||||
return content, nil
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 1. 提取所有 @name 和 #label(排除 <> 避免匹配到 HTML 标签内)
|
||||
mentionRe := regexp.MustCompile(`@([^\s@#<>]+)`)
|
||||
tagRe := regexp.MustCompile(`#([^\s@#<>]+)`)
|
||||
names := make(map[string]string) // cleanName -> token
|
||||
labels := make(map[string]string) // cleanLabel -> tagId
|
||||
|
||||
for _, m := range mentionRe.FindAllStringSubmatch(content, -1) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
raw := m[1]
|
||||
clean := sanitizeNameOrLabel(raw)
|
||||
if clean == "" || !isValidNameOrLabel(clean) || names[clean] != "" {
|
||||
continue
|
||||
}
|
||||
token, err := ensurePersonByName(db, clean)
|
||||
if err == nil && token != "" {
|
||||
names[clean] = token
|
||||
names[raw] = token // 原始名也映射
|
||||
}
|
||||
}
|
||||
for _, m := range tagRe.FindAllStringSubmatch(content, -1) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
raw := m[1]
|
||||
clean := sanitizeNameOrLabel(raw)
|
||||
if clean == "" || !isValidNameOrLabel(clean) || labels[clean] != "" {
|
||||
continue
|
||||
}
|
||||
tagID, err := ensureLinkTagByLabel(db, clean)
|
||||
if err == nil && tagID != "" {
|
||||
labels[clean] = tagID
|
||||
labels[raw] = tagID
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 占位符替换:先把已有 mention/linkTag span 保护起来
|
||||
mentionSpanRe := regexp.MustCompile(`<span[^>]*data-type="mention"[^>]*>.*?</span>`)
|
||||
linkTagSpanRe := regexp.MustCompile(`<span[^>]*data-type="linkTag"[^>]*>.*?</span>`)
|
||||
placeholders := []string{}
|
||||
|
||||
content = mentionSpanRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||
// 回填 data-id、data-label:TipTap 用 data-label 显示名字,缺则显示 token
|
||||
idRe := regexp.MustCompile(`data-id="([^"]*)"`)
|
||||
labelRe := regexp.MustCompile(`data-label="([^"]*)"`)
|
||||
innerRe := regexp.MustCompile(`>([^<]+)<`)
|
||||
nickname := ""
|
||||
if labelRe.MatchString(m) {
|
||||
sub := labelRe.FindStringSubmatch(m)
|
||||
if len(sub) >= 2 && strings.TrimSpace(sub[1]) != "" {
|
||||
nickname = sanitizeNameOrLabel(sub[1])
|
||||
}
|
||||
}
|
||||
if nickname == "" && innerRe.MatchString(m) {
|
||||
sub := innerRe.FindStringSubmatch(m)
|
||||
if len(sub) >= 2 {
|
||||
nickname = sanitizeNameOrLabel(strings.TrimPrefix(sub[1], "@"))
|
||||
}
|
||||
}
|
||||
// 若 inner 是 token(如已损坏显示 @pZefXfWlon...),用 token 查 Person 取真实名字
|
||||
if idRe.MatchString(m) {
|
||||
sub := idRe.FindStringSubmatch(m)
|
||||
if len(sub) >= 2 && strings.TrimSpace(sub[1]) != "" {
|
||||
tokenVal := sub[1]
|
||||
innerSub := innerRe.FindStringSubmatch(m)
|
||||
innerText := ""
|
||||
if len(innerSub) >= 2 {
|
||||
innerText = strings.TrimPrefix(innerSub[1], "@")
|
||||
}
|
||||
// 若 inner 看起来像 token(长串字母数字)且与 data-id 一致,用 DB 查真实名字
|
||||
if innerText == tokenVal && len(tokenVal) >= 20 {
|
||||
if realName := getPersonNameByToken(db, tokenVal); realName != "" {
|
||||
nickname = realName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if nickname != "" {
|
||||
if token, ok := names[nickname]; ok && token != "" {
|
||||
if idRe.MatchString(m) {
|
||||
m = idRe.ReplaceAllString(m, `data-id="`+token+`"`)
|
||||
} else {
|
||||
m = strings.Replace(m, `data-type="mention"`, `data-type="mention" data-id="`+token+`"`, 1)
|
||||
}
|
||||
}
|
||||
// 确保有 data-label,否则 TipTap 会显示 token 而非名字
|
||||
needLabel := !labelRe.MatchString(m)
|
||||
if !needLabel {
|
||||
if sub := labelRe.FindStringSubmatch(m); len(sub) >= 2 && strings.TrimSpace(sub[1]) == "" {
|
||||
needLabel = true
|
||||
}
|
||||
}
|
||||
if needLabel {
|
||||
m = strings.Replace(m, `data-type="mention"`, `data-type="mention" data-label="`+nickname+`"`, 1)
|
||||
}
|
||||
}
|
||||
placeholders = append(placeholders, m)
|
||||
return "\x00PLACEHOLDER\x00"
|
||||
})
|
||||
|
||||
content = linkTagSpanRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||
placeholders = append(placeholders, m)
|
||||
return "\x00PLACEHOLDER\x00"
|
||||
})
|
||||
|
||||
// 3. 替换纯文本 @name 和 #label
|
||||
content = mentionRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||
raw := strings.TrimPrefix(m, "@")
|
||||
clean := sanitizeNameOrLabel(raw)
|
||||
token := names[clean]
|
||||
if token == "" {
|
||||
token = names[raw]
|
||||
}
|
||||
if token == "" {
|
||||
return m
|
||||
}
|
||||
return `<span data-type="mention" data-id="` + token + `" data-label="` + raw + `" class="mention-tag">@` + raw + `</span>`
|
||||
})
|
||||
|
||||
content = tagRe.ReplaceAllStringFunc(content, func(m string) string {
|
||||
raw := strings.TrimPrefix(m, "#")
|
||||
clean := sanitizeNameOrLabel(raw)
|
||||
tagID := labels[clean]
|
||||
if tagID == "" {
|
||||
tagID = labels[raw]
|
||||
}
|
||||
if tagID == "" {
|
||||
return m
|
||||
}
|
||||
return `<span data-type="linkTag" data-url="" data-tag-type="url" data-tag-id="` + tagID + `" data-page-path="" data-app-id="" class="link-tag-node">#` + raw + `</span>`
|
||||
})
|
||||
|
||||
// 4. 恢复占位符
|
||||
for _, p := range placeholders {
|
||||
content = strings.Replace(content, "\x00PLACEHOLDER\x00", p, 1)
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
404
soul-api/internal/handler/balance.go
Normal file
404
soul-api/internal/handler/balance.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BalanceGet GET /api/miniprogram/balance?userId=
|
||||
func BalanceGet(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var ub model.UserBalance
|
||||
if err := db.Where("user_id = ?", userID).First(&ub).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": 0}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": ub.Balance}})
|
||||
}
|
||||
|
||||
// BalanceTransactionsGet GET /api/miniprogram/balance/transactions?userId=&page=&pageSize=
|
||||
func BalanceTransactionsGet(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
db := database.DB()
|
||||
var total int64
|
||||
db.Model(&model.BalanceTransaction{}).Where("user_id = ?", userID).Count(&total)
|
||||
var list []model.BalanceTransaction
|
||||
if err := db.Where("user_id = ?", userID).Order("created_at DESC").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "data": []interface{}{}, "total": 0})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, t := range list {
|
||||
orderID := ""
|
||||
if t.RelatedOrder != nil {
|
||||
orderID = *t.RelatedOrder
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": t.ID, "type": t.Type, "amount": t.Amount,
|
||||
"orderId": orderID, "createdAt": t.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": total})
|
||||
}
|
||||
|
||||
// BalanceRechargePost POST /api/miniprogram/balance/recharge
|
||||
func BalanceRechargePost(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required,gte=0.01"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
db := database.DB()
|
||||
desc := fmt.Sprintf("余额充值 ¥%.2f", req.Amount)
|
||||
status := "created"
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: req.UserID,
|
||||
ProductType: "balance_recharge",
|
||||
ProductID: &orderSn,
|
||||
Amount: req.Amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"orderSn": orderSn}})
|
||||
}
|
||||
|
||||
// BalanceRechargeConfirmPost POST /api/miniprogram/balance/recharge/confirm
|
||||
func BalanceRechargeConfirmPost(c *gin.Context) {
|
||||
var req struct {
|
||||
OrderSn string `json:"orderSn" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 orderSn"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.Order
|
||||
if err := tx.Where("order_sn = ? AND product_type = ?", req.OrderSn, "balance_recharge").First(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
status := ""
|
||||
if order.Status != nil {
|
||||
status = *order.Status
|
||||
}
|
||||
if status != "paid" {
|
||||
return fmt.Errorf("订单未支付")
|
||||
}
|
||||
// 幂等:检查是否已处理
|
||||
var cnt int64
|
||||
tx.Model(&model.BalanceTransaction{}).Where("related_order = ? AND type = ?", req.OrderSn, "recharge").Count(&cnt)
|
||||
if cnt > 0 {
|
||||
return nil
|
||||
}
|
||||
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = balance + ?, updated_at = NOW()", order.UserID, order.Amount)
|
||||
tx.Create(&model.BalanceTransaction{
|
||||
UserID: order.UserID, Type: "recharge", Amount: order.Amount,
|
||||
RelatedOrder: &req.OrderSn, CreatedAt: time.Now(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// BalanceConsumePost POST /api/miniprogram/balance/consume
|
||||
func BalanceConsumePost(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ProductType string `json:"productType" binding:"required"`
|
||||
ProductID string `json:"productId"`
|
||||
Amount float64 `json:"amount" binding:"required,gte=0.01"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
// 后端价格校验
|
||||
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
if req.Amount < standardPrice-0.01 || req.Amount > standardPrice+0.01 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "金额校验失败"})
|
||||
return
|
||||
}
|
||||
amount := standardPrice
|
||||
|
||||
// 解析推荐人
|
||||
var referrerID *string
|
||||
if req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
}
|
||||
}
|
||||
if referrerID == nil {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
_ = db.Raw("SELECT referrer_id FROM referral_bindings WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW() ORDER BY binding_date DESC LIMIT 1", req.UserID).Scan(&binding).Error
|
||||
if binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
}
|
||||
}
|
||||
|
||||
productID := req.ProductID
|
||||
if productID == "" {
|
||||
switch req.ProductType {
|
||||
case "vip":
|
||||
productID = "vip_annual"
|
||||
case "fullbook":
|
||||
productID = "fullbook"
|
||||
default:
|
||||
productID = req.ProductID
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var ub model.UserBalance
|
||||
if err := tx.Where("user_id = ?", req.UserID).First(&ub).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("余额不足")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ub.Balance < amount {
|
||||
return fmt.Errorf("余额不足")
|
||||
}
|
||||
tx.Model(&model.UserBalance{}).Where("user_id = ?", req.UserID).Update("balance", gorm.Expr("balance - ?", amount))
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
desc := ""
|
||||
switch req.ProductType {
|
||||
case "section":
|
||||
desc = "章节购买-" + productID
|
||||
case "fullbook":
|
||||
desc = "《一场Soul的创业实验》全书"
|
||||
case "vip":
|
||||
desc = "卡若创业派对VIP年度会员(365天)"
|
||||
default:
|
||||
desc = "余额消费"
|
||||
}
|
||||
pm := "balance"
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: req.UserID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
ReferrerID: referrerID,
|
||||
PayTime: &now,
|
||||
}
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
tx.Create(&model.BalanceTransaction{
|
||||
UserID: req.UserID, Type: "consume", Amount: -amount,
|
||||
RelatedOrder: &orderSn, CreatedAt: now,
|
||||
})
|
||||
// 激活权益
|
||||
if req.ProductType == "fullbook" {
|
||||
tx.Model(&model.User{}).Where("id = ?", req.UserID).Update("has_full_book", true)
|
||||
} else if req.ProductType == "vip" {
|
||||
activateVIP(tx, req.UserID, 365, now)
|
||||
}
|
||||
// 分佣
|
||||
if referrerID != nil {
|
||||
processReferralCommission(tx, req.UserID, amount, orderSn, &order)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if err.Error() == "余额不足" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "余额不足"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// BalanceRefundPost POST /api/miniprogram/balance/refund
|
||||
func BalanceRefundPost(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required,gte=0.01"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
// 首版简化:暂不实现微信原路退,仅扣减余额并记录
|
||||
db := database.DB()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var ub model.UserBalance
|
||||
if err := tx.Where("user_id = ?", req.UserID).First(&ub).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("余额为零")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ub.Balance < req.Amount {
|
||||
return fmt.Errorf("余额不足")
|
||||
}
|
||||
tx.Model(&model.UserBalance{}).Where("user_id = ?", req.UserID).Update("balance", gorm.Expr("balance - ?", req.Amount))
|
||||
tx.Create(&model.BalanceTransaction{
|
||||
UserID: req.UserID, Type: "refund", Amount: -req.Amount,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "退款申请已提交,1-3个工作日内原路返回"}})
|
||||
}
|
||||
|
||||
// AdminUserBalanceGet GET /api/admin/users/:id/balance 管理端-用户余额与最近交易
|
||||
func AdminUserBalanceGet(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少用户ID"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var ub model.UserBalance
|
||||
balance := 0.0
|
||||
if err := db.Where("user_id = ?", userID).First(&ub).Error; err == nil {
|
||||
balance = ub.Balance
|
||||
}
|
||||
var list []model.BalanceTransaction
|
||||
db.Where("user_id = ?", userID).Order("created_at DESC").Limit(20).Find(&list)
|
||||
transactions := make([]gin.H, 0, len(list))
|
||||
for _, t := range list {
|
||||
orderID := ""
|
||||
if t.RelatedOrder != nil {
|
||||
orderID = *t.RelatedOrder
|
||||
}
|
||||
transactions = append(transactions, gin.H{
|
||||
"id": t.ID, "type": t.Type, "amount": t.Amount,
|
||||
"orderId": orderID, "createdAt": t.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"balance": balance, "transactions": transactions}})
|
||||
}
|
||||
|
||||
// AdminUserBalanceAdjust POST /api/admin/users/:id/balance/adjust 管理端-人工调整用户余额
|
||||
func AdminUserBalanceAdjust(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少用户ID"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Amount float64 `json:"amount" binding:"required"` // 正数增加,负数扣减
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数无效"})
|
||||
return
|
||||
}
|
||||
if req.Amount == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "调整金额不能为 0"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var ub model.UserBalance
|
||||
if err := tx.Where("user_id = ?", userID).First(&ub).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ub = model.UserBalance{UserID: userID, Balance: 0}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
newBalance := ub.Balance + req.Amount
|
||||
if newBalance < 0 {
|
||||
return fmt.Errorf("调整后余额不能为负,当前余额 %.2f", ub.Balance)
|
||||
}
|
||||
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = ?, updated_at = NOW()", userID, newBalance)
|
||||
return tx.Create(&model.BalanceTransaction{
|
||||
UserID: userID, Type: "admin_adjust", Amount: req.Amount,
|
||||
CreatedAt: time.Now(),
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "余额已调整"})
|
||||
}
|
||||
|
||||
// ConfirmBalanceRechargeByOrder 支付成功后确认充值(幂等),供 PayNotify 和 activateOrderBenefits 调用
|
||||
func ConfirmBalanceRechargeByOrder(db *gorm.DB, order *model.Order) error {
|
||||
if order == nil || order.ProductType != "balance_recharge" {
|
||||
return nil
|
||||
}
|
||||
orderSn := order.OrderSN
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var cnt int64
|
||||
tx.Model(&model.BalanceTransaction{}).Where("order_id = ? AND type = ?", orderSn, "recharge").Count(&cnt)
|
||||
if cnt > 0 {
|
||||
return nil // 已处理,幂等
|
||||
}
|
||||
tx.Exec("INSERT INTO user_balances (user_id, balance, updated_at) VALUES (?, 0, NOW()) ON DUPLICATE KEY UPDATE balance = balance + ?, updated_at = NOW()", order.UserID, order.Amount)
|
||||
return tx.Create(&model.BalanceTransaction{
|
||||
UserID: order.UserID, Type: "recharge", Amount: order.Amount,
|
||||
RelatedOrder: &orderSn, CreatedAt: time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
@@ -15,15 +22,207 @@ import (
|
||||
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
|
||||
var excludeParts = []string{"序言", "尾声", "附录"}
|
||||
|
||||
// sortChaptersByNaturalID 同 sort_order 时按 id 自然排序(9.1 < 9.2 < 9.10),调用 db_book 的 naturalLessSectionID
|
||||
func sortChaptersByNaturalID(list []model.Chapter) {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
soI, soJ := 999999, 999999
|
||||
if list[i].SortOrder != nil {
|
||||
soI = *list[i].SortOrder
|
||||
}
|
||||
if list[j].SortOrder != nil {
|
||||
soJ = *list[j].SortOrder
|
||||
}
|
||||
if soI != soJ {
|
||||
return soI < soJ
|
||||
}
|
||||
return naturalLessSectionID(list[i].ID, list[j].ID)
|
||||
})
|
||||
}
|
||||
|
||||
// allChaptersSelectCols 列表不加载 content(longtext),避免 502 超时
|
||||
var allChaptersSelectCols = []string{
|
||||
"mid", "id", "part_id", "part_title", "chapter_id", "chapter_title",
|
||||
"section_title", "word_count", "is_free", "price", "sort_order", "status",
|
||||
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// chapterMetaCols 章节详情元数据(不含 content),用于 content 缓存命中时的轻量查询
|
||||
var chapterMetaCols = []string{
|
||||
"mid", "id", "part_id", "part_title", "chapter_id", "chapter_title",
|
||||
"section_title", "word_count", "is_free", "price", "sort_order", "status",
|
||||
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// allChaptersCache 内存缓存,减轻 DB 压力,30 秒 TTL
|
||||
var allChaptersCache struct {
|
||||
mu sync.RWMutex
|
||||
data []model.Chapter
|
||||
expires time.Time
|
||||
key string // excludeFixed 不同则 key 不同
|
||||
}
|
||||
|
||||
const allChaptersCacheTTL = 30 * time.Second
|
||||
|
||||
// bookPartsCache 目录接口内存缓存,30 秒 TTL,减轻 DB 压力
|
||||
type cachedPartRow struct {
|
||||
PartID string `json:"id"`
|
||||
PartTitle string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
ChapterCount int `json:"chapterCount"`
|
||||
MinSortOrder int `json:"minSortOrder"`
|
||||
}
|
||||
type cachedFixedItem struct {
|
||||
ID string `json:"id"`
|
||||
MID int `json:"mid"`
|
||||
SectionTitle string `json:"title"`
|
||||
}
|
||||
|
||||
// bookPartsRedisPayload Redis 缓存结构,与 BookParts 响应一致
|
||||
type bookPartsRedisPayload struct {
|
||||
Parts []cachedPartRow `json:"parts"`
|
||||
TotalSections int64 `json:"totalSections"`
|
||||
FixedSections []cachedFixedItem `json:"fixedSections"`
|
||||
}
|
||||
|
||||
var bookPartsCache struct {
|
||||
mu sync.RWMutex
|
||||
parts []cachedPartRow
|
||||
total int64
|
||||
fixed []cachedFixedItem
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
const bookPartsCacheTTL = 30 * time.Second
|
||||
|
||||
// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502
|
||||
func WarmAllChaptersCache() {
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
var list []model.Chapter
|
||||
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
|
||||
return
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
allChaptersCache.key = "default"
|
||||
allChaptersCache.mu.Unlock()
|
||||
}
|
||||
|
||||
// fetchAndCacheBookParts 执行 DB 查询并更新缓存,供 BookParts 与 WarmBookPartsCache 复用
|
||||
func fetchAndCacheBookParts() (parts []cachedPartRow, total int64, fixed []cachedFixedItem) {
|
||||
db := database.DB()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conds := make([]string, len(excludeParts))
|
||||
args := make([]interface{}, len(excludeParts))
|
||||
for i, p := range excludeParts {
|
||||
conds[i] = "part_title LIKE ?"
|
||||
args[i] = "%" + p + "%"
|
||||
}
|
||||
where := "(" + strings.Join(conds, " OR ") + ")"
|
||||
var rows []model.Chapter
|
||||
if err := db.Model(&model.Chapter{}).Select("id", "mid", "section_title", "sort_order").
|
||||
Where(where, args...).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
Find(&rows).Error; err == nil {
|
||||
sortChaptersByNaturalID(rows)
|
||||
for _, r := range rows {
|
||||
fixed = append(fixed, cachedFixedItem{r.ID, r.MID, r.SectionTitle})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
where := "1=1"
|
||||
args := []interface{}{}
|
||||
for _, p := range excludeParts {
|
||||
where += " AND part_title NOT LIKE ?"
|
||||
args = append(args, "%"+p+"%")
|
||||
}
|
||||
sql := `SELECT part_id, part_title, '' as subtitle,
|
||||
COUNT(DISTINCT chapter_id) as chapter_count,
|
||||
MIN(COALESCE(sort_order, 999999)) as min_sort
|
||||
FROM chapters WHERE ` + where + `
|
||||
GROUP BY part_id, part_title ORDER BY min_sort ASC, part_id ASC`
|
||||
var raw []struct {
|
||||
PartID string `gorm:"column:part_id"`
|
||||
PartTitle string `gorm:"column:part_title"`
|
||||
Subtitle string `gorm:"column:subtitle"`
|
||||
ChapterCount int `gorm:"column:chapter_count"`
|
||||
MinSortOrder int `gorm:"column:min_sort"`
|
||||
}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Raw(sql, args...).Scan(&raw)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
db.Model(&model.Chapter{}).Count(&total)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
parts = make([]cachedPartRow, len(raw))
|
||||
for i, r := range raw {
|
||||
parts[i] = cachedPartRow{
|
||||
PartID: r.PartID, PartTitle: r.PartTitle, Subtitle: r.Subtitle,
|
||||
ChapterCount: r.ChapterCount, MinSortOrder: r.MinSortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
bookPartsCache.mu.Lock()
|
||||
bookPartsCache.parts = parts
|
||||
bookPartsCache.total = total
|
||||
bookPartsCache.fixed = fixed
|
||||
bookPartsCache.expires = time.Now().Add(bookPartsCacheTTL)
|
||||
bookPartsCache.mu.Unlock()
|
||||
|
||||
return parts, total, fixed
|
||||
}
|
||||
|
||||
// WarmBookPartsCache 启动时预热目录缓存(内存+Redis),避免首请求慢
|
||||
func WarmBookPartsCache() {
|
||||
parts, total, fixed := fetchAndCacheBookParts()
|
||||
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
|
||||
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
|
||||
}
|
||||
|
||||
// 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 的旧数据,避免错位
|
||||
// 免费判断:system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
|
||||
// 支持 excludeFixed=1:排除序言、尾声、附录(目录页固定模块,不参与中间篇章)
|
||||
// 不过滤 status,后台配置的篇章均返回,由前端展示。
|
||||
// 带 30 秒内存缓存,管理端更新后最多 30 秒生效
|
||||
func BookAllChapters(c *gin.Context) {
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
cacheKey := "default"
|
||||
if c.Query("excludeFixed") == "1" {
|
||||
cacheKey = "excludeFixed"
|
||||
}
|
||||
allChaptersCache.mu.RLock()
|
||||
if allChaptersCache.key == cacheKey && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
|
||||
data := allChaptersCache.data
|
||||
allChaptersCache.mu.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
||||
return
|
||||
}
|
||||
allChaptersCache.mu.RUnlock()
|
||||
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
if cacheKey == "excludeFixed" {
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
@@ -33,7 +232,24 @@ 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, "total": len(list)})
|
||||
sortChaptersByNaturalID(list)
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
|
||||
allChaptersCache.mu.Lock()
|
||||
allChaptersCache.data = list
|
||||
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
|
||||
allChaptersCache.key = cacheKey
|
||||
allChaptersCache.mu.Unlock()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookChapterByID GET /api/book/chapter/:id 按业务 id 查询(兼容旧链接)
|
||||
@@ -48,6 +264,81 @@ func BookChapterByID(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情
|
||||
// 返回 parts(排除序言/尾声/附录)、totalSections、fixedSections(id, mid, title 供序言/尾声/附录跳转用 mid)
|
||||
// 缓存优先级:Redis(10min,后台更新时失效)> 内存(30s)> DB;Redis 不可用时回退内存+DB
|
||||
func BookParts(c *gin.Context) {
|
||||
// 1. 优先 Redis(后台无更新时长期有效)
|
||||
var redisPayload bookPartsRedisPayload
|
||||
if cache.Get(context.Background(), cache.KeyBookParts, &redisPayload) && len(redisPayload.Parts) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": redisPayload.Parts,
|
||||
"totalSections": redisPayload.TotalSections,
|
||||
"fixedSections": redisPayload.FixedSections,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 内存缓存(30s,Redis 不可用时的容灾)
|
||||
bookPartsCache.mu.RLock()
|
||||
if time.Now().Before(bookPartsCache.expires) {
|
||||
parts := bookPartsCache.parts
|
||||
total := bookPartsCache.total
|
||||
fixed := bookPartsCache.fixed
|
||||
bookPartsCache.mu.RUnlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": parts,
|
||||
"totalSections": total,
|
||||
"fixedSections": fixed,
|
||||
})
|
||||
return
|
||||
}
|
||||
bookPartsCache.mu.RUnlock()
|
||||
|
||||
// 3. DB 查询并更新 Redis + 内存
|
||||
parts, total, fixed := fetchAndCacheBookParts()
|
||||
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
|
||||
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"parts": parts,
|
||||
"totalSections": total,
|
||||
"fixedSections": fixed,
|
||||
})
|
||||
}
|
||||
|
||||
// BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid,供阅读页 by-mid 请求)
|
||||
func BookChaptersByPart(c *gin.Context) {
|
||||
partId := c.Query("partId")
|
||||
if partId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 partId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var list []model.Chapter
|
||||
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
|
||||
Where("part_id = ?", partId).
|
||||
Order("COALESCE(sort_order, 999999) ASC, id ASC").
|
||||
Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐)
|
||||
func BookChapterByMID(c *gin.Context) {
|
||||
midStr := c.Param("mid")
|
||||
@@ -65,11 +356,144 @@ func BookChapterByMID(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// getFreeChapterIDs 从 system_config 读取免费章节 ID 列表(free_chapters 或 chapter_config.freeChapters)
|
||||
func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
for _, key := range []string{"free_chapters", "chapter_config"} {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", key).First(&row).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
var val interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
|
||||
continue
|
||||
}
|
||||
if key == "free_chapters" {
|
||||
if arr, ok := val.([]interface{}); ok {
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
ids[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if key == "chapter_config" {
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
if arr, ok := m["freeChapters"].([]interface{}); ok {
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
ids[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// getUnpaidPreviewPercent 从 system_config 读取 unpaid_preview_percent,默认 20
|
||||
func getUnpaidPreviewPercent(db *gorm.DB) int {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "unpaid_preview_percent").First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
|
||||
return 20
|
||||
}
|
||||
var val interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
|
||||
return 20
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
p := int(v)
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if p > 100 {
|
||||
p = 100
|
||||
}
|
||||
return p
|
||||
case int:
|
||||
if v < 1 {
|
||||
return 1
|
||||
}
|
||||
if v > 100 {
|
||||
return 100
|
||||
}
|
||||
return v
|
||||
}
|
||||
return 20
|
||||
}
|
||||
|
||||
// previewContent 取内容的前 percent%(不少于 100 字,上限 500 字),并追加省略提示
|
||||
func previewContent(content string, percent int) string {
|
||||
total := utf8.RuneCountInString(content)
|
||||
if total == 0 {
|
||||
return ""
|
||||
}
|
||||
if percent < 1 {
|
||||
percent = 1
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
limit := total * percent / 100
|
||||
if limit < 100 {
|
||||
limit = 100
|
||||
}
|
||||
const maxPreview = 500
|
||||
if limit > maxPreview {
|
||||
limit = maxPreview
|
||||
}
|
||||
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
|
||||
// content 缓存:优先 Redis,命中时仅查元数据(不含 LONGTEXT),未命中时查全量并回填缓存
|
||||
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
var ch model.Chapter
|
||||
db := database.DB()
|
||||
if err := whereFn(db).First(&ch).Error; err != nil {
|
||||
// 1. 先查元数据(不含 content,轻量)
|
||||
if err := whereFn(db).Select(chapterMetaCols).First(&ch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
|
||||
return
|
||||
@@ -77,25 +501,59 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 取 content:优先 Redis,未命中再查 DB
|
||||
if content, ok := cache.GetString(context.Background(), cache.KeyChapterContent(ch.MID)); ok && content != "" {
|
||||
ch.Content = content
|
||||
} else {
|
||||
if err := db.Model(&model.Chapter{}).Where("mid = ?", ch.MID).Pluck("content", &ch.Content).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.SetString(context.Background(), cache.KeyChapterContent(ch.MID), ch.Content, cache.ChapterContentTTL)
|
||||
}
|
||||
|
||||
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 {
|
||||
percent := getUnpaidPreviewPercent(db)
|
||||
returnContent = previewContent(ch.Content, percent)
|
||||
}
|
||||
|
||||
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
|
||||
chForResponse := ch
|
||||
chForResponse.Content = returnContent
|
||||
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"data": ch,
|
||||
"content": ch.Content,
|
||||
"data": chForResponse,
|
||||
"content": returnContent,
|
||||
"chapterTitle": ch.ChapterTitle,
|
||||
"partTitle": ch.PartTitle,
|
||||
"id": ch.ID,
|
||||
"mid": ch.MID,
|
||||
"sectionTitle": ch.SectionTitle,
|
||||
"isFree": isFree,
|
||||
}
|
||||
if ch.IsFree != nil {
|
||||
out["isFree"] = *ch.IsFree
|
||||
}
|
||||
if ch.Price != nil {
|
||||
if isFreeFromConfig {
|
||||
out["price"] = float64(0)
|
||||
} else if ch.Price != nil {
|
||||
out["price"] = *ch.Price
|
||||
// 价格为 0 元则自动视为免费
|
||||
if *ch.Price == 0 {
|
||||
out["isFree"] = true
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -195,7 +653,7 @@ func BookChapters(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
|
||||
}
|
||||
|
||||
// bookHotChaptersSorted 按阅读量优先排序(兼容旧逻辑);排除序言/尾声/附录
|
||||
// bookHotChaptersSorted 按精选推荐算法排序:阅读量优先,同量按更新时间;排除序言/尾声/附录
|
||||
func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
|
||||
q := db.Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
@@ -205,6 +663,8 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&all).Error; err != nil || len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
sortChaptersByNaturalID(all)
|
||||
// 从 reading_progress 统计阅读量
|
||||
ids := make([]string, 0, len(all))
|
||||
for _, c := range all {
|
||||
ids = append(ids, c.ID)
|
||||
@@ -219,9 +679,10 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
|
||||
for _, r := range counts {
|
||||
countMap[r.SectionID] = r.Cnt
|
||||
}
|
||||
// 按阅读量降序、同量按 updated_at 降序
|
||||
type withSort struct {
|
||||
ch model.Chapter
|
||||
cnt int64
|
||||
ch model.Chapter
|
||||
cnt int64
|
||||
}
|
||||
withCnt := make([]withSort, 0, len(all))
|
||||
for _, c := range all {
|
||||
@@ -242,162 +703,102 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
|
||||
return out
|
||||
}
|
||||
|
||||
// bookRecommendedByScore 文章推荐算法:阅读量前20(50%) + 最近30篇(30%) + 付款数前20(20%),排除序言/尾声/附录
|
||||
func bookRecommendedByScore(db *gorm.DB, limit int) []model.Chapter {
|
||||
q := db.Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
var all []model.Chapter
|
||||
if err := q.Find(&all).Error; err != nil || len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(all))
|
||||
for _, c := range all {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
// 1. 阅读量:reading_progress 按 section_id 计数,前20名得 20,19,...,1 分
|
||||
var readCounts []struct {
|
||||
SectionID string `gorm:"column:section_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
|
||||
Where("section_id IN ?", ids).Group("section_id").Scan(&readCounts)
|
||||
readMap := make(map[string]int64)
|
||||
for _, r := range readCounts {
|
||||
readMap[r.SectionID] = r.Cnt
|
||||
}
|
||||
type idCnt struct {
|
||||
id string
|
||||
cnt int64
|
||||
}
|
||||
readSorted := make([]idCnt, 0, len(all))
|
||||
for _, c := range all {
|
||||
readSorted = append(readSorted, idCnt{c.ID, readMap[c.ID]})
|
||||
}
|
||||
for i := 0; i < len(readSorted)-1; i++ {
|
||||
for j := i + 1; j < len(readSorted); j++ {
|
||||
if readSorted[j].cnt > readSorted[i].cnt {
|
||||
readSorted[i], readSorted[j] = readSorted[j], readSorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
readScore := make(map[string]float64)
|
||||
for i := 0; i < 20 && i < len(readSorted); i++ {
|
||||
readScore[readSorted[i].id] = float64(20 - i)
|
||||
}
|
||||
// 2. 最近30篇:按 updated_at 降序,前30名得 30,29,...,1 分
|
||||
recencySorted := make([]model.Chapter, len(all))
|
||||
copy(recencySorted, all)
|
||||
for i := 0; i < len(recencySorted)-1; i++ {
|
||||
for j := i + 1; j < len(recencySorted); j++ {
|
||||
if recencySorted[j].UpdatedAt.After(recencySorted[i].UpdatedAt) {
|
||||
recencySorted[i], recencySorted[j] = recencySorted[j], recencySorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
recencyScore := make(map[string]float64)
|
||||
for i := 0; i < 30 && i < len(recencySorted); i++ {
|
||||
recencyScore[recencySorted[i].ID] = float64(30 - i)
|
||||
}
|
||||
// 3. 付款数前20:orders 中 product_type='section' 且 status='paid',按 product_id 计数
|
||||
var payCounts []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Table("orders").Select("product_id, COUNT(*) as cnt").
|
||||
Where("product_type = ? AND status = ? AND product_id IN ?", "section", "paid", ids).
|
||||
Group("product_id").Scan(&payCounts)
|
||||
payMap := make(map[string]int64)
|
||||
for _, r := range payCounts {
|
||||
payMap[r.ProductID] = r.Cnt
|
||||
}
|
||||
paySorted := make([]idCnt, 0, len(payMap))
|
||||
for id, cnt := range payMap {
|
||||
paySorted = append(paySorted, idCnt{id, cnt})
|
||||
}
|
||||
for i := 0; i < len(paySorted)-1; i++ {
|
||||
for j := i + 1; j < len(paySorted); j++ {
|
||||
if paySorted[j].cnt > paySorted[i].cnt {
|
||||
paySorted[i], paySorted[j] = paySorted[j], paySorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
payScore := make(map[string]float64)
|
||||
for i := 0; i < 20 && i < len(paySorted); i++ {
|
||||
payScore[paySorted[i].id] = float64(20 - i)
|
||||
}
|
||||
// 4. 总分 = 0.5*阅读 + 0.3*新度 + 0.2*付款,按总分降序取 limit
|
||||
type withTotal struct {
|
||||
ch model.Chapter
|
||||
total float64
|
||||
}
|
||||
withTotalList := make([]withTotal, 0, len(all))
|
||||
for _, c := range all {
|
||||
t := 0.5*readScore[c.ID] + 0.3*recencyScore[c.ID] + 0.2*payScore[c.ID]
|
||||
withTotalList = append(withTotalList, withTotal{ch: c, total: t})
|
||||
}
|
||||
for i := 0; i < len(withTotalList)-1; i++ {
|
||||
for j := i + 1; j < len(withTotalList); j++ {
|
||||
if withTotalList[j].total > withTotalList[i].total {
|
||||
withTotalList[i], withTotalList[j] = withTotalList[j], withTotalList[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]model.Chapter, 0, limit)
|
||||
for i := 0; i < limit && i < len(withTotalList); i++ {
|
||||
out = append(out, withTotalList[i].ch)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录)
|
||||
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录;支持 ?limit=,最大 50)
|
||||
// Redis 缓存 5min,章节更新时失效
|
||||
func BookHot(c *gin.Context) {
|
||||
list := bookHotChaptersSorted(database.DB(), 10)
|
||||
limit := 10
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 50 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
// 优先 Redis
|
||||
var cached []model.Chapter
|
||||
if cache.Get(context.Background(), cache.KeyBookHot(limit), &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": cached})
|
||||
return
|
||||
}
|
||||
list := bookHotChaptersSorted(database.DB(), limit)
|
||||
if len(list) == 0 {
|
||||
// 兜底:按 sort_order 取前 10,同样排除序言/尾声/附录
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
|
||||
sortChaptersByNaturalID(list)
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookHot(limit), list, cache.BookRelatedTTL)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
// BookRecommended GET /api/book/recommended 精选推荐(文章推荐算法:阅读50%+最近30篇30%+付款20%,排除序言/尾声/附录)
|
||||
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章)
|
||||
// Redis 缓存 5min,章节更新时失效
|
||||
func BookRecommended(c *gin.Context) {
|
||||
list := bookRecommendedByScore(database.DB(), 3)
|
||||
if len(list) == 0 {
|
||||
list = bookHotChaptersSorted(database.DB(), 3)
|
||||
var cached []gin.H
|
||||
if cache.Get(context.Background(), cache.KeyBookRecommended, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": cached})
|
||||
return
|
||||
}
|
||||
if len(list) == 0 {
|
||||
q := database.DB().Model(&model.Chapter{})
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
q.Order("updated_at DESC, id ASC").Limit(3).Find(&list)
|
||||
sections, err := computeArticleRankingSections(database.DB())
|
||||
if err != nil || len(sections) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []gin.H{}})
|
||||
return
|
||||
}
|
||||
limit := 3
|
||||
if len(sections) < limit {
|
||||
limit = len(sections)
|
||||
}
|
||||
tags := []string{"热门", "推荐", "精选"}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for i, ch := range list {
|
||||
out := make([]gin.H, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
s := sections[i]
|
||||
tag := "精选"
|
||||
if i < len(tags) {
|
||||
tag = tags[i]
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": ch.ID, "mid": ch.MID, "sectionTitle": ch.SectionTitle, "partTitle": ch.PartTitle,
|
||||
"chapterTitle": ch.ChapterTitle, "tag": tag,
|
||||
"isFree": ch.IsFree, "price": ch.Price, "isNew": ch.IsNew,
|
||||
"id": s.ID,
|
||||
"mid": s.MID,
|
||||
"sectionTitle": s.Title,
|
||||
"partTitle": s.PartTitle,
|
||||
"chapterTitle": s.ChapterTitle,
|
||||
"tag": tag,
|
||||
"isFree": s.IsFree,
|
||||
"price": s.Price,
|
||||
"isNew": s.IsNew,
|
||||
})
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyBookRecommended, out, cache.BookRelatedTTL)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// BookLatestChapters GET /api/book/latest-chapters
|
||||
// BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录)
|
||||
func BookLatestChapters(c *gin.Context) {
|
||||
db := database.DB()
|
||||
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
|
||||
for _, p := range excludeParts {
|
||||
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
|
||||
}
|
||||
var list []model.Chapter
|
||||
database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list)
|
||||
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if !list[i].UpdatedAt.Equal(list[j].UpdatedAt) {
|
||||
return list[i].UpdatedAt.After(list[j].UpdatedAt)
|
||||
}
|
||||
return naturalLessSectionID(list[i].ID, list[j].ID)
|
||||
})
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
z := float64(0)
|
||||
list[i].IsFree = &t
|
||||
list[i].Price = &z
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||||
}
|
||||
|
||||
@@ -426,6 +827,7 @@ func BookSearch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q})
|
||||
return
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
lowerQ := strings.ToLower(q)
|
||||
results := make([]gin.H, 0, len(list))
|
||||
for _, ch := range list {
|
||||
@@ -442,9 +844,18 @@ func BookSearch(c *gin.Context) {
|
||||
}
|
||||
|
||||
// BookStats GET /api/book/stats
|
||||
// Redis 缓存 5min,章节更新时失效
|
||||
func BookStats(c *gin.Context) {
|
||||
var cached struct {
|
||||
TotalChapters int64 `json:"totalChapters"`
|
||||
}
|
||||
if cache.Get(context.Background(), cache.KeyBookStats, &cached) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": cached.TotalChapters}})
|
||||
return
|
||||
}
|
||||
var total int64
|
||||
database.DB().Model(&model.Chapter{}).Count(&total)
|
||||
cache.Set(context.Background(), cache.KeyBookStats, gin.H{"totalChapters": total}, cache.BookRelatedTTL)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -15,135 +16,29 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
)
|
||||
|
||||
// 存客宝 API Key 约定(详见 开发文档/8、部署/存客宝API-Key约定.md):
|
||||
// - 链接卡若(添加卡若好友):使用 CKB_LEAD_API_KEY(.env 配置),未配则用下方 ckbAPIKey
|
||||
// - 其他场景(join/match 等):使用 ckbAPIKey
|
||||
const ckbAPIKey = "fyngh-ecy9h-qkdae-epwd5-rz6kd"
|
||||
const ckbAPIURL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
|
||||
|
||||
var ckbSourceMap = map[string]string{"team": "团队招募", "investor": "资源对接", "mentor": "导师顾问", "partner": "创业合伙"}
|
||||
var ckbTagsMap = map[string]string{"team": "切片团队,团队招募", "investor": "资源对接,资源群", "mentor": "导师顾问,咨询服务", "partner": "创业合伙,创业伙伴"}
|
||||
|
||||
type CKBRouteConfig struct {
|
||||
APIURL string `json:"apiUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Source string `json:"source"`
|
||||
Tags string `json:"tags"`
|
||||
SiteTags string `json:"siteTags"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type CKBConfigPayload struct {
|
||||
Routes map[string]CKBRouteConfig `json:"routes"`
|
||||
DocNotes string `json:"docNotes"`
|
||||
DocContent string `json:"docContent"`
|
||||
APIURL string `json:"apiUrl,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
}
|
||||
|
||||
func defaultCKBRouteConfig(routeKey string) CKBRouteConfig {
|
||||
cfg := CKBRouteConfig{
|
||||
APIURL: ckbAPIURL,
|
||||
APIKey: ckbAPIKey,
|
||||
SiteTags: "创业实验APP",
|
||||
}
|
||||
switch routeKey {
|
||||
case "join_partner":
|
||||
cfg.Source = "创业实验-创业合伙"
|
||||
cfg.Tags = "创业合伙,创业伙伴"
|
||||
case "join_investor":
|
||||
cfg.Source = "创业实验-资源对接"
|
||||
cfg.Tags = "资源对接,资源群"
|
||||
case "join_mentor":
|
||||
cfg.Source = "创业实验-导师顾问"
|
||||
cfg.Tags = "导师顾问,咨询服务"
|
||||
case "join_team":
|
||||
cfg.Source = "创业实验-团队招募"
|
||||
cfg.Tags = "切片团队,团队招募"
|
||||
case "match":
|
||||
cfg.Source = "创业实验-找伙伴匹配"
|
||||
cfg.Tags = "找伙伴"
|
||||
cfg.SiteTags = "创业实验APP,匹配用户"
|
||||
case "lead":
|
||||
cfg.Source = "小程序-链接卡若"
|
||||
cfg.Tags = "链接卡若,创业实验"
|
||||
cfg.SiteTags = "创业实验APP,链接卡若"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getCKBConfigPayload() CKBConfigPayload {
|
||||
payload := CKBConfigPayload{
|
||||
Routes: map[string]CKBRouteConfig{},
|
||||
}
|
||||
var cfg model.SystemConfig
|
||||
if err := database.DB().Where("config_key = ?", "ckb_config").First(&cfg).Error; err != nil {
|
||||
return payload
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &m); err != nil {
|
||||
return payload
|
||||
}
|
||||
if v, ok := m["docNotes"].(string); ok {
|
||||
payload.DocNotes = v
|
||||
}
|
||||
if v, ok := m["docContent"].(string); ok {
|
||||
payload.DocContent = v
|
||||
}
|
||||
if v, ok := m["apiKey"].(string); ok {
|
||||
payload.APIKey = v
|
||||
}
|
||||
if v, ok := m["apiUrl"].(string); ok {
|
||||
payload.APIURL = v
|
||||
}
|
||||
if routes, ok := m["routes"].(map[string]interface{}); ok {
|
||||
for key, raw := range routes {
|
||||
itemMap, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := defaultCKBRouteConfig(key)
|
||||
if v, ok := itemMap["apiUrl"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.APIURL = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["apiKey"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.APIKey = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["source"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.Source = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["tags"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.Tags = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["siteTags"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
item.SiteTags = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := itemMap["notes"].(string); ok {
|
||||
item.Notes = v
|
||||
}
|
||||
payload.Routes[key] = item
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func getCKBRouteConfig(routeKey string) (cfg CKBRouteConfig, docNotes string, docContent string) {
|
||||
cfg = defaultCKBRouteConfig(routeKey)
|
||||
payload := getCKBConfigPayload()
|
||||
docNotes = payload.DocNotes
|
||||
docContent = payload.DocContent
|
||||
if item, ok := payload.Routes[routeKey]; ok {
|
||||
cfg = item
|
||||
} else {
|
||||
if strings.TrimSpace(payload.APIURL) != "" {
|
||||
cfg.APIURL = strings.TrimSpace(payload.APIURL)
|
||||
}
|
||||
if strings.TrimSpace(payload.APIKey) != "" {
|
||||
cfg.APIKey = strings.TrimSpace(payload.APIKey)
|
||||
}
|
||||
}
|
||||
return cfg, docNotes, docContent
|
||||
// ckbSubmitSave 加好友/留资类接口统一落库:记录 action、userId、昵称、用户提交的传参,写入 ckb_submit_records
|
||||
func ckbSubmitSave(action, userID, nickname string, params interface{}) {
|
||||
paramsJSON, _ := json.Marshal(params)
|
||||
_ = database.DB().Create(&model.CkbSubmitRecord{
|
||||
Action: action,
|
||||
UserID: userID,
|
||||
Nickname: nickname,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ckbSign 与 next-project app/api/ckb/join 一致:排除 sign/apiKey/portrait,空值跳过,按键升序拼接值,MD5(拼接串) 再 MD5(结果+apiKey)
|
||||
@@ -182,6 +77,23 @@ func ckbSign(params map[string]interface{}, apiKey string) string {
|
||||
return hex.EncodeToString(h2[:])
|
||||
}
|
||||
|
||||
// getCkbLeadApiKey 链接卡若密钥优先级:system_config.site_settings.ckbLeadApiKey > .env CKB_LEAD_API_KEY > 代码内置 ckbAPIKey
|
||||
func getCkbLeadApiKey() string {
|
||||
var row model.SystemConfig
|
||||
if err := database.DB().Where("config_key = ?", "site_settings").First(&row).Error; err == nil && len(row.ConfigValue) > 0 {
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &m); err == nil {
|
||||
if v, ok := m["ckbLeadApiKey"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg := config.Get(); cfg != nil && cfg.CkbLeadAPIKey != "" {
|
||||
return cfg.CkbLeadAPIKey
|
||||
}
|
||||
return ckbAPIKey
|
||||
}
|
||||
|
||||
// CKBJoin POST /api/ckb/join
|
||||
func CKBJoin(c *gin.Context) {
|
||||
var body struct {
|
||||
@@ -206,31 +118,26 @@ func CKBJoin(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的加入类型"})
|
||||
return
|
||||
}
|
||||
routeCfg, _, _ := getCKBRouteConfig("join_" + body.Type)
|
||||
// 先写入 match_records(无论 CKB 是否成功,用户确实提交了表单)
|
||||
if body.UserID != "" {
|
||||
rec := model.MatchRecord{
|
||||
ID: fmt.Sprintf("mr_ckb_%d", time.Now().UnixNano()),
|
||||
UserID: body.UserID,
|
||||
MatchType: body.Type,
|
||||
}
|
||||
if body.Phone != "" {
|
||||
rec.Phone = &body.Phone
|
||||
}
|
||||
if body.Wechat != "" {
|
||||
rec.WechatID = &body.Wechat
|
||||
}
|
||||
if err := database.DB().Create(&rec).Error; err != nil {
|
||||
fmt.Printf("[CKBJoin] 写入 match_records 失败: %v\n", err)
|
||||
nickname := strings.TrimSpace(body.Name)
|
||||
if nickname == "" && body.UserID != "" {
|
||||
var u model.User
|
||||
if database.DB().Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
||||
nickname = *u.Nickname
|
||||
}
|
||||
}
|
||||
|
||||
if nickname == "" {
|
||||
nickname = "-"
|
||||
}
|
||||
ckbSubmitSave("join", body.UserID, nickname, map[string]interface{}{
|
||||
"type": body.Type, "phone": body.Phone, "wechat": body.Wechat, "name": body.Name,
|
||||
"userId": body.UserID, "remark": body.Remark, "canHelp": body.CanHelp, "needHelp": body.NeedHelp,
|
||||
})
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": routeCfg.Source,
|
||||
"tags": routeCfg.Tags,
|
||||
"siteTags": routeCfg.SiteTags,
|
||||
"source": "创业实验-" + ckbSourceMap[body.Type],
|
||||
"tags": ckbTagsMap[body.Type],
|
||||
"siteTags": "创业实验APP",
|
||||
"remark": body.Remark,
|
||||
}
|
||||
if body.Remark == "" {
|
||||
@@ -249,8 +156,8 @@ func CKBJoin(c *gin.Context) {
|
||||
if body.Name != "" {
|
||||
params["name"] = body.Name
|
||||
}
|
||||
params["apiKey"] = routeCfg.APIKey
|
||||
params["sign"] = ckbSign(params, routeCfg.APIKey)
|
||||
params["apiKey"] = ckbAPIKey
|
||||
params["sign"] = ckbSign(params, ckbAPIKey)
|
||||
sourceData := map[string]interface{}{
|
||||
"joinType": body.Type, "joinLabel": ckbSourceMap[body.Type], "userId": body.UserID,
|
||||
"device": "webapp", "timestamp": time.Now().Format(time.RFC3339),
|
||||
@@ -270,10 +177,9 @@ func CKBJoin(c *gin.Context) {
|
||||
"uniqueId": "soul_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
resp, err := http.Post(routeCfg.APIURL, "application/json", bytes.NewReader(raw))
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
fmt.Printf("[CKBJoin] CKB 请求失败: %v (match_records 已写入)\n", err)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已提交(存客宝暂不可达,稍后自动重试)"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "服务器错误,请稍后重试"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -323,7 +229,6 @@ func CKBJoin(c *gin.Context) {
|
||||
|
||||
// CKBMatch POST /api/ckb/match
|
||||
func CKBMatch(c *gin.Context) {
|
||||
routeCfg, _, _ := getCKBRouteConfig("match")
|
||||
var body struct {
|
||||
MatchType string `json:"matchType"`
|
||||
Phone string `json:"phone"`
|
||||
@@ -337,21 +242,25 @@ func CKBMatch(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
nickname := strings.TrimSpace(body.Nickname)
|
||||
if nickname == "" {
|
||||
nickname = "-"
|
||||
}
|
||||
ckbSubmitSave("match", body.UserID, nickname, map[string]interface{}{
|
||||
"matchType": body.MatchType, "phone": body.Phone, "wechat": body.Wechat,
|
||||
"userId": body.UserID, "nickname": body.Nickname, "matchedUser": body.MatchedUser,
|
||||
})
|
||||
ts := time.Now().Unix()
|
||||
label := ckbSourceMap[body.MatchType]
|
||||
if label == "" {
|
||||
label = "创业合伙"
|
||||
}
|
||||
tags := routeCfg.Tags
|
||||
if label != "" && tags != "" && !strings.Contains(tags, label) {
|
||||
tags = tags + "," + label
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": routeCfg.Source,
|
||||
"tags": tags,
|
||||
"siteTags": routeCfg.SiteTags,
|
||||
"remark": "用户发起" + label + "匹配",
|
||||
"source": "创业实验-找伙伴匹配",
|
||||
"tags": "找伙伴," + label,
|
||||
"siteTags": "创业实验APP,匹配用户",
|
||||
"remark": "用户发起" + label + "匹配",
|
||||
}
|
||||
if body.Phone != "" {
|
||||
params["phone"] = body.Phone
|
||||
@@ -362,8 +271,8 @@ func CKBMatch(c *gin.Context) {
|
||||
if body.Nickname != "" {
|
||||
params["name"] = body.Nickname
|
||||
}
|
||||
params["apiKey"] = routeCfg.APIKey
|
||||
params["sign"] = ckbSign(params, routeCfg.APIKey)
|
||||
params["apiKey"] = ckbAPIKey
|
||||
params["sign"] = ckbSign(params, ckbAPIKey)
|
||||
params["portrait"] = map[string]interface{}{
|
||||
"type": 4, "source": 0,
|
||||
"sourceData": map[string]interface{}{
|
||||
@@ -374,7 +283,7 @@ func CKBMatch(c *gin.Context) {
|
||||
"uniqueId": "soul_match_" + body.Phone + body.Wechat + strconv.FormatInt(ts, 10),
|
||||
}
|
||||
raw, _ := json.Marshal(params)
|
||||
resp, err := http.Post(routeCfg.APIURL, "application/json", bytes.NewReader(raw))
|
||||
resp, err := http.Post(ckbAPIURL, "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "匹配成功"})
|
||||
return
|
||||
@@ -398,11 +307,10 @@ 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
|
||||
func CKBLead(c *gin.Context) {
|
||||
routeCfg, _, _ := getCKBRouteConfig("lead")
|
||||
// CKBIndexLead POST /api/miniprogram/ckb/index-lead 小程序首页「点击链接卡若」专用留资接口
|
||||
// - 固定使用全局 CKB_LEAD_API_KEY,不受文章 @ 人物的 ckb_api_key 影响
|
||||
// - 请求体:userId(可选,用于补全昵称)、phone/wechatId(至少一个)、name(可选)
|
||||
func CKBIndexLead(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
@@ -412,28 +320,192 @@ func CKBLead(c *gin.Context) {
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
phone := strings.TrimSpace(body.Phone)
|
||||
wechatId := strings.TrimSpace(body.WechatID)
|
||||
if phone == "" && wechatId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
// 存客宝侧仅接收手机号,不接收微信号;首页入口必须提供手机号
|
||||
if phone == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请先填写手机号"})
|
||||
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 = "小程序用户"
|
||||
}
|
||||
|
||||
// 首页固定使用全局密钥:system_config > .env > 代码内置
|
||||
leadKey := getCkbLeadApiKey()
|
||||
|
||||
// 去重:同一用户只记录一次(首页链接卡若)
|
||||
repeatedSubmit := false
|
||||
if body.UserID != "" {
|
||||
var existCount int64
|
||||
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND source = ?", body.UserID, "index_link_button").Count(&existCount)
|
||||
repeatedSubmit = existCount > 0
|
||||
}
|
||||
|
||||
source := "index_link_button"
|
||||
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
||||
"source": source,
|
||||
})
|
||||
_ = db.Create(&model.CkbLeadRecord{
|
||||
UserID: body.UserID,
|
||||
Nickname: name,
|
||||
Phone: phone,
|
||||
WechatID: wechatId,
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Source: source,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"source": routeCfg.Source,
|
||||
"tags": routeCfg.Tags,
|
||||
"siteTags": routeCfg.SiteTags,
|
||||
"remark": "首页点击「链接卡若」留资",
|
||||
"name": name,
|
||||
"timestamp": ts,
|
||||
"apiKey": leadKey,
|
||||
}
|
||||
params["phone"] = phone
|
||||
params["sign"] = ckbSign(params, leadKey)
|
||||
q := url.Values{}
|
||||
q.Set("name", name)
|
||||
q.Set("timestamp", strconv.FormatInt(ts, 10))
|
||||
q.Set("apiKey", leadKey)
|
||||
q.Set("phone", phone)
|
||||
q.Set("sign", params["sign"].(string))
|
||||
reqURL := ckbAPIURL + "?" + q.Encode()
|
||||
fmt.Printf("[CKBIndexLead] 请求存客宝完整链接: %s\n", reqURL)
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code == 200 {
|
||||
msg := "提交成功,卡若会尽快联系您"
|
||||
if repeatedSubmit {
|
||||
msg = "您已留资过,我们已再次通知卡若,请耐心等待添加"
|
||||
}
|
||||
data := gin.H{}
|
||||
if result.Data != nil {
|
||||
if m, ok := result.Data.(map[string]interface{}); ok {
|
||||
data = m
|
||||
}
|
||||
}
|
||||
data["repeatedSubmit"] = repeatedSubmit
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
}
|
||||
// 存客宝返回失败,透传其错误信息与 code,便于前端/运营判断原因
|
||||
errMsg := strings.TrimSpace(result.Message)
|
||||
if errMsg == "" {
|
||||
errMsg = "提交失败,请稍后重试"
|
||||
}
|
||||
fmt.Printf("[CKBIndexLead] 存客宝返回异常 code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": errMsg,
|
||||
"ckbCode": result.Code,
|
||||
"ckbMessage": result.Message,
|
||||
})
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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)
|
||||
wechatId := strings.TrimSpace(body.WechatID)
|
||||
if phone == "" && wechatId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "请提供手机号或微信号"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
db := database.DB()
|
||||
if name == "" && body.UserID != "" {
|
||||
var u model.User
|
||||
if db.Select("nickname").Where("id = ?", body.UserID).First(&u).Error == nil && u.Nickname != nil && *u.Nickname != "" {
|
||||
name = *u.Nickname
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = "小程序用户"
|
||||
}
|
||||
|
||||
// 存客宝 scenarios 内部 API 需要计划级 apiKey(persons.ckb_api_key),不是 token
|
||||
// 文章 @ 场景:targetUserId=token → 查 Person 取 CkbApiKey 作为 leadKey
|
||||
// 首页链接卡若:targetUserId 为空 → 用全局 getCkbLeadApiKey()
|
||||
leadKey := getCkbLeadApiKey()
|
||||
targetName := strings.TrimSpace(body.TargetNickname)
|
||||
if body.TargetUserID != "" {
|
||||
var p model.Person
|
||||
if db.Where("token = ?", body.TargetUserID).First(&p).Error != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "未找到该人物配置,请稍后重试"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.CkbApiKey) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "该人物尚未配置存客宝密钥,请联系管理员"})
|
||||
return
|
||||
}
|
||||
leadKey = p.CkbApiKey
|
||||
if targetName == "" {
|
||||
targetName = p.Name
|
||||
}
|
||||
}
|
||||
|
||||
// 去重:同一用户对同一目标人物只记录一次(不再限制时间间隔,允许对不同人物立即提交)
|
||||
repeatedSubmit := false
|
||||
if body.UserID != "" && body.TargetUserID != "" {
|
||||
var existCount int64
|
||||
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND target_person_id = ?", body.UserID, body.TargetUserID).Count(&existCount)
|
||||
repeatedSubmit = 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),
|
||||
TargetPersonID: body.TargetUserID,
|
||||
Source: source,
|
||||
Params: string(paramsJSON),
|
||||
}).Error
|
||||
|
||||
ts := time.Now().Unix()
|
||||
params := map[string]interface{}{
|
||||
"name": name,
|
||||
"timestamp": ts,
|
||||
"apiKey": leadKey,
|
||||
}
|
||||
if phone != "" {
|
||||
params["phone"] = phone
|
||||
@@ -441,157 +513,72 @@ func CKBLead(c *gin.Context) {
|
||||
if wechatId != "" {
|
||||
params["wechatId"] = wechatId
|
||||
}
|
||||
params["apiKey"] = routeCfg.APIKey
|
||||
params["sign"] = ckbSign(params, routeCfg.APIKey)
|
||||
raw, _ := json.Marshal(params)
|
||||
fmt.Printf("[CKBLead] 请求: phone=%s wechatId=%s name=%s\n", phone, wechatId, name)
|
||||
resp, err := http.Post(routeCfg.APIURL, "application/json", bytes.NewReader(raw))
|
||||
params["sign"] = ckbSign(params, leadKey)
|
||||
q := url.Values{}
|
||||
q.Set("name", name)
|
||||
q.Set("timestamp", strconv.FormatInt(ts, 10))
|
||||
q.Set("apiKey", leadKey)
|
||||
if phone != "" {
|
||||
q.Set("phone", phone)
|
||||
}
|
||||
if wechatId != "" {
|
||||
q.Set("wechatId", wechatId)
|
||||
}
|
||||
q.Set("sign", params["sign"].(string))
|
||||
reqURL := ckbAPIURL + "?" + q.Encode()
|
||||
fmt.Printf("[CKBLead] 请求存客宝完整链接: %s\n", reqURL)
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[CKBLead] 请求存客宝失败: %v\n", err)
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Msg string `json:"msg"` // 存客保部分接口用 msg 返回错误
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
fmt.Printf("[CKBLead] 响应: code=%d message=%s raw=%s\n", result.Code, result.Message, string(b))
|
||||
if result.Code == 200 {
|
||||
msg := "提交成功,卡若会尽快联系您"
|
||||
if result.Message == "已存在" {
|
||||
msg = "您已留资,我们会尽快联系您"
|
||||
// 成功文案:有被@的人则用 TA 的名字,否则用"对方"
|
||||
who := targetName
|
||||
if who == "" {
|
||||
who = "对方"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": result.Data})
|
||||
msg := fmt.Sprintf("提交成功,%s 会尽快联系您", who)
|
||||
if repeatedSubmit {
|
||||
msg = fmt.Sprintf("您已留资过,我们已再次通知 %s,请耐心等待添加", who)
|
||||
}
|
||||
data := gin.H{}
|
||||
if result.Data != nil {
|
||||
if m, ok := result.Data.(map[string]interface{}); ok {
|
||||
data = m
|
||||
}
|
||||
}
|
||||
data["repeatedSubmit"] = repeatedSubmit
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
}
|
||||
errMsg := result.Message
|
||||
ckbMsg := strings.TrimSpace(result.Message)
|
||||
if ckbMsg == "" {
|
||||
ckbMsg = strings.TrimSpace(result.Msg)
|
||||
}
|
||||
errMsg := ckbMsg
|
||||
if errMsg == "" {
|
||||
errMsg = "提交失败,请稍后重试"
|
||||
}
|
||||
fmt.Printf("[CKBLead] 失败: phone=%s code=%d message=%s\n", phone, result.Code, result.Message)
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": errMsg})
|
||||
}
|
||||
|
||||
// CKBPlanStats GET /api/db/ckb-plan-stats 代理存客宝获客计划统计
|
||||
func CKBPlanStats(c *gin.Context) {
|
||||
routeCfg, docNotes, docContent := getCKBRouteConfig("lead")
|
||||
ts := time.Now().Unix()
|
||||
// 用 scenarios 接口查询方式不可行,存客宝 plan-stats 需要 JWT
|
||||
// 这里用本地 match_records + CKB 签名信息返回聚合统计
|
||||
db := database.DB()
|
||||
// 各类型提交数量(通过 CKBJoin 写入的 mr_ckb_ 开头的记录)
|
||||
type TypeStat struct {
|
||||
MatchType string `gorm:"column:match_type" json:"matchType"`
|
||||
Total int64 `gorm:"column:total" json:"total"`
|
||||
}
|
||||
var ckbStats []TypeStat
|
||||
db.Raw("SELECT match_type, COUNT(*) as total FROM match_records WHERE id LIKE 'mr_ckb_%' GROUP BY match_type").Scan(&ckbStats)
|
||||
var ckbTotal int64
|
||||
db.Raw("SELECT COUNT(*) FROM match_records WHERE id LIKE 'mr_ckb_%'").Scan(&ckbTotal)
|
||||
// 各类型有联系方式的数量
|
||||
var withContact int64
|
||||
db.Raw("SELECT COUNT(*) FROM match_records WHERE id LIKE 'mr_ckb_%' AND ((phone IS NOT NULL AND phone != '') OR (wechat_id IS NOT NULL AND wechat_id != ''))").Scan(&withContact)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"ckbTotal": ckbTotal,
|
||||
"withContact": withContact,
|
||||
"byType": ckbStats,
|
||||
"ckbApiKey": routeCfg.APIKey[:minInt(len(routeCfg.APIKey), 8)] + "...",
|
||||
"ckbApiUrl": routeCfg.APIURL,
|
||||
"lastSignTest": ts,
|
||||
"docNotes": docNotes,
|
||||
"docContent": docContent,
|
||||
"routes": getCKBConfigPayload().Routes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DBCKBLeadList GET /api/db/ckb-leads 管理端-CKB线索明细
|
||||
func DBCKBLeadList(c *gin.Context) {
|
||||
db := database.DB()
|
||||
mode := strings.TrimSpace(c.DefaultQuery("mode", "submitted")) // submitted|contact
|
||||
matchType := strings.TrimSpace(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
|
||||
}
|
||||
|
||||
q := db.Model(&model.MatchRecord{}).Where("id LIKE 'mr_ckb_%'")
|
||||
if matchType != "" {
|
||||
q = q.Where("match_type = ?", matchType)
|
||||
}
|
||||
if mode == "contact" {
|
||||
q = q.Where("((phone IS NOT NULL AND phone != '') OR (wechat_id IS NOT NULL AND wechat_id != ''))")
|
||||
}
|
||||
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
|
||||
var records []model.MatchRecord
|
||||
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, "message": 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.MatchType,
|
||||
"phone": func() string { if r.Phone == nil { return "" }; return *r.Phone }(),
|
||||
"wechatId": func() string { if r.WechatID == nil { return "" }; return *r.WechatID }(),
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"records": out,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
fmt.Printf("[CKBLead] 存客宝返回异常 code=%d msg=%s raw=%s\n", result.Code, ckbMsg, string(b))
|
||||
respObj := gin.H{
|
||||
"success": false,
|
||||
"message": errMsg,
|
||||
"ckbCode": result.Code,
|
||||
"ckbMessage": ckbMsg,
|
||||
}
|
||||
if ckbMsg == "" && len(b) > 0 {
|
||||
respObj["ckbRaw"] = string(b) // 存客保未返回 message/msg 时透传原始响应,供调试
|
||||
}
|
||||
c.JSON(http.StatusOK, respObj)
|
||||
}
|
||||
|
||||
460
soul-api/internal/handler/ckb_open.go
Normal file
460
soul-api/internal/handler/ckb_open.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"soul-api/internal/config"
|
||||
)
|
||||
|
||||
const ckbOpenBaseURL = "https://ckbapi.quwanzhi.com"
|
||||
|
||||
// ckbOpenSign 按 open-api-sign.md:sign = MD5(MD5(account+timestamp) + apiKey)
|
||||
func ckbOpenSign(account string, ts int64, apiKey string) string {
|
||||
plain := account + strconv.FormatInt(ts, 10)
|
||||
h := md5.Sum([]byte(plain))
|
||||
first := hex.EncodeToString(h[:])
|
||||
h2 := md5.Sum([]byte(first + apiKey))
|
||||
return hex.EncodeToString(h2[:])
|
||||
}
|
||||
|
||||
func getCkbOpenConfig() (apiKey, account string) {
|
||||
cfg := config.Get()
|
||||
if cfg != nil {
|
||||
apiKey = cfg.CkbOpenAPIKey
|
||||
account = cfg.CkbOpenAccount
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ckbOpenGetToken 获取开放 API JWT
|
||||
func ckbOpenGetToken() (string, error) {
|
||||
apiKey, account := getCkbOpenConfig()
|
||||
if apiKey == "" || account == "" {
|
||||
return "", fmt.Errorf("CKB_OPEN_API_KEY 或 CKB_OPEN_ACCOUNT 未配置,请在后端 .env 中配置后重试")
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
sign := ckbOpenSign(account, ts, apiKey)
|
||||
authBody := map[string]interface{}{
|
||||
"apiKey": apiKey,
|
||||
"account": account,
|
||||
"timestamp": ts,
|
||||
"sign": sign,
|
||||
}
|
||||
raw, _ := json.Marshal(authBody)
|
||||
authResp, err := http.Post(ckbOpenBaseURL+"/v1/open/auth/token", "application/json", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求存客宝鉴权失败: %w", err)
|
||||
}
|
||||
defer authResp.Body.Close()
|
||||
authBytes, _ := io.ReadAll(authResp.Body)
|
||||
var authResult struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(authBytes, &authResult)
|
||||
if authResult.Code != 200 || authResult.Data.Token == "" {
|
||||
msg := authResult.Message
|
||||
if msg == "" {
|
||||
msg = "存客宝鉴权失败"
|
||||
}
|
||||
return "", fmt.Errorf(msg)
|
||||
}
|
||||
return authResult.Data.Token, nil
|
||||
}
|
||||
|
||||
// ckbOpenCreatePlan 调用 /v1/plan/create 创建获客计划,返回 planId、存客宝原始 data、以及完整响应(失败时便于排查)
|
||||
func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int64, createData map[string]interface{}, ckbResponse map[string]interface{}, err error) {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, ckbOpenBaseURL+"/v1/plan/create", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return 0, nil, nil, fmt.Errorf("构造创建计划请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, nil, fmt.Errorf("请求存客宝创建计划失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
// 始终组装完整响应,便于失败时返回给调用方查看存客宝实际返回
|
||||
ckbResponse = map[string]interface{}{
|
||||
"code": result.Code,
|
||||
"message": result.Message,
|
||||
"data": nil,
|
||||
}
|
||||
if len(result.Data) > 0 {
|
||||
var dataObj interface{}
|
||||
_ = json.Unmarshal(result.Data, &dataObj)
|
||||
ckbResponse["data"] = dataObj
|
||||
}
|
||||
if result.Code != 200 {
|
||||
if result.Message == "" {
|
||||
result.Message = "创建计划失败"
|
||||
}
|
||||
return 0, nil, ckbResponse, fmt.Errorf(result.Message)
|
||||
}
|
||||
// 原始 data 转为 map 供响应展示
|
||||
createData = make(map[string]interface{})
|
||||
_ = json.Unmarshal(result.Data, &createData)
|
||||
// 存客宝可能返回 planId 为数字或字符串(如 "629"),兼容解析
|
||||
planID = parsePlanIDFromData(createData)
|
||||
if planID != 0 {
|
||||
return planID, createData, ckbResponse, nil
|
||||
}
|
||||
return 0, createData, ckbResponse, fmt.Errorf("创建计划返回结果中缺少 planId")
|
||||
}
|
||||
|
||||
// parseApiKeyFromCreateData 从 create 返回的 data 中解析 apiKey(若存客宝直接返回则复用,避免二次请求)
|
||||
func parseApiKeyFromCreateData(data map[string]interface{}) string {
|
||||
for _, key := range []string{"apiKey", "api_key"} {
|
||||
v, ok := data[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parsePlanIDFromData 从 data 中解析 planId,支持 number 或 string;若无则尝试 id
|
||||
func parsePlanIDFromData(data map[string]interface{}) int64 {
|
||||
for _, key := range []string{"planId", "id"} {
|
||||
v, ok := data[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
if val > 0 {
|
||||
return int64(val)
|
||||
}
|
||||
case int:
|
||||
if val > 0 {
|
||||
return int64(val)
|
||||
}
|
||||
case int64:
|
||||
if val > 0 {
|
||||
return val
|
||||
}
|
||||
case string:
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(val, 10, 64)
|
||||
if err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ckbOpenGetPlanDetail 调用 /v1/plan/detail?planId=,返回计划级 apiKey
|
||||
func ckbOpenGetPlanDetail(token string, planID int64) (string, error) {
|
||||
u := fmt.Sprintf("%s/v1/plan/detail?planId=%d", ckbOpenBaseURL, planID)
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("构造计划详情请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求存客宝计划详情失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code != 200 {
|
||||
if result.Message == "" {
|
||||
result.Message = "获取计划详情失败"
|
||||
}
|
||||
return "", fmt.Errorf(result.Message)
|
||||
}
|
||||
if result.Data.APIKey == "" {
|
||||
return "", fmt.Errorf("计划详情中缺少 apiKey")
|
||||
}
|
||||
return result.Data.APIKey, nil
|
||||
}
|
||||
|
||||
// ckbOpenGetDefaultDeviceID 获取默认设备 ID:拉设备列表,取第一个 memo 或 nickname 包含 "soul" 的设备;用于 deviceGroups 必填时的默认值
|
||||
func ckbOpenGetDefaultDeviceID(token string) (int64, error) {
|
||||
u := ckbOpenBaseURL + "/v1/devices?keyword=soul&page=1&limit=50"
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("构造设备列表请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("请求存客宝设备列表失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(b, &parsed); err != nil {
|
||||
return 0, fmt.Errorf("解析设备列表失败: %w", err)
|
||||
}
|
||||
var listAny interface{}
|
||||
if dataVal, ok := parsed["data"].(map[string]interface{}); ok {
|
||||
listAny = dataVal["list"]
|
||||
} else if la, ok := parsed["list"]; ok {
|
||||
listAny = la
|
||||
}
|
||||
arr, ok := listAny.([]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("设备列表格式异常")
|
||||
}
|
||||
// 优先匹配 memo/nickname 包含 soul 的设备;若无则取第一个(keyword 可能已过滤)
|
||||
for _, item := range arr {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
memo := toString(m["memo"])
|
||||
if memo == "" {
|
||||
memo = toString(m["imei"])
|
||||
}
|
||||
nickname := toString(m["nickname"])
|
||||
lowerMemo := strings.ToLower(memo)
|
||||
lowerNick := strings.ToLower(nickname)
|
||||
if strings.Contains(lowerMemo, "soul") || strings.Contains(lowerNick, "soul") {
|
||||
id := parseDeviceID(m["id"])
|
||||
if id > 0 {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未找到含 soul 的,取第一个
|
||||
if len(arr) > 0 {
|
||||
if m, ok := arr[0].(map[string]interface{}); ok {
|
||||
id := parseDeviceID(m["id"])
|
||||
if id > 0 {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("未找到名为 soul 的设备,请先在存客宝添加设备并设置 memo 或 nickname 包含 soul")
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(val)
|
||||
case int64:
|
||||
return strconv.FormatInt(val, 10)
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func parseDeviceID(v interface{}) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
if val > 0 {
|
||||
return int64(val)
|
||||
}
|
||||
case int:
|
||||
if val > 0 {
|
||||
return int64(val)
|
||||
}
|
||||
case int64:
|
||||
if val > 0 {
|
||||
return val
|
||||
}
|
||||
case string:
|
||||
if val == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(val, 10, 64)
|
||||
if err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ckbOpenDeletePlan 调用 DELETE /v1/plan/delete 删除存客宝获客计划
|
||||
func ckbOpenDeletePlan(token string, planID int64) error {
|
||||
payload := map[string]interface{}{"planId": planID}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodDelete, ckbOpenBaseURL+"/v1/plan/delete", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("构造删除计划请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求存客宝删除计划失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
if result.Code != 200 {
|
||||
if result.Message == "" {
|
||||
result.Message = "删除计划失败"
|
||||
}
|
||||
return fmt.Errorf(result.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminCKBDevices GET /api/admin/ckb/devices 管理端-存客宝设备列表(供链接人与事选择设备)
|
||||
// 通过开放 API 获取 JWT,再调用 /v1/devices,返回精简后的设备列表。
|
||||
func AdminCKBDevices(c *gin.Context) {
|
||||
token, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 调用 /v1/devices 获取设备列表
|
||||
pageStr := c.Query("page")
|
||||
if pageStr == "" {
|
||||
pageStr = "1"
|
||||
}
|
||||
limitStr := c.Query("limit")
|
||||
if limitStr == "" {
|
||||
limitStr = "20"
|
||||
}
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("page", pageStr)
|
||||
values.Set("limit", limitStr)
|
||||
if keyword != "" {
|
||||
values.Set("keyword", keyword)
|
||||
}
|
||||
deviceURL := ckbOpenBaseURL + "/v1/devices"
|
||||
if len(values) > 0 {
|
||||
deviceURL += "?" + values.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, deviceURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "构造设备列表请求失败"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求存客宝设备列表失败"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 设备返回结构:参考 Cunkebao getDeviceList 使用方式,形如 { code, msg, data: { list, total } } 或 { code, msg, list, total }
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(b, &parsed); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "解析存客宝设备列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试从 data.list 或 list 提取设备列表与 total
|
||||
var listAny interface{}
|
||||
if dataVal, ok := parsed["data"].(map[string]interface{}); ok {
|
||||
listAny = dataVal["list"]
|
||||
if _, ok := parsed["total"]; !ok {
|
||||
if tv, ok := dataVal["total"]; ok {
|
||||
parsed["total"] = tv
|
||||
}
|
||||
}
|
||||
} else if la, ok := parsed["list"]; ok {
|
||||
listAny = la
|
||||
}
|
||||
|
||||
devices := make([]map[string]interface{}, 0)
|
||||
if arr, ok := listAny.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
id := m["id"]
|
||||
memo := m["memo"]
|
||||
if memo == nil || memo == "" {
|
||||
memo = m["imei"]
|
||||
}
|
||||
wechatID := m["wechatId"]
|
||||
status := "offline"
|
||||
if alive, ok := m["alive"].(float64); ok && int(alive) == 1 {
|
||||
status = "online"
|
||||
}
|
||||
devices = append(devices, map[string]interface{}{
|
||||
"id": id,
|
||||
"memo": memo,
|
||||
"imei": m["imei"],
|
||||
"wechatId": wechatID,
|
||||
"status": status,
|
||||
"avatar": m["avatar"],
|
||||
"nickname": m["nickname"],
|
||||
"usedInPlan": m["usedInPlans"],
|
||||
"totalFriend": m["totalFriend"],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := 0
|
||||
switch tv := parsed["total"].(type) {
|
||||
case float64:
|
||||
total = int(tv)
|
||||
case int:
|
||||
total = tv
|
||||
case string:
|
||||
if n, err := strconv.Atoi(tv); err == nil {
|
||||
total = n
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"devices": devices,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -69,10 +70,53 @@ func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error)
|
||||
}
|
||||
tradeState, transactionID, totalFee, qerr := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN)
|
||||
if qerr != nil {
|
||||
// 微信返回「订单不存在」:说明该 out_trade_no 在微信侧已无效,直接将本地订单标记为关闭
|
||||
if strings.Contains(qerr.Error(), "ORDER_NOT_EXIST") {
|
||||
now := time.Now()
|
||||
if err := db.Model(&o).Updates(map[string]interface{}{
|
||||
"status": "closed",
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
syncOrdersLogf("微信提示订单不存在,标记订单 %s 为关闭失败: %v", o.OrderSN, err)
|
||||
} else {
|
||||
syncOrdersLogf("微信提示订单不存在,已将本地订单标记为关闭: %s", o.OrderSN)
|
||||
}
|
||||
continue
|
||||
}
|
||||
syncOrdersLogf("查询订单 %s 失败: %v", o.OrderSN, qerr)
|
||||
continue
|
||||
}
|
||||
// 根据微信支付状态决定本地订单后续处理:
|
||||
// - SUCCESS:补齐漏单,发放权益
|
||||
// - NOTPAY/USERPAYING:在有效期内保持 created,超过一定时间自动标记为关闭
|
||||
// - 其他终态(CLOSED、REVOKED、PAYERROR 等):标记为关闭,避免无限轮询
|
||||
if tradeState != "SUCCESS" {
|
||||
// 对仍未支付的订单设置超时关闭(避免长时间轮询)
|
||||
if tradeState == "NOTPAY" || tradeState == "USERPAYING" {
|
||||
// 超过 30 分钟仍未支付,视为关闭
|
||||
if time.Since(o.CreatedAt) > 30*time.Minute {
|
||||
now := time.Now()
|
||||
if err := db.Model(&o).Updates(map[string]interface{}{
|
||||
"status": "closed",
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
syncOrdersLogf("标记超时未支付订单 %s 为关闭失败: %v", o.OrderSN, err)
|
||||
} else {
|
||||
syncOrdersLogf("订单超时未支付,标记为关闭: %s", o.OrderSN)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 其他非 SUCCESS 状态(如 CLOSED、REVOKED、PAYERROR 等),直接在本地标记为关闭
|
||||
now := time.Now()
|
||||
if err := db.Model(&o).Updates(map[string]interface{}{
|
||||
"status": "closed",
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
syncOrdersLogf("标记订单 %s 为关闭状态失败(trade_state=%s): %v", o.OrderSN, tradeState, err)
|
||||
} else {
|
||||
syncOrdersLogf("订单在微信已为终态 %s,本地标记为关闭: %s", tradeState, o.OrderSN)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 微信已支付,本地未更新 → 补齐
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -16,10 +18,15 @@ import (
|
||||
)
|
||||
|
||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||
// 从 system_config 读取 chapter_config、feature_config、mp_config,合并后返回(免费以章节 is_free/price 为准)
|
||||
// Redis 缓存 10min,配置变更时失效
|
||||
func GetPublicDBConfig(c *gin.Context) {
|
||||
var cached map[string]interface{}
|
||||
if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 {
|
||||
c.JSON(http.StatusOK, cached)
|
||||
return
|
||||
}
|
||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
|
||||
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
|
||||
apiDomain := "https://soulapi.quwanzhi.com"
|
||||
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
|
||||
apiDomain = cfg.BaseURL
|
||||
@@ -101,6 +108,33 @@ func GetPublicDBConfig(c *gin.Context) {
|
||||
if _, has := out["userDiscount"]; !has {
|
||||
out["userDiscount"] = float64(5)
|
||||
}
|
||||
// 链接标签列表(小程序 onLinkTagTap 需要 type;miniprogram 类型存 mpKey,用 key 查 linkedMiniprograms 得 appId)
|
||||
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 {
|
||||
h := gin.H{"tagId": t.TagID, "label": t.Label, "url": t.URL, "type": t.Type, "pagePath": t.PagePath}
|
||||
if t.Type == "miniprogram" {
|
||||
h["mpKey"] = t.AppID // miniprogram 类型时 AppID 列存的是密钥
|
||||
} else {
|
||||
h["appId"] = t.AppID
|
||||
}
|
||||
tags = append(tags, h)
|
||||
}
|
||||
out["linkTags"] = tags
|
||||
}
|
||||
// 关联小程序列表(key 为 32 位密钥,小程序用 key 查 appId 后 wx.navigateToMiniProgram)
|
||||
var linkedMpRow model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "linked_miniprograms").First(&linkedMpRow).Error; err == nil && len(linkedMpRow.ConfigValue) > 0 {
|
||||
var linkedList []gin.H
|
||||
if err := json.Unmarshal(linkedMpRow.ConfigValue, &linkedList); err == nil && len(linkedList) > 0 {
|
||||
out["linkedMiniprograms"] = linkedList
|
||||
}
|
||||
}
|
||||
if _, has := out["linkedMiniprograms"]; !has {
|
||||
out["linkedMiniprograms"] = []gin.H{}
|
||||
}
|
||||
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -148,11 +182,12 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
|
||||
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true},
|
||||
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
|
||||
"mpConfig": defaultMp,
|
||||
"ossConfig": gin.H{},
|
||||
}
|
||||
keys := []string{"feature_config", "site_settings", "mp_config"}
|
||||
keys := []string{"feature_config", "site_settings", "mp_config", "oss_config"}
|
||||
for _, k := range keys {
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||||
@@ -182,6 +217,10 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
}
|
||||
out["mpConfig"] = merged
|
||||
}
|
||||
case "oss_config":
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
out["ossConfig"] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
@@ -193,6 +232,7 @@ func AdminSettingsPost(c *gin.Context) {
|
||||
FeatureConfig map[string]interface{} `json:"featureConfig"`
|
||||
SiteSettings map[string]interface{} `json:"siteSettings"`
|
||||
MpConfig map[string]interface{} `json:"mpConfig"`
|
||||
OssConfig map[string]interface{} `json:"ossConfig"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -234,6 +274,13 @@ func AdminSettingsPost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.OssConfig != nil {
|
||||
if err := saveKey("oss_config", "阿里云 OSS 配置", body.OssConfig); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存 OSS 配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"})
|
||||
}
|
||||
|
||||
@@ -317,6 +364,7 @@ func AdminReferralSettingsPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
|
||||
}
|
||||
|
||||
@@ -488,6 +536,7 @@ func DBConfigPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
|
||||
}
|
||||
|
||||
@@ -514,14 +563,12 @@ func DBUsersList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "user": nil})
|
||||
return
|
||||
}
|
||||
// 填充 hasFullBook(含 is_vip 或 orders)
|
||||
// 填充 hasFullBook(含 orders、is_vip、手动设置的 has_full_book)
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
|
||||
id, "paid", "completed", "fullbook", "vip").Count(&cnt)
|
||||
user.HasFullBook = ptrBool(cnt > 0)
|
||||
if user.IsVip != nil && *user.IsVip {
|
||||
user.HasFullBook = ptrBool(true)
|
||||
}
|
||||
hasFull := cnt > 0 || (user.IsVip != nil && *user.IsVip) || (user.HasFullBook != nil && *user.HasFullBook)
|
||||
user.HasFullBook = ptrBool(hasFull)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "user": user})
|
||||
return
|
||||
}
|
||||
@@ -644,11 +691,14 @@ func DBUsersList(c *gin.Context) {
|
||||
// 填充每个用户的实时计算字段
|
||||
for i := range users {
|
||||
uid := users[i].ID
|
||||
// 购买状态(含手动设置的 VIP:is_vip=1 且 vip_expire_date>NOW)
|
||||
// 购买状态(含订单、is_vip、手动设置的 has_full_book)
|
||||
hasFull := hasFullBookMap[uid]
|
||||
if users[i].IsVip != nil && *users[i].IsVip && users[i].VipExpireDate != nil && users[i].VipExpireDate.After(time.Now()) {
|
||||
hasFull = true
|
||||
}
|
||||
if users[i].HasFullBook != nil && *users[i].HasFullBook {
|
||||
hasFull = true
|
||||
}
|
||||
users[i].HasFullBook = ptrBool(hasFull)
|
||||
users[i].PurchasedSectionCount = sectionCountMap[uid]
|
||||
// 分销收益
|
||||
@@ -712,13 +762,14 @@ func DBUsersAction(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "user": u, "isNew": true, "message": "用户创建成功"})
|
||||
return
|
||||
}
|
||||
// PUT 更新(含 VIP 手动设置:is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio)
|
||||
// PUT 更新(含 VIP 手动设置:is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio;tags 存 ckb_tags)
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
Nickname *string `json:"nickname"`
|
||||
Phone *string `json:"phone"`
|
||||
WechatID *string `json:"wechatId"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Tags *string `json:"tags"` // JSON 数组字符串,如 ["创业者","电商"],存 ckb_tags
|
||||
HasFullBook *bool `json:"hasFullBook"`
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
Earnings *float64 `json:"earnings"`
|
||||
@@ -763,6 +814,9 @@ func DBUsersAction(c *gin.Context) {
|
||||
if body.Avatar != nil {
|
||||
updates["avatar"] = *body.Avatar
|
||||
}
|
||||
if body.Tags != nil {
|
||||
updates["ckb_tags"] = *body.Tags
|
||||
}
|
||||
if body.HasFullBook != nil {
|
||||
updates["has_full_book"] = *body.HasFullBook
|
||||
}
|
||||
@@ -855,7 +909,26 @@ func DBUsersDelete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
|
||||
return
|
||||
}
|
||||
if err := database.DB().Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
|
||||
db := database.DB()
|
||||
cleanupTables := []struct{ table, col string }{
|
||||
{"match_records", "user_id"},
|
||||
{"reading_progress", "user_id"},
|
||||
{"user_tracks", "user_id"},
|
||||
{"referral_bindings", "referrer_id"},
|
||||
{"referral_bindings", "referee_id"},
|
||||
{"referral_visits", "visitor_id"},
|
||||
{"ckb_submit_records", "user_id"},
|
||||
{"ckb_lead_records", "user_id"},
|
||||
{"user_addresses", "user_id"},
|
||||
{"user_balances", "user_id"},
|
||||
{"balance_transactions", "user_id"},
|
||||
{"withdrawals", "user_id"},
|
||||
{"orders", "user_id"},
|
||||
}
|
||||
for _, t := range cleanupTables {
|
||||
db.Exec("DELETE FROM "+t.table+" WHERE "+t.col+" = ?", id)
|
||||
}
|
||||
if err := db.Where("id = ?", id).Delete(&model.User{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
@@ -13,29 +17,273 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 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", "updated_at",
|
||||
"hot_score_override",
|
||||
// naturalLessSectionID 对章节 id(如 9.1、9.2、9.10)做自然排序,避免 9.1 < 9.10 < 9.2 的字典序问题
|
||||
func naturalLessSectionID(a, b string) bool {
|
||||
partsA := strings.Split(a, ".")
|
||||
partsB := strings.Split(b, ".")
|
||||
for i := 0; i < len(partsA) && i < len(partsB); i++ {
|
||||
na, errA := strconv.Atoi(partsA[i])
|
||||
nb, errB := strconv.Atoi(partsB[i])
|
||||
if errA != nil || errB != nil {
|
||||
if partsA[i] != partsB[i] {
|
||||
return partsA[i] < partsB[i]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if na != nb {
|
||||
return na < nb
|
||||
}
|
||||
}
|
||||
return len(partsA) < len(partsB)
|
||||
}
|
||||
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰),含点击、付款与热度排名
|
||||
// listSelectCols 列表/导出不加载 content,大幅加速
|
||||
var listSelectCols = []string{
|
||||
"id", "mid", "section_title", "price", "is_free", "is_new",
|
||||
"part_id", "part_title", "chapter_id", "chapter_title", "sort_order",
|
||||
"hot_score", "updated_at",
|
||||
}
|
||||
|
||||
// sectionListItem 与前端 SectionListItem 一致(小写驼峰)
|
||||
type sectionListItem struct {
|
||||
ID string `json:"id"`
|
||||
MID int `json:"mid,omitempty"` // 自增主键,小程序跳转用
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree,omitempty"`
|
||||
IsNew *bool `json:"isNew,omitempty"`
|
||||
IsNew *bool `json:"isNew,omitempty"` // stitch_soul:标记最新新增
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
FilePath *string `json:"filePath,omitempty"`
|
||||
ClickCount int `json:"clickCount,omitempty"` // 阅读/点击次数(reading_progress 条数)
|
||||
PayCount int `json:"payCount,omitempty"` // 付款笔数(orders 已支付)
|
||||
HotScore float64 `json:"hotScore,omitempty"` // 热度积分(文章排名算法算出)
|
||||
HotRank int `json:"hotRank,omitempty"` // 热度排名(1=最高)
|
||||
ClickCount int64 `json:"clickCount"` // 阅读次数(reading_progress)
|
||||
PayCount int64 `json:"payCount"` // 付款笔数(orders.product_type=section)
|
||||
HotScore float64 `json:"hotScore"` // 热度积分(加权计算)
|
||||
IsPinned bool `json:"isPinned,omitempty"` // 是否置顶(仅 ranking 返回)
|
||||
}
|
||||
|
||||
// computeSectionListWithHotScore 计算章节列表(含 hotScore),保持 sort_order 顺序,供 章节管理 树使用
|
||||
func computeSectionListWithHotScore(db *gorm.DB) ([]sectionListItem, error) {
|
||||
sections, err := computeSectionsWithHotScore(db, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// computeArticleRankingSections 统一计算内容排行榜:置顶优先 + 按 hotScore 降序
|
||||
// 供管理端内容排行榜页与小程序首页精选推荐共用,排序与置顶均在后端计算
|
||||
func computeArticleRankingSections(db *gorm.DB) ([]sectionListItem, error) {
|
||||
sections, err := computeSectionsWithHotScore(db, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 读取置顶配置 pinned_section_ids
|
||||
pinnedIDs := []string{}
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
|
||||
_ = json.Unmarshal(cfg.ConfigValue, &pinnedIDs)
|
||||
}
|
||||
pinnedSet := make(map[string]int) // id -> 置顶顺序
|
||||
for i, id := range pinnedIDs {
|
||||
if id != "" {
|
||||
pinnedSet[id] = i
|
||||
}
|
||||
}
|
||||
// 排序:置顶优先(按置顶顺序),其次按 hotScore 降序
|
||||
sort.Slice(sections, func(i, j int) bool {
|
||||
pi, pj := pinnedSet[sections[i].ID], pinnedSet[sections[j].ID]
|
||||
piOk, pjOk := sections[i].IsPinned, sections[j].IsPinned
|
||||
if piOk && !pjOk {
|
||||
return true
|
||||
}
|
||||
if !piOk && pjOk {
|
||||
return false
|
||||
}
|
||||
if piOk && pjOk {
|
||||
return pi < pj
|
||||
}
|
||||
return sections[i].HotScore > sections[j].HotScore
|
||||
})
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// computeSectionsWithHotScore 内部:按排名分算法计算 hotScore
|
||||
// 热度积分 = 阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分
|
||||
// 阅读量前20名: 第1名=20分...第20名=1分;最近更新前30篇: 第1名=30分...第30名=1分;付款数前20名: 第1名=20分...第20名=1分
|
||||
func computeSectionsWithHotScore(db *gorm.DB, setPinned bool) ([]sectionListItem, error) {
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 同 sort_order 时按 id 自然排序(9.1 < 9.2 < 9.10),避免字典序 9.1 < 9.10 < 9.2
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
soI, soJ := 999999, 999999
|
||||
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 naturalLessSectionID(rows[i].ID, rows[j].ID)
|
||||
})
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
readCountMap := make(map[string]int64)
|
||||
if len(ids) > 0 {
|
||||
var rp []struct {
|
||||
SectionID string `gorm:"column:section_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
|
||||
Where("section_id IN ?", ids).Group("section_id").Scan(&rp)
|
||||
for _, r := range rp {
|
||||
readCountMap[r.SectionID] = r.Cnt
|
||||
}
|
||||
}
|
||||
payCountMap := make(map[string]int64)
|
||||
if len(ids) > 0 {
|
||||
var op []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
db.Model(&model.Order{}).
|
||||
Select("product_id, COUNT(*) as cnt").
|
||||
Where("product_type = ? AND product_id IN ? AND status IN ?", "section", ids, []string{"paid", "completed", "success"}).
|
||||
Group("product_id").Scan(&op)
|
||||
for _, r := range op {
|
||||
payCountMap[r.ProductID] = r.Cnt
|
||||
}
|
||||
}
|
||||
readWeight, payWeight, recencyWeight := 0.1, 0.4, 0.5 // 默认与截图一致
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "article_ranking_weights").First(&cfg).Error; err == nil && len(cfg.ConfigValue) > 0 {
|
||||
var v struct {
|
||||
ReadWeight float64 `json:"readWeight"`
|
||||
RecencyWeight float64 `json:"recencyWeight"`
|
||||
PayWeight float64 `json:"payWeight"`
|
||||
}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &v); err == nil {
|
||||
if v.ReadWeight >= 0 {
|
||||
readWeight = v.ReadWeight
|
||||
}
|
||||
if v.PayWeight >= 0 {
|
||||
payWeight = v.PayWeight
|
||||
}
|
||||
if v.RecencyWeight >= 0 {
|
||||
recencyWeight = v.RecencyWeight
|
||||
}
|
||||
}
|
||||
}
|
||||
weightSum := readWeight + payWeight + recencyWeight
|
||||
if weightSum > 3 {
|
||||
readWeight = readWeight / weightSum
|
||||
payWeight = payWeight / weightSum
|
||||
recencyWeight = recencyWeight / weightSum
|
||||
}
|
||||
pinnedIDs := []string{}
|
||||
if setPinned {
|
||||
var cfg2 model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "pinned_section_ids").First(&cfg2).Error; err == nil && len(cfg2.ConfigValue) > 0 {
|
||||
_ = json.Unmarshal(cfg2.ConfigValue, &pinnedIDs)
|
||||
}
|
||||
}
|
||||
pinnedSet := make(map[string]bool)
|
||||
for _, id := range pinnedIDs {
|
||||
if id != "" {
|
||||
pinnedSet[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 阅读量排名:按 readCount 降序,前20名得 20~1 分
|
||||
type idCnt struct {
|
||||
id string
|
||||
cnt int64
|
||||
}
|
||||
readRank := make([]idCnt, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
readRank = append(readRank, idCnt{r.ID, readCountMap[r.ID]})
|
||||
}
|
||||
sort.Slice(readRank, func(i, j int) bool { return readRank[i].cnt > readRank[j].cnt })
|
||||
readRankScoreMap := make(map[string]float64)
|
||||
for i := 0; i < len(readRank) && i < 20; i++ {
|
||||
readRankScoreMap[readRank[i].id] = float64(20 - i)
|
||||
}
|
||||
|
||||
// 2. 新度排名:按 updated_at 降序(最近更新在前),前30篇得 30~1 分
|
||||
recencyRank := make([]struct {
|
||||
id string
|
||||
updatedAt time.Time
|
||||
}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
recencyRank = append(recencyRank, struct {
|
||||
id string
|
||||
updatedAt time.Time
|
||||
}{r.ID, r.UpdatedAt})
|
||||
}
|
||||
sort.Slice(recencyRank, func(i, j int) bool {
|
||||
return recencyRank[i].updatedAt.After(recencyRank[j].updatedAt)
|
||||
})
|
||||
recencyRankScoreMap := make(map[string]float64)
|
||||
for i := 0; i < len(recencyRank) && i < 30; i++ {
|
||||
recencyRankScoreMap[recencyRank[i].id] = float64(30 - i)
|
||||
}
|
||||
|
||||
// 3. 付款数排名:按 payCount 降序,前20名得 20~1 分
|
||||
payRank := make([]idCnt, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
payRank = append(payRank, idCnt{r.ID, payCountMap[r.ID]})
|
||||
}
|
||||
sort.Slice(payRank, func(i, j int) bool { return payRank[i].cnt > payRank[j].cnt })
|
||||
payRankScoreMap := make(map[string]float64)
|
||||
for i := 0; i < len(payRank) && i < 20; i++ {
|
||||
payRankScoreMap[payRank[i].id] = float64(20 - i)
|
||||
}
|
||||
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
if r.Price != nil {
|
||||
price = *r.Price
|
||||
}
|
||||
readCnt := readCountMap[r.ID]
|
||||
payCnt := payCountMap[r.ID]
|
||||
readRankScore := readRankScoreMap[r.ID]
|
||||
recencyRankScore := recencyRankScoreMap[r.ID]
|
||||
payRankScore := payRankScoreMap[r.ID]
|
||||
// 热度积分 = 阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分
|
||||
hot := readWeight*readRankScore + recencyWeight*recencyRankScore + payWeight*payRankScore
|
||||
// 若章节有手动覆盖的 hot_score(>0),则优先使用
|
||||
if r.HotScore > 0 {
|
||||
hot = float64(r.HotScore)
|
||||
}
|
||||
item := sectionListItem{
|
||||
ID: r.ID,
|
||||
MID: r.MID,
|
||||
Title: r.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: r.IsFree,
|
||||
IsNew: r.IsNew,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
ChapterTitle: r.ChapterTitle,
|
||||
ClickCount: readCnt,
|
||||
PayCount: payCnt,
|
||||
HotScore: hot,
|
||||
}
|
||||
if setPinned {
|
||||
item.IsPinned = pinnedSet[r.ID]
|
||||
}
|
||||
sections = append(sections, item)
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
// DBBookAction GET/POST/PUT /api/db/book
|
||||
@@ -47,148 +295,65 @@ func DBBookAction(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
switch action {
|
||||
case "list":
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
// 章节管理树:按 sort_order 顺序,含 hotScore
|
||||
sections, err := computeSectionListWithHotScore(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.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 {
|
||||
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").Where("section_id IN ?", ids).Group("section_id").Scan(&readCounts)
|
||||
}
|
||||
readMap := make(map[string]int)
|
||||
for _, x := range readCounts {
|
||||
readMap[x.SectionID] = int(x.Cnt)
|
||||
}
|
||||
// 付款笔数:与 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 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
|
||||
}
|
||||
overrideMap := make(map[string]float64)
|
||||
for _, r := range rows {
|
||||
if r.HotScoreOverride != nil && *r.HotScoreOverride > 0 {
|
||||
overrideMap[r.ID] = *r.HotScoreOverride
|
||||
}
|
||||
}
|
||||
totals := make([]idTotal, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
t := readWeight*readScore[r.ID] + recencyWeight*recencyScore[r.ID] + payWeight*payScore[r.ID]
|
||||
if v, ok := overrideMap[r.ID]; ok {
|
||||
t = v
|
||||
}
|
||||
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
|
||||
if r.Price != nil {
|
||||
price = *r.Price
|
||||
}
|
||||
sections = append(sections, sectionListItem{
|
||||
ID: r.ID,
|
||||
Title: r.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: r.IsFree,
|
||||
IsNew: r.IsNew,
|
||||
PartID: r.PartID,
|
||||
PartTitle: r.PartTitle,
|
||||
ChapterID: r.ChapterID,
|
||||
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)})
|
||||
return
|
||||
case "ranking":
|
||||
// 内容排行榜:置顶优先 + hotScore 降序,排序由后端统一计算,前端只展示
|
||||
sections, err := computeArticleRankingSections(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "sections": []sectionListItem{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
|
||||
return
|
||||
case "read":
|
||||
midStr := c.Query("mid")
|
||||
if midStr != "" {
|
||||
// 优先用 mid 获取(管理端编辑、小程序跳转推荐)
|
||||
mid, err := strconv.Atoi(midStr)
|
||||
if err != nil || mid < 1 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "mid 必须为正整数"})
|
||||
return
|
||||
}
|
||||
var ch model.Chapter
|
||||
if err := db.Where("mid = ?", mid).First(&ch).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
price := 1.0
|
||||
if ch.Price != nil {
|
||||
price = *ch.Price
|
||||
}
|
||||
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,
|
||||
"editionStandard": ch.EditionStandard,
|
||||
"editionPremium": ch.EditionPremium,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或 mid"})
|
||||
return
|
||||
}
|
||||
var ch model.Chapter
|
||||
@@ -207,15 +372,17 @@ 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
|
||||
@@ -235,10 +402,23 @@ func DBBookAction(c *gin.Context) {
|
||||
return
|
||||
case "export":
|
||||
var rows []model.Chapter
|
||||
if err := db.Select(listSelectCols).Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
if err := db.Select(listSelectCols).Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
soI, soJ := 999999, 999999
|
||||
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 naturalLessSectionID(rows[i].ID, rows[j].ID)
|
||||
})
|
||||
sections := make([]sectionListItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
price := 1.0
|
||||
@@ -267,6 +447,8 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
switch body.Action {
|
||||
case "sync":
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成(Gin 无文件源时可从 DB 已存在数据视为已同步)"})
|
||||
return
|
||||
case "import":
|
||||
@@ -280,20 +462,24 @@ func DBBookAction(c *gin.Context) {
|
||||
if item.IsFree != nil {
|
||||
isFree = *item.IsFree
|
||||
}
|
||||
wordCount := len(item.Content)
|
||||
processed, _ := ParseAutoLinkContent(item.Content)
|
||||
wordCount := len(processed)
|
||||
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: processed,
|
||||
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 {
|
||||
@@ -311,8 +497,11 @@ func DBBookAction(c *gin.Context) {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cache.InvalidateChapterContentByID(item.ID)
|
||||
imported++
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
|
||||
return
|
||||
default:
|
||||
@@ -326,21 +515,24 @@ func DBBookAction(c *gin.Context) {
|
||||
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"`
|
||||
NewID string `json:"newId"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Price *float64 `json:"price"`
|
||||
IsFree *bool `json:"isFree"`
|
||||
IsNew *bool `json:"isNew"`
|
||||
HotScore *float64 `json:"hotScore"`
|
||||
EditionStandard *bool `json:"editionStandard"`
|
||||
EditionPremium *bool `json:"editionPremium"`
|
||||
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"`
|
||||
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": "请求体无效"})
|
||||
@@ -373,6 +565,8 @@ func DBBookAction(c *gin.Context) {
|
||||
}
|
||||
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
}()
|
||||
return
|
||||
}
|
||||
@@ -387,6 +581,8 @@ func DBBookAction(c *gin.Context) {
|
||||
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error
|
||||
}
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
}()
|
||||
return
|
||||
}
|
||||
@@ -410,6 +606,8 @@ func DBBookAction(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
|
||||
return
|
||||
}
|
||||
@@ -425,10 +623,16 @@ func DBBookAction(c *gin.Context) {
|
||||
if body.IsFree != nil {
|
||||
isFree = *body.IsFree
|
||||
}
|
||||
wordCount := len(body.Content)
|
||||
// 后端统一解析 @/# 并转为带 data-id 的 span
|
||||
processedContent, err := ParseAutoLinkContent(body.Content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "解析 @/# 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
wordCount := len(processedContent)
|
||||
updates := map[string]interface{}{
|
||||
"section_title": body.Title,
|
||||
"content": body.Content,
|
||||
"content": processedContent,
|
||||
"word_count": wordCount,
|
||||
"price": price,
|
||||
"is_free": isFree,
|
||||
@@ -436,23 +640,98 @@ 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
|
||||
}
|
||||
if body.HotScore != nil {
|
||||
updates["hot_score_override"] = *body.HotScore
|
||||
updates["hot_score"] = *body.HotScore
|
||||
}
|
||||
if body.NewID != "" && body.NewID != body.ID {
|
||||
updates["id"] = body.NewID
|
||||
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: processedContent,
|
||||
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
|
||||
}
|
||||
cache.InvalidateChapterContent(ch.MID)
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
cache.InvalidateChapterContentByID(body.ID)
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
return
|
||||
}
|
||||
@@ -493,9 +772,12 @@ func DBBookDelete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
|
||||
return
|
||||
}
|
||||
cache.InvalidateChapterContentByID(id)
|
||||
if err := database.DB().Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cache.InvalidateBookParts()
|
||||
cache.InvalidateBookCache()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
240
soul-api/internal/handler/db_ckb_leads.go
Normal file
240
soul-api/internal/handler/db_ckb_leads.go
Normal file
@@ -0,0 +1,240 @@
|
||||
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_records(join/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
|
||||
}
|
||||
|
||||
dedup := c.DefaultQuery("dedup", "true")
|
||||
|
||||
if mode == "contact" {
|
||||
q := db.Model(&model.CkbLeadRecord{})
|
||||
var total int64
|
||||
var records []model.CkbLeadRecord
|
||||
if dedup == "true" {
|
||||
subQ := db.Model(&model.CkbLeadRecord{}).
|
||||
Select("MAX(id) as id").
|
||||
Group("COALESCE(NULLIF(user_id,''), COALESCE(NULLIF(phone,''), COALESCE(NULLIF(wechat_id,''), CAST(id AS CHAR))))")
|
||||
q = db.Model(&model.CkbLeadRecord{}).Where("id IN (?)", subQ)
|
||||
}
|
||||
q.Count(&total)
|
||||
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
|
||||
}
|
||||
|
||||
q := db.Model(&model.CkbSubmitRecord{})
|
||||
if matchType != "" {
|
||||
if matchType == "join" || matchType == "match" {
|
||||
q = q.Where("action = ?", matchType)
|
||||
}
|
||||
}
|
||||
if dedup == "true" {
|
||||
subQ := db.Model(&model.CkbSubmitRecord{}).
|
||||
Select("MAX(id) as id").
|
||||
Group("COALESCE(NULLIF(user_id,''), CAST(id AS CHAR))")
|
||||
if matchType == "join" || matchType == "match" {
|
||||
subQ = subQ.Where("action = ?", matchType)
|
||||
}
|
||||
q = db.Model(&model.CkbSubmitRecord{}).Where("id IN (?)", subQ)
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// CKBPersonLeadStats GET /api/db/ckb-person-leads 每个人物的获客线索统计及明细
|
||||
func CKBPersonLeadStats(c *gin.Context) {
|
||||
db := database.DB()
|
||||
personToken := c.Query("token")
|
||||
|
||||
if personToken != "" {
|
||||
// 返回某人物的线索明细(通过 token → Person → 用 PersonID 和 Token 匹配 CkbLeadRecord.TargetPersonID)
|
||||
var person model.Person
|
||||
if err := db.Where("token = ?", personToken).First(&person).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
q := db.Model(&model.CkbLeadRecord{}).Where("target_person_id IN ?", []string{person.PersonID, person.Token})
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var records []model.CkbLeadRecord
|
||||
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records)
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"userId": r.UserID,
|
||||
"nickname": r.Nickname,
|
||||
"phone": r.Phone,
|
||||
"wechatId": r.WechatID,
|
||||
"name": r.Name,
|
||||
"source": r.Source,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"personName": person.Name,
|
||||
"records": out,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 无 token 参数:返回所有人物的获客数量汇总
|
||||
type PersonLeadStat struct {
|
||||
TargetPersonID string `gorm:"column:target_person_id"`
|
||||
Total int64 `gorm:"column:total"`
|
||||
}
|
||||
var stats []PersonLeadStat
|
||||
db.Raw("SELECT target_person_id, COUNT(*) as total FROM ckb_lead_records WHERE target_person_id != '' GROUP BY target_person_id").Scan(&stats)
|
||||
|
||||
// 构建 personId/token → Person.Token 的映射,使前端能用 token 匹配
|
||||
var persons []model.Person
|
||||
db.Select("person_id, token").Find(&persons)
|
||||
pidToToken := make(map[string]string, len(persons))
|
||||
for _, p := range persons {
|
||||
pidToToken[p.PersonID] = p.Token
|
||||
pidToToken[p.Token] = p.Token
|
||||
}
|
||||
merged := make(map[string]int64)
|
||||
for _, s := range stats {
|
||||
key := pidToToken[s.TargetPersonID]
|
||||
if key == "" {
|
||||
key = s.TargetPersonID
|
||||
}
|
||||
merged[key] += s.Total
|
||||
}
|
||||
byPerson := make([]gin.H, 0, len(merged))
|
||||
for token, total := range merged {
|
||||
byPerson = append(byPerson, gin.H{"token": token, "total": total})
|
||||
}
|
||||
|
||||
// 同时统计全局(无特定人物的)线索
|
||||
var globalTotal int64
|
||||
db.Model(&model.CkbLeadRecord{}).Where("target_person_id = '' OR target_person_id IS NULL").Count(&globalTotal)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"byPerson": byPerson,
|
||||
"globalLeads": globalTotal,
|
||||
})
|
||||
}
|
||||
|
||||
// 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{},
|
||||
},
|
||||
})
|
||||
}
|
||||
99
soul-api/internal/handler/db_link_tag.go
Normal file
99
soul-api/internal/handler/db_link_tag.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"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"`
|
||||
Aliases string `json:"aliases"`
|
||||
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.Label == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 必填"})
|
||||
return
|
||||
}
|
||||
if !isValidNameOrLabel(strings.TrimSpace(body.Label)) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "label 只能包含汉字/字母/数字,不能为纯符号"})
|
||||
return
|
||||
}
|
||||
if body.TagID == "" {
|
||||
body.TagID = body.Label
|
||||
}
|
||||
if body.Type == "" {
|
||||
body.Type = "url"
|
||||
}
|
||||
// 小程序类型:只存 appId + pagePath,不存 weixin:// 到 url
|
||||
if body.Type == "miniprogram" {
|
||||
body.URL = ""
|
||||
}
|
||||
db := database.DB()
|
||||
var existing model.LinkTag
|
||||
// 按 label 查找:文章编辑自动创建场景,若已存在则直接返回
|
||||
if db.Where("label = ?", body.Label).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
return
|
||||
}
|
||||
if db.Where("tag_id = ?", body.TagID).First(&existing).Error == nil {
|
||||
existing.Label = body.Label
|
||||
existing.Aliases = body.Aliases
|
||||
existing.URL = body.URL
|
||||
existing.Type = body.Type
|
||||
existing.AppID = body.AppID
|
||||
existing.PagePath = body.PagePath
|
||||
db.Save(&existing)
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
|
||||
return
|
||||
}
|
||||
// body.URL 已在 miniprogram 类型时置空
|
||||
t := model.LinkTag{TagID: body.TagID, Label: body.Label, Aliases: body.Aliases, 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
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
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
|
||||
}
|
||||
cache.InvalidateConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
@@ -20,11 +25,37 @@ func DBPersonList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "persons": rows})
|
||||
}
|
||||
|
||||
// DBPersonDetail GET /api/db/person 管理端-单个人物详情(编辑回显用)
|
||||
func DBPersonDetail(c *gin.Context) {
|
||||
pid := c.Query("personId")
|
||||
if pid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
|
||||
return
|
||||
}
|
||||
var row model.Person
|
||||
if err := database.DB().Where("person_id = ?", pid).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": row})
|
||||
}
|
||||
|
||||
// DBPersonSave POST /api/db/persons 管理端-新增或更新人物
|
||||
// 新增时自动生成 32 位唯一 token,文章 @ 时存 token,小程序点击时用 token 兑换真实密钥
|
||||
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
|
||||
Greeting string `json:"greeting"`
|
||||
Tips string `json:"tips"`
|
||||
RemarkType string `json:"remarkType"`
|
||||
RemarkFormat string `json:"remarkFormat"`
|
||||
AddFriendInterval *int `json:"addFriendInterval"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
DeviceGroups []int64 `json:"deviceGroups"` // 设备ID列表,由管理端选择后传入
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
@@ -34,97 +65,271 @@ func DBPersonSave(c *gin.Context) {
|
||||
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())
|
||||
if !isValidNameOrLabel(strings.TrimSpace(body.Name)) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "name 只能包含汉字/字母/数字,不能为纯符号"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var existing model.Person
|
||||
// 按 name 查找:文章编辑自动创建场景,PersonID 为空时先查是否已存在
|
||||
if body.PersonID == "" {
|
||||
if db.Where("name = ?", strings.TrimSpace(body.Name)).First(&existing).Error == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.PersonID == "" {
|
||||
body.PersonID = fmt.Sprintf("%s_%d", strings.ToLower(strings.ReplaceAll(strings.TrimSpace(body.Name), " ", "_")), time.Now().UnixMilli())
|
||||
}
|
||||
if db.Where("person_id = ?", body.PersonID).First(&existing).Error == nil {
|
||||
existing.Name = body.Name
|
||||
existing.Label = body.Label
|
||||
existing.CkbApiKey = body.CkbApiKey
|
||||
existing.Greeting = body.Greeting
|
||||
existing.Tips = body.Tips
|
||||
existing.RemarkType = body.RemarkType
|
||||
existing.RemarkFormat = body.RemarkFormat
|
||||
if body.AddFriendInterval != nil && *body.AddFriendInterval > 0 {
|
||||
existing.AddFriendInterval = *body.AddFriendInterval
|
||||
}
|
||||
if strings.TrimSpace(body.StartTime) != "" {
|
||||
existing.StartTime = strings.TrimSpace(body.StartTime)
|
||||
}
|
||||
if strings.TrimSpace(body.EndTime) != "" {
|
||||
existing.EndTime = strings.TrimSpace(body.EndTime)
|
||||
}
|
||||
if len(body.DeviceGroups) > 0 {
|
||||
ids := make([]string, 0, len(body.DeviceGroups))
|
||||
for _, id := range body.DeviceGroups {
|
||||
if id > 0 {
|
||||
ids = append(ids, fmt.Sprintf("%d", id))
|
||||
}
|
||||
}
|
||||
existing.DeviceGroups = strings.Join(ids, ",")
|
||||
} else {
|
||||
existing.DeviceGroups = ""
|
||||
}
|
||||
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 {
|
||||
|
||||
// 新增:创建本地 Person 记录前,先在存客宝创建获客计划并获取 planId + apiKey
|
||||
tok, err := genPersonToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成 token 失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 获取开放 API token
|
||||
openToken, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": p})
|
||||
|
||||
// 2. 构造创建计划请求体
|
||||
name := fmt.Sprintf("SOUL链接人与事-%s", body.Name)
|
||||
addInterval := 1
|
||||
if body.AddFriendInterval != nil && *body.AddFriendInterval > 0 {
|
||||
addInterval = *body.AddFriendInterval
|
||||
}
|
||||
startTime := "09:00"
|
||||
if strings.TrimSpace(body.StartTime) != "" {
|
||||
startTime = strings.TrimSpace(body.StartTime)
|
||||
}
|
||||
endTime := "18:00"
|
||||
if strings.TrimSpace(body.EndTime) != "" {
|
||||
endTime = strings.TrimSpace(body.EndTime)
|
||||
}
|
||||
deviceIDs := make([]int64, 0, len(body.DeviceGroups))
|
||||
for _, id := range body.DeviceGroups {
|
||||
if id > 0 {
|
||||
deviceIDs = append(deviceIDs, id)
|
||||
}
|
||||
}
|
||||
if len(deviceIDs) == 0 {
|
||||
defaultID, err := ckbOpenGetDefaultDeviceID(openToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "获取默认设备失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
deviceIDs = []int64{defaultID}
|
||||
}
|
||||
planPayload := map[string]interface{}{
|
||||
"name": name,
|
||||
"planType": 1,
|
||||
"sceneId": 9,
|
||||
"scenario": 9,
|
||||
"status": 1,
|
||||
"remarkType": body.RemarkType,
|
||||
"greeting": body.Greeting,
|
||||
"addInterval": addInterval,
|
||||
"startTime": startTime,
|
||||
"endTime": endTime,
|
||||
"enabled": true,
|
||||
"tips": body.Tips,
|
||||
"distributionEnabled": false,
|
||||
"deviceGroups": deviceIDs,
|
||||
}
|
||||
|
||||
planID, ckbCreateData, ckbResponse, err := ckbOpenCreatePlan(openToken, planPayload)
|
||||
if err != nil {
|
||||
out := gin.H{"success": false, "error": "创建存客宝计划失败: " + err.Error()}
|
||||
if ckbResponse != nil {
|
||||
out["ckbResponse"] = ckbResponse
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := parseApiKeyFromCreateData(ckbCreateData)
|
||||
if apiKey == "" {
|
||||
var getErr error
|
||||
apiKey, getErr = ckbOpenGetPlanDetail(openToken, planID)
|
||||
if getErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建成功但获取计划密钥失败: " + getErr.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
newPerson := model.Person{
|
||||
PersonID: body.PersonID,
|
||||
Token: tok,
|
||||
Name: body.Name,
|
||||
Label: body.Label,
|
||||
CkbApiKey: apiKey,
|
||||
CkbPlanID: planID,
|
||||
Greeting: body.Greeting,
|
||||
Tips: body.Tips,
|
||||
RemarkType: body.RemarkType,
|
||||
RemarkFormat: body.RemarkFormat,
|
||||
AddFriendInterval: addInterval,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
}
|
||||
idsStr := make([]string, 0, len(deviceIDs))
|
||||
for _, id := range deviceIDs {
|
||||
idsStr = append(idsStr, fmt.Sprintf("%d", id))
|
||||
}
|
||||
newPerson.DeviceGroups = strings.Join(idsStr, ",")
|
||||
|
||||
if err := db.Create(&newPerson).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
resp := gin.H{"success": true, "person": newPerson}
|
||||
if len(ckbCreateData) > 0 {
|
||||
resp["ckbCreateResult"] = ckbCreateData
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// createPersonMinimal 仅按 name 创建 Person(含存客宝计划),供 autolink 复用
|
||||
func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("name 必填")
|
||||
}
|
||||
personID := fmt.Sprintf("%s_%d", strings.ToLower(strings.ReplaceAll(name, " ", "_")), time.Now().UnixMilli())
|
||||
tok, err := genPersonToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成 token 失败")
|
||||
}
|
||||
openToken, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
planName := fmt.Sprintf("SOUL链接人与事-%s", name)
|
||||
defaultID, err := ckbOpenGetDefaultDeviceID(openToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取默认设备失败: %w", err)
|
||||
}
|
||||
planPayload := map[string]interface{}{
|
||||
"name": planName,
|
||||
"planType": 1,
|
||||
"sceneId": 9,
|
||||
"scenario": 9,
|
||||
"status": 1,
|
||||
"addInterval": 1,
|
||||
"startTime": "09:00",
|
||||
"endTime": "18:00",
|
||||
"enabled": true,
|
||||
"distributionEnabled": false,
|
||||
"deviceGroups": []int64{defaultID},
|
||||
}
|
||||
planID, createData, _, err := ckbOpenCreatePlan(openToken, planPayload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建存客宝计划失败: %w", err)
|
||||
}
|
||||
apiKey := parseApiKeyFromCreateData(createData)
|
||||
if apiKey == "" {
|
||||
var getErr error
|
||||
apiKey, getErr = ckbOpenGetPlanDetail(openToken, planID)
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("获取计划密钥失败: %w", getErr)
|
||||
}
|
||||
}
|
||||
newPerson := model.Person{
|
||||
PersonID: personID,
|
||||
Token: tok,
|
||||
Name: name,
|
||||
CkbApiKey: apiKey,
|
||||
CkbPlanID: planID,
|
||||
AddFriendInterval: 1,
|
||||
StartTime: "09:00",
|
||||
EndTime: "18:00",
|
||||
DeviceGroups: fmt.Sprintf("%d", defaultID),
|
||||
}
|
||||
if err := db.Create(&newPerson).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &newPerson, nil
|
||||
}
|
||||
|
||||
func genPersonToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := base64.URLEncoding.EncodeToString(b)
|
||||
s = strings.ReplaceAll(s, "+", "")
|
||||
s = strings.ReplaceAll(s, "/", "")
|
||||
s = strings.ReplaceAll(s, "=", "")
|
||||
if len(s) >= 32 {
|
||||
return s[:32], nil
|
||||
}
|
||||
return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil
|
||||
}
|
||||
|
||||
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
|
||||
// 若有 ckb_plan_id,先调存客宝删除计划,再删本地
|
||||
func DBPersonDelete(c *gin.Context) {
|
||||
pid := c.Query("personId")
|
||||
if pid == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 personId"})
|
||||
return
|
||||
}
|
||||
var row model.Person
|
||||
if err := database.DB().Where("person_id = ?", pid).First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
|
||||
return
|
||||
}
|
||||
// 若有存客宝计划,先调 CKB 删除
|
||||
if row.CkbPlanID > 0 {
|
||||
token, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "获取存客宝鉴权失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ckbOpenDeletePlan(token, row.CkbPlanID); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "删除存客宝计划失败: " + err.Error()})
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
493
soul-api/internal/handler/gift_pay.go
Normal file
493
soul-api/internal/handler/gift_pay.go
Normal file
@@ -0,0 +1,493 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
"soul-api/internal/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const giftPayExpireHours = 24
|
||||
|
||||
// giftPayPreviewContent 取内容前 20%,用于代付页营销展示
|
||||
func giftPayPreviewContent(content string) string {
|
||||
n := utf8.RuneCountInString(content)
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
limit := n * 20 / 100
|
||||
if limit < 50 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > n {
|
||||
limit = n
|
||||
}
|
||||
runes := []rune(content)
|
||||
if limit >= n {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:limit]) + "……"
|
||||
}
|
||||
|
||||
// GiftPayCreate POST /api/miniprogram/gift-pay/create 创建代付请求
|
||||
func GiftPayCreate(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
ProductType string `json:"productType" binding:"required"`
|
||||
ProductID string `json:"productId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 校验发起人
|
||||
var initiator model.User
|
||||
if err := db.Where("id = ?", req.UserID).First(&initiator).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 价格与商品校验
|
||||
productID := req.ProductID
|
||||
if productID == "" {
|
||||
switch req.ProductType {
|
||||
case "vip":
|
||||
productID = "vip_annual"
|
||||
case "match":
|
||||
productID = "match"
|
||||
case "fullbook":
|
||||
productID = "fullbook"
|
||||
}
|
||||
}
|
||||
amount, priceErr := getStandardPrice(db, req.ProductType, productID)
|
||||
if priceErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
|
||||
return
|
||||
}
|
||||
// 发起人若有推荐人绑定,享受好友优惠
|
||||
var referrerID *string
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT referrer_id FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if json.Unmarshal(cfg.ConfigValue, &config) == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
amount = amount * (1 - userDiscount/100)
|
||||
if amount < 0.01 {
|
||||
amount = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = referrerID // 分佣在 PayNotify 时按发起人计算
|
||||
|
||||
// 校验发起人是否已拥有
|
||||
if req.ProductType == "section" && productID != "" {
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND product_id = ? AND status IN ?",
|
||||
req.UserID, "section", productID, []string{"paid", "completed"}).Count(&cnt)
|
||||
if cnt > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有该章节"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.ProductType == "fullbook" || req.ProductType == "vip" {
|
||||
var u model.User
|
||||
db.Where("id = ?", req.UserID).Select("has_full_book", "is_vip", "vip_expire_date").First(&u)
|
||||
if u.HasFullBook != nil && *u.HasFullBook {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已拥有全书"})
|
||||
return
|
||||
}
|
||||
if req.ProductType == "vip" && u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已是有效VIP"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 描述
|
||||
desc := ""
|
||||
switch req.ProductType {
|
||||
case "fullbook":
|
||||
desc = "《一场Soul的创业实验》全书"
|
||||
case "vip":
|
||||
desc = "卡若创业派对VIP年度会员(365天)"
|
||||
case "match":
|
||||
desc = "购买匹配次数"
|
||||
case "section":
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title").Where("id = ?", productID).First(&ch).Error; err == nil && ch.SectionTitle != "" {
|
||||
desc = ch.SectionTitle
|
||||
} else {
|
||||
desc = fmt.Sprintf("章节-%s", productID)
|
||||
}
|
||||
default:
|
||||
desc = fmt.Sprintf("%s-%s", req.ProductType, productID)
|
||||
}
|
||||
|
||||
expireAt := time.Now().Add(giftPayExpireHours * time.Hour)
|
||||
requestSN := "GPR" + wechat.GenerateOrderSn()
|
||||
id := "gpr_" + fmt.Sprintf("%d", time.Now().UnixNano()%100000000000)
|
||||
|
||||
gpr := model.GiftPayRequest{
|
||||
ID: id,
|
||||
RequestSN: requestSN,
|
||||
InitiatorUserID: req.UserID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: productID,
|
||||
Amount: amount,
|
||||
Description: desc,
|
||||
Status: "pending",
|
||||
ExpireAt: expireAt,
|
||||
}
|
||||
if err := db.Create(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建失败"})
|
||||
return
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("pages/gift-pay/detail?requestSn=%s", requestSN)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"requestSn": requestSN,
|
||||
"path": path,
|
||||
"amount": amount,
|
||||
"expireAt": expireAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayDetail GET /api/miniprogram/gift-pay/detail?requestSn=xxx 代付详情(代付人用)
|
||||
func GiftPayDetail(c *gin.Context) {
|
||||
requestSn := strings.TrimSpace(c.Query("requestSn"))
|
||||
if requestSn == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少代付请求号"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ?", requestSn).First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if gpr.Status != "pending" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
db.Model(&gpr).Update("status", "expired")
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
// 发起人昵称(脱敏)
|
||||
var initiator model.User
|
||||
nickname := "好友"
|
||||
if err := db.Where("id = ?", gpr.InitiatorUserID).Select("nickname").First(&initiator).Error; err == nil && initiator.Nickname != nil {
|
||||
n := *initiator.Nickname
|
||||
if len(n) > 2 {
|
||||
n = string([]rune(n)[0]) + "**"
|
||||
}
|
||||
nickname = n
|
||||
}
|
||||
|
||||
// 营销:章节类型时返回标题和内容预览,吸引代付人
|
||||
sectionTitle := gpr.Description
|
||||
contentPreview := ""
|
||||
if gpr.ProductType == "section" && gpr.ProductID != "" {
|
||||
var ch model.Chapter
|
||||
if err := db.Select("section_title", "content").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
|
||||
if ch.SectionTitle != "" {
|
||||
sectionTitle = ch.SectionTitle
|
||||
}
|
||||
contentPreview = giftPayPreviewContent(ch.Content)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"requestSn": gpr.RequestSN,
|
||||
"productType": gpr.ProductType,
|
||||
"productId": gpr.ProductID,
|
||||
"amount": gpr.Amount,
|
||||
"description": gpr.Description,
|
||||
"sectionTitle": sectionTitle,
|
||||
"contentPreview": contentPreview,
|
||||
"initiatorNickname": nickname,
|
||||
"initiatorUserId": gpr.InitiatorUserID,
|
||||
"expireAt": gpr.ExpireAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayPay POST /api/miniprogram/gift-pay/pay 代付人发起支付
|
||||
func GiftPayPay(c *gin.Context) {
|
||||
var req struct {
|
||||
RequestSn string `json:"requestSn" binding:"required"`
|
||||
OpenID string `json:"openId" binding:"required"`
|
||||
UserID string `json:"userId"` // 代付人ID,用于校验不能自己付
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "pending").First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或已处理"})
|
||||
return
|
||||
}
|
||||
if time.Now().After(gpr.ExpireAt) {
|
||||
db.Model(&gpr).Update("status", "expired")
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
// 不能自己给自己代付
|
||||
if req.UserID != "" && req.UserID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取代付人信息
|
||||
var payer model.User
|
||||
if err := db.Where("open_id = ?", req.OpenID).First(&payer).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
|
||||
return
|
||||
}
|
||||
if payer.ID == gpr.InitiatorUserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不能为自己代付"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建订单(归属发起人,记录代付信息)
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
status := "created"
|
||||
pm := "wechat"
|
||||
productID := gpr.ProductID
|
||||
desc := gpr.Description
|
||||
gprID := gpr.ID
|
||||
payerID := payer.ID
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: gpr.InitiatorUserID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: gpr.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: gpr.Amount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
PaymentMethod: &pm,
|
||||
GiftPayRequestID: &gprID,
|
||||
PayerUserID: &payerID,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 唤起微信支付,attach 中 userId=发起人,giftPayRequestSn=请求号
|
||||
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s","giftPayRequestSn":"%s"}`,
|
||||
gpr.ProductType, gpr.ProductID, gpr.InitiatorUserID, gpr.RequestSN)
|
||||
totalFee := int(gpr.Amount * 100)
|
||||
ctx := c.Request.Context()
|
||||
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, "代付-"+gpr.Description, attach)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("支付请求失败: %v", err)})
|
||||
return
|
||||
}
|
||||
payParams, err := wechat.GetJSAPIPayParams(prepayID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成支付参数失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 预占:更新请求状态为 paying(可选,防并发)
|
||||
// 简化:不预占,PayNotify 时再更新
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"orderSn": orderSn,
|
||||
"prepayId": prepayID,
|
||||
"payParams": payParams,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GiftPayCancel POST /api/miniprogram/gift-pay/cancel 发起人取消
|
||||
func GiftPayCancel(c *gin.Context) {
|
||||
var req struct {
|
||||
RequestSn string `json:"requestSn" binding:"required"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var gpr model.GiftPayRequest
|
||||
if err := db.Where("request_sn = ?", req.RequestSn).First(&gpr).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"})
|
||||
return
|
||||
}
|
||||
if gpr.InitiatorUserID != req.UserID {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权取消"})
|
||||
return
|
||||
}
|
||||
if gpr.Status != "pending" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(&gpr).Update("status", "cancelled")
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已取消"})
|
||||
}
|
||||
|
||||
// GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的
|
||||
func GiftPayMyRequests(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var list []model.GiftPayRequest
|
||||
db.Where("initiator_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
|
||||
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
out = append(out, gin.H{
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"expireAt": r.ExpireAt.Format(time.RFC3339),
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
}
|
||||
|
||||
// GiftPayMyPayments GET /api/miniprogram/gift-pay/my-payments?userId= 我帮付的
|
||||
func GiftPayMyPayments(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少userId"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
var list []model.GiftPayRequest
|
||||
db.Where("payer_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
|
||||
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
out = append(out, gin.H{
|
||||
"requestSn": r.RequestSN,
|
||||
"productType": r.ProductType,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"createdAt": r.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
|
||||
}
|
||||
|
||||
// AdminGiftPayRequestsList GET /api/admin/gift-pay-requests 管理端-代付请求列表
|
||||
func AdminGiftPayRequestsList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
db := database.DB()
|
||||
q := db.Model(&model.GiftPayRequest{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var list []model.GiftPayRequest
|
||||
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list)
|
||||
userIDs := make(map[string]bool)
|
||||
for _, r := range list {
|
||||
userIDs[r.InitiatorUserID] = true
|
||||
if r.PayerUserID != nil && *r.PayerUserID != "" {
|
||||
userIDs[*r.PayerUserID] = true
|
||||
}
|
||||
}
|
||||
nicknames := make(map[string]string)
|
||||
if len(userIDs) > 0 {
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []model.User
|
||||
db.Select("id, nickname").Where("id IN ?", ids).Find(&users)
|
||||
for _, u := range users {
|
||||
if u.Nickname != nil {
|
||||
nicknames[u.ID] = *u.Nickname
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]gin.H, 0, len(list))
|
||||
for _, r := range list {
|
||||
initiatorNick := nicknames[r.InitiatorUserID]
|
||||
payerNick := ""
|
||||
if r.PayerUserID != nil {
|
||||
payerNick = nicknames[*r.PayerUserID]
|
||||
}
|
||||
orderID := ""
|
||||
if r.OrderID != nil {
|
||||
orderID = *r.OrderID
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID,
|
||||
"requestSn": r.RequestSN,
|
||||
"initiatorUserId": r.InitiatorUserID,
|
||||
"initiatorNick": initiatorNick,
|
||||
"productType": r.ProductType,
|
||||
"productId": r.ProductID,
|
||||
"amount": r.Amount,
|
||||
"description": r.Description,
|
||||
"status": r.Status,
|
||||
"payerUserId": r.PayerUserID,
|
||||
"payerNick": payerNick,
|
||||
"orderId": orderID,
|
||||
"expireAt": r.ExpireAt,
|
||||
"createdAt": r.CreatedAt,
|
||||
"updatedAt": r.UpdatedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": total})
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -13,10 +11,9 @@ import (
|
||||
)
|
||||
|
||||
// DBMatchRecordsList GET /api/db/match-records 管理端-匹配记录列表(分页、按类型筛选)
|
||||
// 当 ?stats=true 时返回汇总统计(总匹配数、今日匹配、按类型分布、独立用户数)
|
||||
// 当 ?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)
|
||||
@@ -30,14 +27,11 @@ func DBMatchRecordsList(c *gin.Context) {
|
||||
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)
|
||||
|
||||
// 匹配收益:product_type=match 且 status=paid 的订单金额总和
|
||||
var matchRevenue float64
|
||||
db.Model(&model.Order{}).Where("product_type = ? AND status = ?", "match", "paid").
|
||||
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 = ?", "match", "paid").Count(&paidMatchCount)
|
||||
|
||||
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{
|
||||
@@ -51,7 +45,6 @@ func DBMatchRecordsList(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
matchType := c.Query("matchType")
|
||||
@@ -77,8 +70,12 @@ func DBMatchRecordsList(c *gin.Context) {
|
||||
|
||||
userIDs := make(map[string]bool)
|
||||
for _, r := range records {
|
||||
userIDs[r.UserID] = true
|
||||
userIDs[r.MatchedUserID] = true
|
||||
if r.UserID != "" {
|
||||
userIDs[r.UserID] = true
|
||||
}
|
||||
if r.MatchedUserID != "" {
|
||||
userIDs[r.MatchedUserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
@@ -100,28 +97,35 @@ func DBMatchRecordsList(c *gin.Context) {
|
||||
return *s
|
||||
}
|
||||
|
||||
safeNickname := func(u *model.User) string {
|
||||
if u == nil || u.Nickname == nil { return "" }
|
||||
return *u.Nickname
|
||||
}
|
||||
safeAvatar := func(u *model.User) string {
|
||||
if u == nil || u.Avatar == nil { return "" }
|
||||
return *u.Avatar
|
||||
}
|
||||
|
||||
out := make([]gin.H, 0, len(records))
|
||||
for _, r := range records {
|
||||
u := userMap[r.UserID]
|
||||
mu := userMap[r.MatchedUserID]
|
||||
userAvatar := ""
|
||||
matchedUserAvatar := ""
|
||||
if u != nil && u.Avatar != nil {
|
||||
userAvatar = *u.Avatar
|
||||
}
|
||||
if mu != nil && mu.Avatar != nil {
|
||||
matchedUserAvatar = *mu.Avatar
|
||||
}
|
||||
userNickname := ""
|
||||
if u != nil {
|
||||
userNickname = getStr(u.Nickname)
|
||||
}
|
||||
matchedNickname := ""
|
||||
if mu != nil {
|
||||
matchedNickname = getStr(mu.Nickname)
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID, "userId": r.UserID, "matchedUserId": r.MatchedUserID,
|
||||
"matchType": r.MatchType, "phone": getStr(r.Phone), "wechatId": getStr(r.WechatID),
|
||||
"userNickname": safeNickname(u),
|
||||
"matchedNickname": safeNickname(mu),
|
||||
"userAvatar": safeAvatar(u),
|
||||
"matchedUserAvatar": safeAvatar(mu),
|
||||
"matchScore": r.MatchScore,
|
||||
"createdAt": r.CreatedAt,
|
||||
"userNickname": userNickname,
|
||||
"matchedNickname": matchedNickname,
|
||||
"userAvatar": userAvatar,
|
||||
"matchedUserAvatar": matchedUserAvatar,
|
||||
"matchScore": r.MatchScore,
|
||||
"createdAt": r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,34 +161,3 @@ func DBMatchPoolCounts(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DBMatchRecordInsertTest POST /api/db/match-records/test 插入测试匹配记录
|
||||
func DBMatchRecordInsertTest(c *gin.Context) {
|
||||
var body struct {
|
||||
MatchType string `json:"matchType"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if body.MatchType == "" {
|
||||
body.MatchType = "team"
|
||||
}
|
||||
if body.Phone == "" {
|
||||
body.Phone = "13800000000"
|
||||
}
|
||||
db := database.DB()
|
||||
rec := model.MatchRecord{
|
||||
ID: fmt.Sprintf("mr_test_%d", time.Now().UnixNano()),
|
||||
UserID: "admin_test",
|
||||
MatchType: body.MatchType,
|
||||
Phone: &body.Phone,
|
||||
}
|
||||
if body.WechatID != "" {
|
||||
rec.WechatID = &body.WechatID
|
||||
}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试记录已插入", "id": rec.ID})
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ func MiniprogramLogin(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
// 记录注册行为到 user_tracks
|
||||
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
db.Create(&model.UserTrack{ID: trackID, UserID: user.ID, Action: "register"})
|
||||
// 新用户:异步调用神射手自动打标(手机号尚未绑定,phone 为空时暂不调用)
|
||||
AdminShensheShouAutoTag(userID, "")
|
||||
} else {
|
||||
@@ -134,7 +137,7 @@ func MiniprogramLogin(c *gin.Context) {
|
||||
"id": user.ID,
|
||||
"openId": getStringValue(user.OpenID),
|
||||
"nickname": getStringValue(user.Nickname),
|
||||
"avatar": getStringValue(user.Avatar),
|
||||
"avatar": getUrlValue(user.Avatar),
|
||||
"phone": getStringValue(user.Phone),
|
||||
"wechatId": getStringValue(user.WechatID),
|
||||
"referralCode": getStringValue(user.ReferralCode),
|
||||
@@ -160,6 +163,86 @@ func MiniprogramLogin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// MiniprogramDevLoginAs POST /api/miniprogram/dev/login-as 开发专用:按 userId 切换账号(仅 APP_ENV=development 可用)
|
||||
func MiniprogramDevLoginAs(c *gin.Context) {
|
||||
if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) != "development" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "仅开发环境可用"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
userID := strings.TrimSpace(req.UserID)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "userId 不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
openID := getStringValue(user.OpenID)
|
||||
if openID == "" {
|
||||
openID = user.ID // 部分用户 id 即 openId
|
||||
}
|
||||
tokenSuffix := openID
|
||||
if len(openID) >= 8 {
|
||||
tokenSuffix = openID[len(openID)-8:]
|
||||
}
|
||||
token := fmt.Sprintf("tk_%s_%d", tokenSuffix, time.Now().Unix())
|
||||
|
||||
var purchasedSections []string
|
||||
var orderRows []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
}
|
||||
db.Raw(`SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'`, user.ID).Scan(&orderRows)
|
||||
for _, row := range orderRows {
|
||||
if row.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, row.ProductID)
|
||||
}
|
||||
}
|
||||
if purchasedSections == nil {
|
||||
purchasedSections = []string{}
|
||||
}
|
||||
|
||||
responseUser := map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"openId": openID,
|
||||
"nickname": getStringValue(user.Nickname),
|
||||
"avatar": getUrlValue(user.Avatar),
|
||||
"phone": getStringValue(user.Phone),
|
||||
"wechatId": getStringValue(user.WechatID),
|
||||
"referralCode": getStringValue(user.ReferralCode),
|
||||
"hasFullBook": getBoolValue(user.HasFullBook),
|
||||
"purchasedSections": purchasedSections,
|
||||
"earnings": getFloatValue(user.Earnings),
|
||||
"pendingEarnings": getFloatValue(user.PendingEarnings),
|
||||
"referralCount": getIntValue(user.ReferralCount),
|
||||
"createdAt": user.CreatedAt,
|
||||
}
|
||||
if user.IsVip != nil {
|
||||
responseUser["isVip"] = *user.IsVip
|
||||
}
|
||||
if user.VipExpireDate != nil {
|
||||
responseUser["vipExpireDate"] = user.VipExpireDate.Format("2006-01-02")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"openId": openID,
|
||||
"user": responseUser,
|
||||
"token": token,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
func getStringValue(ptr *string) string {
|
||||
if ptr == nil {
|
||||
@@ -168,6 +251,18 @@ func getStringValue(ptr *string) string {
|
||||
return *ptr
|
||||
}
|
||||
|
||||
// getUrlValue 取字符串指针值并修复缺少冒号的 URL("https//..." → "https://...")
|
||||
func getUrlValue(ptr *string) string {
|
||||
s := getStringValue(ptr)
|
||||
if strings.HasPrefix(s, "https//") {
|
||||
return "https://" + s[7:]
|
||||
}
|
||||
if strings.HasPrefix(s, "http//") {
|
||||
return "http://" + s[6:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func getBoolValue(ptr *bool) bool {
|
||||
if ptr == nil {
|
||||
return false
|
||||
@@ -222,54 +317,81 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 查询用户的有效推荐人(先查 binding,再查 referralCode)
|
||||
var finalAmount float64
|
||||
var orderSn string
|
||||
var referrerID *string
|
||||
if req.UserID != "" {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT referrer_id
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error
|
||||
if err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
}
|
||||
}
|
||||
if referrerID == nil && req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
}
|
||||
}
|
||||
|
||||
// 有推荐人时应用好友优惠(无论是 binding 还是 referralCode)
|
||||
finalAmount := req.Amount
|
||||
if referrerID != nil {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
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)
|
||||
if finalAmount < 0.01 {
|
||||
finalAmount = 0.01
|
||||
if req.ProductType == "balance_recharge" {
|
||||
// 充值:从已创建的订单取金额,productId=orderSn
|
||||
var existOrder model.Order
|
||||
if err := db.Where("order_sn = ? AND product_type = ? AND status = ?", req.ProductID, "balance_recharge", "created").First(&existOrder).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "充值订单不存在或已支付"})
|
||||
return
|
||||
}
|
||||
orderSn = existOrder.OrderSN
|
||||
finalAmount = existOrder.Amount
|
||||
if req.UserID != "" && existOrder.UserID != req.UserID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "订单用户不匹配"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// -------- 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
|
||||
}
|
||||
finalAmount = standardPrice
|
||||
|
||||
if req.UserID != "" {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT referrer_id
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error
|
||||
if err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
}
|
||||
}
|
||||
if referrerID == nil && req.ReferralCode != "" {
|
||||
var refUser model.User
|
||||
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
|
||||
referrerID = &refUser.ID
|
||||
}
|
||||
}
|
||||
if referrerID != nil {
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
|
||||
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
|
||||
discountRate := userDiscount / 100
|
||||
finalAmount = finalAmount * (1 - discountRate)
|
||||
if finalAmount < 0.01 {
|
||||
finalAmount = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
orderSn := wechat.GenerateOrderSn()
|
||||
totalFee := int(finalAmount * 100) // 转为分
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
if req.ProductType == "fullbook" {
|
||||
if req.ProductType == "balance_recharge" {
|
||||
description = fmt.Sprintf("余额充值 ¥%.2f", finalAmount)
|
||||
} else if req.ProductType == "fullbook" {
|
||||
description = "《一场Soul的创业实验》全书"
|
||||
} else if req.ProductType == "vip" {
|
||||
description = "卡若创业派对VIP年度会员(365天)"
|
||||
@@ -286,7 +408,6 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
clientIP = "127.0.0.1"
|
||||
}
|
||||
|
||||
// 插入订单到数据库
|
||||
userID := req.UserID
|
||||
if userID == "" {
|
||||
userID = req.OpenID
|
||||
@@ -304,24 +425,27 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
status := "created"
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: userID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: finalAmount,
|
||||
Description: &description,
|
||||
Status: &status,
|
||||
ReferrerID: referrerID,
|
||||
ReferralCode: &req.ReferralCode,
|
||||
}
|
||||
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
// 订单创建失败,但不中断支付流程
|
||||
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
|
||||
// 充值订单已存在,不重复创建
|
||||
if req.ProductType != "balance_recharge" {
|
||||
status := "created"
|
||||
pm := "wechat"
|
||||
order := model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: userID,
|
||||
OpenID: req.OpenID,
|
||||
ProductType: req.ProductType,
|
||||
ProductID: &productID,
|
||||
Amount: finalAmount,
|
||||
Description: &description,
|
||||
Status: &status,
|
||||
ReferrerID: referrerID,
|
||||
ReferralCode: &req.ReferralCode,
|
||||
PaymentMethod: &pm,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID)
|
||||
@@ -372,7 +496,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 +506,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, now)
|
||||
}
|
||||
}
|
||||
case "CLOSED", "REVOKED", "PAYERROR":
|
||||
status = "failed"
|
||||
@@ -408,9 +538,10 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
|
||||
|
||||
var attach struct {
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
ProductType string `json:"productType"`
|
||||
ProductID string `json:"productId"`
|
||||
UserID string `json:"userId"`
|
||||
GiftPayRequestSn string `json:"giftPayRequestSn"`
|
||||
}
|
||||
if attachStr != "" {
|
||||
_ = json.Unmarshal([]byte(attachStr), &attach)
|
||||
@@ -466,11 +597,12 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
} else if *order.Status != "paid" {
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
if err := db.Model(&order).Updates(map[string]interface{}{
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
}).Error; err != nil {
|
||||
}
|
||||
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("update order: %w", err)
|
||||
}
|
||||
@@ -479,35 +611,60 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
|
||||
}
|
||||
|
||||
if buyerUserID != "" && attach.ProductType != "" {
|
||||
// 代付订单:更新 gift_pay_request、订单 payer_user_id
|
||||
// 权益归属与分佣:代付时归发起人(order.UserID),普通订单归 buyerUserID
|
||||
beneficiaryUserID := buyerUserID
|
||||
if attach.GiftPayRequestSn != "" && order.UserID != "" {
|
||||
beneficiaryUserID = order.UserID
|
||||
fmt.Printf("[PayNotify] 代付订单,权益归属发起人: %s\n", beneficiaryUserID)
|
||||
}
|
||||
if attach.GiftPayRequestSn != "" {
|
||||
var payerUserID string
|
||||
if openID != "" {
|
||||
var payer model.User
|
||||
if err := db.Where("open_id = ?", openID).First(&payer).Error; err == nil {
|
||||
payerUserID = payer.ID
|
||||
db.Model(&order).Update("payer_user_id", payerUserID)
|
||||
}
|
||||
}
|
||||
db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", attach.GiftPayRequestSn).
|
||||
Updates(map[string]interface{}{
|
||||
"status": "paid",
|
||||
"payer_user_id": payerUserID,
|
||||
"order_id": orderSn,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
if beneficiaryUserID != "" && attach.ProductType != "" {
|
||||
if attach.ProductType == "fullbook" {
|
||||
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
|
||||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
|
||||
db.Model(&model.User{}).Where("id = ?", beneficiaryUserID).Update("has_full_book", true)
|
||||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", beneficiaryUserID)
|
||||
} else if attach.ProductType == "vip" {
|
||||
// VIP 支付成功:更新 users.is_vip、vip_expire_date、vip_activated_at(排序:后付款在前)
|
||||
expireDate := time.Now().AddDate(0, 0, 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,
|
||||
})
|
||||
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"))
|
||||
expireDate := activateVIP(db, beneficiaryUserID, 365, vipActivatedAt)
|
||||
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", beneficiaryUserID, 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)
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", beneficiaryUserID, orderSn)
|
||||
} else if attach.ProductType == "balance_recharge" {
|
||||
if err := ConfirmBalanceRechargeByOrder(db, &order); err != nil {
|
||||
fmt.Printf("[PayNotify] 余额充值确认失败: %s, err=%v\n", orderSn, err)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 余额充值成功: %s, 金额 %.2f\n", beneficiaryUserID, totalAmount)
|
||||
}
|
||||
} else if attach.ProductType == "section" && attach.ProductID != "" {
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductID, orderSn,
|
||||
beneficiaryUserID, attach.ProductID, orderSn,
|
||||
).Count(&count)
|
||||
if count == 0 {
|
||||
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", beneficiaryUserID, attach.ProductID)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", beneficiaryUserID, attach.ProductID)
|
||||
}
|
||||
}
|
||||
productID := attach.ProductID
|
||||
@@ -516,9 +673,9 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
}
|
||||
db.Where(
|
||||
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductType, productID, orderSn,
|
||||
beneficiaryUserID, attach.ProductType, productID, orderSn,
|
||||
).Delete(&model.Order{})
|
||||
processReferralCommission(db, buyerUserID, totalAmount, orderSn, &order)
|
||||
processReferralCommission(db, beneficiaryUserID, totalAmount, orderSn, &order)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -630,7 +787,13 @@ func MiniprogramPhone(c *gin.Context) {
|
||||
if req.UserID != "" {
|
||||
db := database.DB()
|
||||
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
|
||||
// 记录绑定手机号行为到 user_tracks
|
||||
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
db.Create(&model.UserTrack{ID: trackID, UserID: req.UserID, Action: "bind_phone"})
|
||||
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
|
||||
// 记录绑定手机行为
|
||||
bindTrackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
database.DB().Create(&model.UserTrack{ID: bindTrackID, UserID: req.UserID, Action: "bind_phone"})
|
||||
// 绑定手机号后,异步调用神射手自动完善标签
|
||||
AdminShensheShouAutoTag(req.UserID, phoneNumber)
|
||||
}
|
||||
@@ -734,6 +897,45 @@ func MiniprogramQrcodeImage(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "image/png", imageData)
|
||||
}
|
||||
|
||||
// GiftLinkGet GET /api/miniprogram/gift/link 代付链接(需登录,传 userId)
|
||||
// 返回 path、ref、scene,供 gift-link 页展示与复制;qrcodeImageUrl 供生成小程序码
|
||||
func GiftLinkGet(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId,请先登录"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
var user model.User
|
||||
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
ref := getStringValue(user.ReferralCode)
|
||||
if ref == "" {
|
||||
suffix := userID
|
||||
if len(userID) >= 6 {
|
||||
suffix = userID[len(userID)-6:]
|
||||
}
|
||||
ref = "SOUL" + strings.ToUpper(suffix)
|
||||
}
|
||||
path := fmt.Sprintf("pages/gift-link/gift-link?ref=%s&gift=1", ref)
|
||||
scene := fmt.Sprintf("ref_%s_gift_1", strings.ReplaceAll(ref, "&", "_"))
|
||||
if len(scene) > 32 {
|
||||
scene = scene[:32]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"path": path,
|
||||
"ref": ref,
|
||||
"scene": scene,
|
||||
})
|
||||
}
|
||||
|
||||
// base64 编码
|
||||
func base64Encode(data []byte) string {
|
||||
const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
@@ -783,14 +985,17 @@ 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,
|
||||
"nickname": getStringValue(user.Nickname),
|
||||
"avatar": getStringValue(user.Avatar),
|
||||
"avatar": getUrlValue(user.Avatar),
|
||||
"phone": getStringValue(user.Phone),
|
||||
"wechatId": getStringValue(user.WechatID),
|
||||
"vipName": getStringValue(user.VipName),
|
||||
@@ -810,7 +1015,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
|
||||
@@ -821,15 +1026,121 @@ 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,
|
||||
"avatar": getUrlValue(u.Avatar),
|
||||
"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, order *model.Order, payTime time.Time) {
|
||||
if order == nil {
|
||||
return
|
||||
}
|
||||
userID := order.UserID
|
||||
productType := order.ProductType
|
||||
switch productType {
|
||||
case "fullbook":
|
||||
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
|
||||
case "vip":
|
||||
activateVIP(db, userID, 365, payTime)
|
||||
case "balance_recharge":
|
||||
ConfirmBalanceRechargeByOrder(db, order)
|
||||
}
|
||||
}
|
||||
|
||||
// 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", "gift":
|
||||
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
|
||||
|
||||
case "balance_recharge":
|
||||
if productID == "" {
|
||||
return 0, fmt.Errorf("充值订单号缺失")
|
||||
}
|
||||
var order model.Order
|
||||
if err := db.Where("order_sn = ? AND product_type = ?", productID, "balance_recharge").First(&order).Error; err != nil {
|
||||
return 0, fmt.Errorf("充值订单不存在: %s", productID)
|
||||
}
|
||||
if order.Amount <= 0 {
|
||||
return 0, fmt.Errorf("充值金额无效")
|
||||
}
|
||||
return order.Amount, nil
|
||||
|
||||
default:
|
||||
return 0, fmt.Errorf("未知商品类型: %s", productType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func OrdersList(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 收集订单中的 user_id、referrer_id,查用户信息
|
||||
// 收集订单中的 user_id、referrer_id、payer_user_id(代付人),查用户信息
|
||||
userIDs := make(map[string]bool)
|
||||
for _, o := range orders {
|
||||
if o.UserID != "" {
|
||||
@@ -112,6 +112,9 @@ func OrdersList(c *gin.Context) {
|
||||
if o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
userIDs[*o.ReferrerID] = true
|
||||
}
|
||||
if o.PayerUserID != nil && *o.PayerUserID != "" {
|
||||
userIDs[*o.PayerUserID] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
@@ -156,6 +159,14 @@ func OrdersList(c *gin.Context) {
|
||||
m["referrerCode"] = getStr(u.ReferralCode)
|
||||
}
|
||||
}
|
||||
// 代付人信息(实际付款人)
|
||||
if o.PayerUserID != nil && *o.PayerUserID != "" {
|
||||
if u := userMap[*o.PayerUserID]; u != nil {
|
||||
m["payerNickname"] = getStr(u.Nickname)
|
||||
} else {
|
||||
m["payerNickname"] = ""
|
||||
}
|
||||
}
|
||||
// 分销佣金:仅对已支付且存在推荐人的订单,按 computeOrderCommission(会员 20%/10%,内容 90%)
|
||||
status := getStr(o.Status)
|
||||
if status == "paid" && o.ReferrerID != nil && *o.ReferrerID != "" {
|
||||
|
||||
148
soul-api/internal/handler/oss.go
Normal file
148
soul-api/internal/handler/oss.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
)
|
||||
|
||||
type ossConfigCache struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
Bucket string `json:"bucket"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
func getOssConfig() *ossConfigCache {
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
var cfg ossConfigCache
|
||||
if err := json.Unmarshal(row.ConfigValue, &cfg); err != nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.Endpoint == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" || cfg.Bucket == "" {
|
||||
return nil
|
||||
}
|
||||
return &cfg
|
||||
}
|
||||
|
||||
func ossUploadFile(file multipart.File, folder, filename string) (string, error) {
|
||||
cfg := getOssConfig()
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("OSS 未配置")
|
||||
}
|
||||
|
||||
endpoint := cfg.Endpoint
|
||||
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
|
||||
endpoint = "https://" + endpoint
|
||||
}
|
||||
|
||||
client, err := oss.New(endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 OSS 客户端失败: %w", err)
|
||||
}
|
||||
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 Bucket 失败: %w", err)
|
||||
}
|
||||
|
||||
objectKey := fmt.Sprintf("%s/%s/%s", folder, time.Now().Format("2006-01"), filename)
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
|
||||
err = bucket.PutObject(objectKey, strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("上传 OSS 失败: %w", err)
|
||||
}
|
||||
|
||||
signedURL, err := bucket.SignURL(objectKey, oss.HTTPGet, 3600*24*365*10)
|
||||
if err != nil {
|
||||
host := cfg.Bucket + "." + cfg.Endpoint
|
||||
if !strings.HasPrefix(cfg.Endpoint, "http://") && !strings.HasPrefix(cfg.Endpoint, "https://") {
|
||||
host = cfg.Bucket + "." + cfg.Endpoint
|
||||
} else {
|
||||
host = strings.Replace(cfg.Endpoint, "://", "://"+cfg.Bucket+".", 1)
|
||||
}
|
||||
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
||||
host = "https://" + host
|
||||
}
|
||||
return host + "/" + objectKey, nil
|
||||
}
|
||||
return normalizeOSSUrl(signedURL), nil
|
||||
}
|
||||
|
||||
// normalizeOSSUrl 修复 OSS SDK 可能返回的缺少冒号的 URL(如 "https//..." → "https://...")
|
||||
func normalizeOSSUrl(u string) string {
|
||||
if strings.HasPrefix(u, "https//") {
|
||||
return "https://" + u[7:]
|
||||
}
|
||||
if strings.HasPrefix(u, "http//") {
|
||||
return "http://" + u[6:]
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func ossUploadBytes(data []byte, folder, filename, contentType string) (string, error) {
|
||||
cfg := getOssConfig()
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("OSS 未配置")
|
||||
}
|
||||
|
||||
endpoint := cfg.Endpoint
|
||||
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
|
||||
endpoint = "https://" + endpoint
|
||||
}
|
||||
|
||||
client, err := oss.New(endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 OSS 客户端失败: %w", err)
|
||||
}
|
||||
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 Bucket 失败: %w", err)
|
||||
}
|
||||
|
||||
objectKey := fmt.Sprintf("%s/%s/%s", folder, time.Now().Format("2006-01"), filename)
|
||||
|
||||
var opts []oss.Option
|
||||
if contentType != "" {
|
||||
opts = append(opts, oss.ContentType(contentType))
|
||||
}
|
||||
|
||||
err = bucket.PutObject(objectKey, strings.NewReader(string(data)), opts...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("上传 OSS 失败: %w", err)
|
||||
}
|
||||
|
||||
signedURL, err := bucket.SignURL(objectKey, oss.HTTPGet, 3600*24*365*10)
|
||||
if err != nil {
|
||||
host := cfg.Bucket + "." + cfg.Endpoint
|
||||
if !strings.HasPrefix(cfg.Endpoint, "http://") && !strings.HasPrefix(cfg.Endpoint, "https://") {
|
||||
host = cfg.Bucket + "." + cfg.Endpoint
|
||||
} else {
|
||||
host = strings.Replace(cfg.Endpoint, "://", "://"+cfg.Bucket+".", 1)
|
||||
}
|
||||
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
||||
host = "https://" + host
|
||||
}
|
||||
return host + "/" + objectKey, nil
|
||||
}
|
||||
return normalizeOSSUrl(signedURL), nil
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -272,7 +283,7 @@ func ReferralData(c *gin.Context) {
|
||||
activeUsers = append(activeUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"avatar": getUrlValue(referee.Avatar),
|
||||
"daysRemaining": daysRemaining,
|
||||
"hasFullBook": getBoolValue(referee.HasFullBook),
|
||||
"bindingDate": b.BindingDate,
|
||||
@@ -301,7 +312,7 @@ func ReferralData(c *gin.Context) {
|
||||
convertedUsers = append(convertedUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"avatar": getUrlValue(referee.Avatar),
|
||||
"commission": commission,
|
||||
"orderAmount": orderAmount,
|
||||
"purchaseCount": getIntValue(b.PurchaseCount),
|
||||
@@ -325,7 +336,7 @@ func ReferralData(c *gin.Context) {
|
||||
expiredUsers = append(expiredUsers, gin.H{
|
||||
"id": b.RefereeID,
|
||||
"nickname": getStringValue(referee.Nickname),
|
||||
"avatar": getStringValue(referee.Avatar),
|
||||
"avatar": getUrlValue(referee.Avatar),
|
||||
"bindingDate": b.BindingDate,
|
||||
"expiryDate": b.ExpiryDate,
|
||||
"status": "expired",
|
||||
@@ -355,7 +366,7 @@ func ReferralData(c *gin.Context) {
|
||||
"productId": getStringValue(e.ProductID),
|
||||
"description": getStringValue(e.Description),
|
||||
"buyerNickname": getStringValue(buyer.Nickname),
|
||||
"buyerAvatar": getStringValue(buyer.Avatar),
|
||||
"buyerAvatar": getUrlValue(buyer.Avatar),
|
||||
"payTime": e.PayTime,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -37,6 +37,7 @@ func SearchGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
|
||||
return
|
||||
}
|
||||
sortChaptersByNaturalID(list)
|
||||
lowerQ := strings.ToLower(q)
|
||||
results := make([]gin.H, 0, len(list))
|
||||
for _, ch := range list {
|
||||
@@ -70,7 +71,7 @@ func SearchGet(c *gin.Context) {
|
||||
price = *ch.Price
|
||||
}
|
||||
results = append(results, gin.H{
|
||||
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
|
||||
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
|
||||
"price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,14 +9,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/oss"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const uploadDir = "uploads"
|
||||
const maxUploadBytes = 5 * 1024 * 1024 // 5MB
|
||||
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
|
||||
|
||||
// UploadPost POST /api/upload 上传图片(表单 file)
|
||||
// 若管理端已配置 OSS,优先上传到 OSS;OSS 失败或未配置时回退本地磁盘(容灾)
|
||||
func UploadPost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -40,16 +42,41 @@ func UploadPost(c *gin.Context) {
|
||||
if folder == "" {
|
||||
folder = "avatars"
|
||||
}
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
|
||||
objectKey := filepath.ToSlash(filepath.Join("uploads", folder, name))
|
||||
|
||||
// 优先尝试 OSS(已配置时)
|
||||
if oss.IsEnabled() {
|
||||
f, err := file.Open()
|
||||
if err == nil {
|
||||
url, uploadErr := oss.Upload(objectKey, f)
|
||||
_ = f.Close()
|
||||
if uploadErr == nil && url != "" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
|
||||
return
|
||||
}
|
||||
// OSS 失败,回退本地(容灾)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地磁盘存储(OSS 未配置或失败时)
|
||||
uploadDir := config.Get().UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "uploads"
|
||||
}
|
||||
dir := filepath.Join(uploadDir, folder)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||||
return
|
||||
}
|
||||
url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
|
||||
relPath := "/uploads/" + filepath.ToSlash(filepath.Join(folder, name))
|
||||
fullURL := relPath
|
||||
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
|
||||
fullURL = cfg.BaseURLJoin(relPath)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": fullURL, "data": gin.H{"url": fullURL, "fileName": name, "size": file.Size, "type": ct}})
|
||||
}
|
||||
|
||||
func randomStrUpload(n int) string {
|
||||
@@ -62,17 +89,40 @@ func randomStrUpload(n int) string {
|
||||
}
|
||||
|
||||
// UploadDelete DELETE /api/upload
|
||||
// path 支持:/uploads/xxx(本地)或 https://bucket.oss-xxx.aliyuncs.com/uploads/xxx(OSS)
|
||||
func UploadDelete(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请指定 path"})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(path, "/uploads/") && !strings.HasPrefix(path, "uploads/") {
|
||||
// OSS 公网 URL:从 OSS 删除
|
||||
if oss.IsOSSURL(path) {
|
||||
objectKey := oss.ParseObjectKeyFromURL(path)
|
||||
if objectKey != "" {
|
||||
if err := oss.Delete(objectKey); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "OSS 删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// 本地路径:支持 /uploads/xxx、uploads/xxx 或含 /uploads/ 的完整 URL
|
||||
if idx := strings.Index(path, "/uploads/"); idx >= 0 {
|
||||
path = path[idx+1:] // 从 uploads/ 开始
|
||||
}
|
||||
rel := strings.TrimPrefix(path, "/uploads/")
|
||||
rel = strings.TrimPrefix(rel, "uploads/")
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
|
||||
return
|
||||
}
|
||||
fullPath := strings.TrimPrefix(path, "/")
|
||||
uploadDir := config.Get().UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "uploads"
|
||||
}
|
||||
fullPath := filepath.Join(uploadDir, filepath.FromSlash(rel))
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "文件不存在或删除失败"})
|
||||
return
|
||||
|
||||
280
soul-api/internal/handler/upload_content.go
Normal file
280
soul-api/internal/handler/upload_content.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
uploadDirContent = "uploads"
|
||||
maxImageBytes = 5 * 1024 * 1024 // 5MB
|
||||
maxVideoBytes = 100 * 1024 * 1024 // 100MB
|
||||
defaultImageQuality = 85
|
||||
)
|
||||
|
||||
var (
|
||||
allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true,
|
||||
}
|
||||
allowedVideoTypes = map[string]bool{
|
||||
"video/mp4": true, "video/quicktime": true, "video/webm": true, "video/x-msvideo": true,
|
||||
}
|
||||
)
|
||||
|
||||
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩),优先 OSS
|
||||
// 表单:file(必填), folder(可选,默认 images), quality(可选 1-100,默认 85)
|
||||
func UploadImagePost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的图片"})
|
||||
return
|
||||
}
|
||||
if file.Size > maxImageBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "图片大小不能超过 5MB"})
|
||||
return
|
||||
}
|
||||
ct := file.Header.Get("Content-Type")
|
||||
if !allowedImageTypes[ct] && !strings.HasPrefix(ct, "image/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 jpg/png/gif/webp 格式"})
|
||||
return
|
||||
}
|
||||
quality := defaultImageQuality
|
||||
if q := c.PostForm("quality"); q != "" {
|
||||
if qn, e := strconv.Atoi(q); e == nil && qn >= 1 && qn <= 100 {
|
||||
quality = qn
|
||||
}
|
||||
}
|
||||
folder := c.PostForm("folder")
|
||||
if folder == "" {
|
||||
folder = "images"
|
||||
}
|
||||
ext := filepath.Ext(file.Filename)
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(6), ext)
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "打开文件失败"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
data, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取文件失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// JPEG 压缩
|
||||
var finalData []byte
|
||||
finalCt := ct
|
||||
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
|
||||
if img, err := jpeg.Decode(bytes.NewReader(data)); err == nil {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err == nil {
|
||||
finalData = buf.Bytes()
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(ct, "png") {
|
||||
if img, err := png.Decode(bytes.NewReader(data)); err == nil {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err == nil {
|
||||
finalData = buf.Bytes()
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(ct, "gif") {
|
||||
if img, err := gif.Decode(bytes.NewReader(data)); err == nil {
|
||||
var buf bytes.Buffer
|
||||
if err := gif.Encode(&buf, img, nil); err == nil {
|
||||
finalData = buf.Bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
if finalData == nil {
|
||||
finalData = data
|
||||
}
|
||||
|
||||
// 优先 OSS 上传
|
||||
if ossURL, err := ossUploadBytes(finalData, folder, name, finalCt); err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "url": ossURL,
|
||||
"data": gin.H{"url": ossURL, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "oss"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 回退本地存储
|
||||
dir := filepath.Join(uploadDirContent, folder)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(dst, finalData, 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||||
return
|
||||
}
|
||||
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "local"}})
|
||||
}
|
||||
|
||||
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传,优先 OSS
|
||||
// 表单:file(必填), folder(可选,默认 videos)
|
||||
func UploadVideoPost(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的视频"})
|
||||
return
|
||||
}
|
||||
if file.Size > maxVideoBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "视频大小不能超过 100MB"})
|
||||
return
|
||||
}
|
||||
ct := file.Header.Get("Content-Type")
|
||||
if !allowedVideoTypes[ct] && !strings.HasPrefix(ct, "video/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 mp4/mov/avi 等视频格式"})
|
||||
return
|
||||
}
|
||||
folder := c.PostForm("folder")
|
||||
if folder == "" {
|
||||
folder = "videos"
|
||||
}
|
||||
ext := filepath.Ext(file.Filename)
|
||||
if ext == "" {
|
||||
ext = ".mp4"
|
||||
}
|
||||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext)
|
||||
|
||||
// 优先 OSS 上传
|
||||
if ossCfg := getOssConfig(); ossCfg != nil {
|
||||
src, err := file.Open()
|
||||
if err == nil {
|
||||
defer src.Close()
|
||||
if ossURL, err := ossUploadFile(src, folder, name); err == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "url": ossURL,
|
||||
"data": gin.H{"url": ossURL, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "oss"},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回退本地存储
|
||||
dir := filepath.Join(uploadDirContent, folder)
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
dst := filepath.Join(dir, name)
|
||||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||||
return
|
||||
}
|
||||
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "url": url,
|
||||
"data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "local"},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminContentUpload POST /api/admin/content/upload 管理端-内容上传(通过 API 写入内容管理,不直接操作数据库)
|
||||
// 需 AdminAuth。Body: { "action": "import", "data": [ { "id","title","content","price","isFree","partId","partTitle","chapterId","chapterTitle" } ] }
|
||||
func AdminContentUpload(c *gin.Context) {
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
Data []importItem `json:"data"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.Action != "import" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 import"})
|
||||
return
|
||||
}
|
||||
if len(body.Data) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "data 不能为空"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
imported, failed := 0, 0
|
||||
for _, item := range body.Data {
|
||||
if item.ID == "" || item.Title == "" {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
price := 1.0
|
||||
if item.Price != nil {
|
||||
price = *item.Price
|
||||
}
|
||||
isFree := false
|
||||
if item.IsFree != nil {
|
||||
isFree = *item.IsFree
|
||||
}
|
||||
wordCount := len(item.Content)
|
||||
status := "published"
|
||||
editionStandard, editionPremium := true, false
|
||||
ch := model.Chapter{
|
||||
ID: item.ID,
|
||||
PartID: strPtrContent(item.PartID, "part-1"),
|
||||
PartTitle: strPtrContent(item.PartTitle, "未分类"),
|
||||
ChapterID: strPtrContent(item.ChapterID, "chapter-1"),
|
||||
ChapterTitle: strPtrContent(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 {
|
||||
err = db.Create(&ch).Error
|
||||
} else if err == nil {
|
||||
err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||||
"section_title": ch.SectionTitle,
|
||||
"content": ch.Content,
|
||||
"word_count": ch.WordCount,
|
||||
"is_free": ch.IsFree,
|
||||
"price": ch.Price,
|
||||
}).Error
|
||||
}
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
|
||||
}
|
||||
|
||||
func randomStrContent(n int) string {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.Intn(len(letters))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func strPtrContent(s *string, def string) string {
|
||||
if s != nil && *s != "" {
|
||||
return *s
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -166,18 +167,48 @@ func UserCheckPurchased(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"})
|
||||
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"}})
|
||||
// 超级VIP(管理端开通):is_vip=1 且 vip_expire_date>NOW 时,所有文章阅读免费,无需再查订单
|
||||
if user.IsVip != nil && *user.IsVip && user.VipExpireDate != nil && user.VipExpireDate.After(time.Now()) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_vip"}})
|
||||
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]}})
|
||||
@@ -377,8 +408,10 @@ func UserPurchaseStatus(c *gin.Context) {
|
||||
if user.PendingEarnings != nil {
|
||||
pendingEarnings = *user.PendingEarnings
|
||||
}
|
||||
// 9.9 买断:仅表示“普通版买断”,不等同 VIP(增值版仍需 VIP 或单章购买)
|
||||
hasFullBook := user.HasFullBook != nil && *user.HasFullBook
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"hasFullBook": user.HasFullBook != nil && *user.HasFullBook,
|
||||
"hasFullBook": hasFullBook,
|
||||
"purchasedSections": purchasedSections,
|
||||
"sectionMidMap": sectionMidMap,
|
||||
"purchasedCount": len(purchasedSections),
|
||||
@@ -419,20 +452,44 @@ func UserReadingProgressGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// parseDuration 从 JSON 解析 duration,兼容数字与字符串(防止客户端传字符串导致累加异常)
|
||||
func parseDuration(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int(x)
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(x)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// UserReadingProgressPost POST /api/user/reading-progress
|
||||
func UserReadingProgressPost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
Progress int `json:"progress"`
|
||||
Duration int `json:"duration"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
Progress int `json:"progress"`
|
||||
Duration interface{} `json:"duration"` // 兼容 int/float64/string,防止字符串导致累加异常
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"})
|
||||
return
|
||||
}
|
||||
duration := parseDuration(body.Duration)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
db := database.DB()
|
||||
now := time.Now()
|
||||
var existing model.ReadingProgress
|
||||
@@ -442,7 +499,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if body.Progress > newProgress {
|
||||
newProgress = body.Progress
|
||||
}
|
||||
newDuration := existing.Duration + body.Duration
|
||||
newDuration := existing.Duration + duration
|
||||
newStatus := body.Status
|
||||
if newStatus == "" {
|
||||
newStatus = "reading"
|
||||
@@ -469,94 +526,13 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
completedAt = &t
|
||||
}
|
||||
db.Create(&model.ReadingProgress{
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: body.Duration,
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: duration,
|
||||
Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now,
|
||||
})
|
||||
}
|
||||
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")
|
||||
@@ -644,6 +620,11 @@ func UserTrackPost(c *gin.Context) {
|
||||
if body.Target != "" {
|
||||
t.ChapterID = &chID
|
||||
}
|
||||
if body.ExtraData != nil {
|
||||
if raw, err := json.Marshal(body.ExtraData); err == nil {
|
||||
t.ExtraData = raw
|
||||
}
|
||||
}
|
||||
if err := db.Create(&t).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
@@ -651,6 +632,45 @@ func UserTrackPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "trackId": trackID, "message": "行为记录成功"})
|
||||
}
|
||||
|
||||
// MiniprogramTrackPost POST /api/miniprogram/track 小程序埋点(userId 可选,支持匿名)
|
||||
func MiniprogramTrackPost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target"`
|
||||
ExtraData interface{} `json:"extraData"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
if body.Action == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "行为类型不能为空"})
|
||||
return
|
||||
}
|
||||
userId := body.UserID
|
||||
if userId == "" {
|
||||
userId = "anonymous"
|
||||
}
|
||||
db := database.DB()
|
||||
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
||||
chID := body.Target
|
||||
if body.Action == "view_chapter" && body.Target != "" {
|
||||
chID = body.Target
|
||||
}
|
||||
t := model.UserTrack{
|
||||
ID: trackID, UserID: userId, Action: body.Action, Target: &body.Target,
|
||||
}
|
||||
if body.Target != "" {
|
||||
t.ChapterID = &chID
|
||||
}
|
||||
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, "trackId": trackID, "message": "记录成功"})
|
||||
}
|
||||
|
||||
// UserUpdate POST /api/user/update 更新昵称、头像、手机、微信号等
|
||||
func UserUpdate(c *gin.Context) {
|
||||
var body struct {
|
||||
@@ -688,3 +708,95 @@ 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
|
||||
}
|
||||
// 异常数据保护:历史 bug 导致累加错误可能产生超大值, cap 到 99999 分钟(约 69 天)
|
||||
if totalReadMinutes > 99999 {
|
||||
totalReadMinutes = 99999
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 买断(永久权益),不等同于 VIP(365天)。
|
||||
// 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
|
||||
}
|
||||
@@ -284,12 +286,9 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
if name == "" {
|
||||
name = "创业者"
|
||||
}
|
||||
avatar := ""
|
||||
if u.Avatar != nil && *u.Avatar != "" {
|
||||
avatar = *u.Avatar
|
||||
}
|
||||
if avatar == "" && u.VipAvatar != nil && *u.VipAvatar != "" {
|
||||
avatar = *u.VipAvatar
|
||||
avatar := getUrlValue(u.Avatar)
|
||||
if avatar == "" {
|
||||
avatar = getUrlValue(u.VipAvatar)
|
||||
}
|
||||
project := getStringValue(u.VipProject)
|
||||
if project == "" {
|
||||
@@ -310,6 +309,10 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
if u.VipRole != nil {
|
||||
vipRole = *u.VipRole
|
||||
}
|
||||
vipSort := 0
|
||||
if u.VipSort != nil {
|
||||
vipSort = *u.VipSort
|
||||
}
|
||||
return gin.H{
|
||||
"id": u.ID,
|
||||
"name": name,
|
||||
@@ -341,12 +344,14 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
|
||||
"story_achievement": getStringValue(u.StoryAchievement),
|
||||
"storyTurning": getStringValue(u.StoryTurning),
|
||||
"story_turning": getStringValue(u.StoryTurning),
|
||||
"helpOffer": getStringValue(u.HelpOffer),
|
||||
"helpOffer": getStringValue(u.HelpOffer),
|
||||
"help_offer": getStringValue(u.HelpOffer),
|
||||
"helpNeed": getStringValue(u.HelpNeed),
|
||||
"help_need": getStringValue(u.HelpNeed),
|
||||
"projectIntro": getStringValue(u.ProjectIntro),
|
||||
"project_intro": getStringValue(u.ProjectIntro),
|
||||
"project_intro": getStringValue(u.ProjectIntro),
|
||||
"vipSort": vipSort,
|
||||
"vip_sort": vipSort,
|
||||
"is_vip": isVip,
|
||||
}
|
||||
}
|
||||
|
||||
54
soul-api/internal/handler/vip_members_admin.go
Normal file
54
soul-api/internal/handler/vip_members_admin.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DBVipMembersList GET /api/db/vip-members 管理端 - VIP 成员列表(用于超级个体排序)
|
||||
// 与小程序端 VipMembers 的列表逻辑保持一致:仅列出仍在有效期内的 VIP 用户。
|
||||
func DBVipMembersList(c *gin.Context) {
|
||||
limit := 200
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if n, err := parseInt(l); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
|
||||
// 与 VipMembers 一致:优先 users 表(is_vip=1 且 vip_expire_date>NOW),排序使用 vip_sort
|
||||
var users []model.User
|
||||
err := db.Table("users").
|
||||
Select("id", "nickname", "avatar", "vip_name", "vip_role", "vip_project", "vip_avatar", "vip_bio", "vip_activated_at", "vip_sort", "vip_expire_date", "is_vip", "phone", "wechat_id").
|
||||
Where("is_vip = 1 AND vip_expire_date > ?", time.Now()).
|
||||
Order("COALESCE(vip_sort, 999999) ASC, COALESCE(vip_activated_at, vip_expire_date) DESC").
|
||||
Limit(limit).
|
||||
Find(&users).Error
|
||||
|
||||
if err != nil || len(users) == 0 {
|
||||
// 兜底:从 orders 查,逻辑与 VipMembers 保持一致
|
||||
var userIDs []string
|
||||
db.Model(&model.Order{}).Select("DISTINCT user_id").
|
||||
Where("(status = ? OR status = ?) AND (product_type = ? OR product_type = ?)", "paid", "completed", "fullbook", "vip").
|
||||
Pluck("user_id", &userIDs)
|
||||
if len(userIDs) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}, "total": 0})
|
||||
return
|
||||
}
|
||||
db.Where("id IN ?", userIDs).Find(&users)
|
||||
}
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
for i := range users {
|
||||
list = append(list, formatVipMember(&users[i], true))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
@@ -133,7 +134,12 @@ func WithdrawPost(c *gin.Context) {
|
||||
// AdminWithdrawTest GET/POST /api/admin/withdraw-test 提现测试接口,供 curl 等调试用
|
||||
// 参数:userId(默认 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg)、amount(默认 1)
|
||||
// 测试时忽略最低提现额限制,仅校验可提现余额与用户存在
|
||||
// 生产环境禁用,避免误用
|
||||
func AdminWithdrawTest(c *gin.Context) {
|
||||
if cfg := config.Get(); cfg != nil && cfg.Mode == "release" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "生产环境禁用"})
|
||||
return
|
||||
}
|
||||
userID := c.DefaultQuery("userId", "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg")
|
||||
amountStr := c.DefaultQuery("amount", "1")
|
||||
var amount float64
|
||||
|
||||
Reference in New Issue
Block a user