Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	miniprogram/app.js
#	miniprogram/app.json
#	miniprogram/pages/chapters/chapters.js
#	miniprogram/pages/chapters/chapters.wxml
#	miniprogram/pages/chapters/chapters.wxss
#	miniprogram/pages/index/index.js
#	miniprogram/pages/index/index.wxml
#	miniprogram/pages/match/match.js
#	miniprogram/pages/my/my.js
#	miniprogram/pages/my/my.wxml
#	miniprogram/pages/read/read.js
#	miniprogram/pages/read/read.wxml
#	miniprogram/pages/read/read.wxss
#	miniprogram/pages/referral/referral.js
#	miniprogram/pages/search/search.js
#	miniprogram/pages/vip/vip.js
#	miniprogram/pages/wallet/wallet.wxml
#	miniprogram/project.private.config.json
#	soul-admin/dist/index.html
#	soul-admin/src/pages/dashboard/DashboardPage.tsx
#	soul-admin/src/pages/settings/SettingsPage.tsx
#	soul-api/go.mod
#	soul-api/internal/handler/admin_dashboard.go
#	soul-api/internal/handler/db.go
#	soul-api/wechat/info.log
#	开发文档/10、项目管理/运营与变更.md
#	开发文档/README.md
This commit is contained in:
Alex-larget
2026-03-18 17:55:34 +08:00
125 changed files with 46439 additions and 2916 deletions

View File

@@ -227,6 +227,7 @@ func AdminChaptersAction(c *gin.Context) {
}
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -3,7 +3,9 @@ package handler
import (
"encoding/json"
"net/http"
"strconv"
"sync"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -54,11 +56,17 @@ func AdminDashboardStats(c *gin.Context) {
})
}
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders
// AdminDashboardRecentOrders GET /api/admin/dashboard/recent-orders?limit=10
func AdminDashboardRecentOrders(c *gin.Context) {
db := database.DB()
limit := 5
if l := c.Query("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 20 {
limit = n
}
}
var recentOrders []model.Order
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(10).Find(&recentOrders)
db.Where("status IN ?", paidStatuses).Order("created_at DESC").Limit(limit).Find(&recentOrders)
c.JSON(http.StatusOK, gin.H{"success": true, "recentOrders": buildRecentOrdersOut(db, recentOrders)})
}
@@ -180,6 +188,101 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
return out
}
// AdminTrackStats GET /api/admin/track/stats?period=today|week|month|all
// 埋点统计:按 extra_data->module 分组,按 action+target 聚合 count
func AdminTrackStats(c *gin.Context) {
period := c.DefaultQuery("period", "week")
if period != "today" && period != "week" && period != "month" && period != "all" {
period = "week"
}
now := time.Now()
var start time.Time
switch period {
case "today":
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
case "week":
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
start = time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location())
case "month":
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
case "all":
start = time.Time{}
}
db := database.DB()
var tracks []model.UserTrack
q := db.Model(&model.UserTrack{})
if !start.IsZero() {
q = q.Where("created_at >= ?", start)
}
if err := q.Find(&tracks).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
// byModule: module -> map[key] -> count, key = action + "|" + target
type item struct {
Action string `json:"action"`
Target string `json:"target"`
Module string `json:"module"`
Page string `json:"page"`
Count int `json:"count"`
}
byModule := make(map[string]map[string]*item)
total := 0
for _, t := range tracks {
total++
module := "other"
page := ""
if len(t.ExtraData) > 0 {
var extra map[string]interface{}
if err := json.Unmarshal(t.ExtraData, &extra); err == nil {
if m, ok := extra["module"].(string); ok && m != "" {
module = m
}
if p, ok := extra["page"].(string); ok {
page = p
}
}
}
target := ""
if t.Target != nil {
target = *t.Target
}
key := t.Action + "|" + target
if byModule[module] == nil {
byModule[module] = make(map[string]*item)
}
if byModule[module][key] == nil {
byModule[module][key] = &item{Action: t.Action, Target: target, Module: module, Page: page, Count: 0}
}
byModule[module][key].Count++
}
// 转为前端期望格式byModule[module] = [{action,target,module,page,count},...]
out := make(map[string][]gin.H)
for mod, m := range byModule {
list := make([]gin.H, 0, len(m))
for _, v := range m {
list = append(list, gin.H{
"action": v.Action, "target": v.Target, "module": v.Module, "page": v.Page, "count": v.Count,
})
}
out[mod] = list
}
c.JSON(http.StatusOK, gin.H{"success": true, "total": total, "byModule": out})
}
// AdminBalanceSummary GET /api/admin/balance/summary
// 汇总代付金额product_type 为 gift_pay 或 gift_pay_batch 的已支付订单),用于 Dashboard 显示「含代付 ¥xx」
func AdminBalanceSummary(c *gin.Context) {
db := database.DB()
var totalGifted float64
db.Model(&model.Order{}).Where("product_type IN ? AND status IN ?", []string{"gift_pay", "gift_pay_batch"}, paidStatuses).
Select("COALESCE(SUM(amount), 0)").Scan(&totalGifted)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalGifted": totalGifted}})
}
// AdminDashboardMerchantBalance GET /api/admin/dashboard/merchant-balance
// 查询微信商户号实时余额(可用余额、待结算余额),用于看板展示
// 注意:普通商户可能需向微信申请开通权限,未开通时返回 error

View File

@@ -94,7 +94,27 @@ var bookPartsCache struct {
const bookPartsCacheTTL = 30 * time.Second
// WarmAllChaptersCache 启动时预热缓存,避免首请求冷启动 502
// chaptersByPartCache 篇章内章节列表内存缓存30 秒 TTL
type chaptersByPartEntry struct {
data []model.Chapter
expires time.Time
}
var chaptersByPartCache struct {
mu sync.RWMutex
entries map[string]*chaptersByPartEntry
}
const chaptersByPartCacheTTL = 30 * time.Second
// InvalidateChaptersByPartCache 后台更新章节时调用,使 chapters-by-part 内存缓存失效
func InvalidateChaptersByPartCache() {
chaptersByPartCache.mu.Lock()
chaptersByPartCache.entries = nil
chaptersByPartCache.mu.Unlock()
}
// WarmAllChaptersCache 启动时预热缓存Redis+内存),避免首请求冷启动 502
func WarmAllChaptersCache() {
db := database.DB()
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
@@ -112,6 +132,7 @@ func WarmAllChaptersCache() {
list[i].Price = &z
}
}
cache.Set(context.Background(), cache.KeyAllChapters("default"), list, cache.AllChaptersTTL)
allChaptersCache.mu.Lock()
allChaptersCache.data = list
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
@@ -202,15 +223,26 @@ func WarmBookPartsCache() {
}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
//
// Deprecated: 小程序已迁移至 book/parts + chapters-by-part + book/statsid↔mid 从各接口响应积累。
// 保留以兼容旧版/管理端,计划后续下线。
//
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// 免费判断system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
// 带 30 秒内存缓存,管理端更新后最多 30 秒生
// 缓存优先级Redis10min> 内存30s> DB后台更新时失
func BookAllChapters(c *gin.Context) {
cacheKey := "default"
if c.Query("excludeFixed") == "1" {
cacheKey = "excludeFixed"
}
// 1. 优先 Redis
var list []model.Chapter
if cache.Get(context.Background(), cache.KeyAllChapters(cacheKey), &list) && len(list) > 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
return
}
// 2. 内存缓存
allChaptersCache.mu.RLock()
if allChaptersCache.key == cacheKey && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
data := allChaptersCache.data
@@ -220,6 +252,7 @@ func BookAllChapters(c *gin.Context) {
}
allChaptersCache.mu.RUnlock()
// 3. DB 查询
db := database.DB()
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
if cacheKey == "excludeFixed" {
@@ -227,7 +260,6 @@ func BookAllChapters(c *gin.Context) {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
}
var list []model.Chapter
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
@@ -243,6 +275,8 @@ func BookAllChapters(c *gin.Context) {
}
}
// 回填 Redis + 内存
cache.Set(context.Background(), cache.KeyAllChapters(cacheKey), list, cache.AllChaptersTTL)
allChaptersCache.mu.Lock()
allChaptersCache.data = list
allChaptersCache.expires = time.Now().Add(allChaptersCacheTTL)
@@ -311,14 +345,33 @@ func BookParts(c *gin.Context) {
}
// BookChaptersByPart GET /api/miniprogram/book/chapters-by-part?partId=xxx 按篇章返回章节列表(含 mid供阅读页 by-mid 请求)
// 缓存优先级Redis10min> 内存30s> DB后台更新时失效
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()
// 1. 优先 Redis
var list []model.Chapter
if cache.Get(context.Background(), cache.KeyChaptersByPart(partId), &list) && len(list) > 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
return
}
// 2. 内存缓存
chaptersByPartCache.mu.RLock()
if chaptersByPartCache.entries != nil {
if e, ok := chaptersByPartCache.entries[partId]; ok && time.Now().Before(e.expires) {
list := e.data
chaptersByPartCache.mu.RUnlock()
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
return
}
}
chaptersByPartCache.mu.RUnlock()
// 3. DB 查询
db := database.DB()
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
Where("part_id = ?", partId).
Order("COALESCE(sort_order, 999999) ASC, id ASC").
@@ -336,9 +389,42 @@ func BookChaptersByPart(c *gin.Context) {
list[i].Price = &z
}
}
// 回填 Redis + 内存
cache.Set(context.Background(), cache.KeyChaptersByPart(partId), list, cache.ChaptersByPartTTL)
chaptersByPartCache.mu.Lock()
if chaptersByPartCache.entries == nil {
chaptersByPartCache.entries = make(map[string]*chaptersByPartEntry)
}
chaptersByPartCache.entries[partId] = &chaptersByPartEntry{data: list, expires: time.Now().Add(chaptersByPartCacheTTL)}
chaptersByPartCache.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// getOrderedChapterList 获取按 sort_order+id 排序的章节列表(复用 all-chapters 缓存)
func getOrderedChapterList() []model.Chapter {
var list []model.Chapter
if cache.Get(context.Background(), cache.KeyAllChapters("default"), &list) && len(list) > 0 {
return list
}
allChaptersCache.mu.RLock()
if allChaptersCache.key == "default" && time.Now().Before(allChaptersCache.expires) && len(allChaptersCache.data) > 0 {
list = allChaptersCache.data
allChaptersCache.mu.RUnlock()
return list
}
allChaptersCache.mu.RUnlock()
db := database.DB()
if err := db.Model(&model.Chapter{}).Select(allChaptersSelectCols).
Order("COALESCE(sort_order, 999999) ASC, id ASC").
Find(&list).Error; err != nil || len(list) == 0 {
return nil
}
sortChaptersByNaturalID(list)
return list
}
// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐)
func BookChapterByMID(c *gin.Context) {
midStr := c.Param("mid")
@@ -357,8 +443,16 @@ func BookChapterByMID(c *gin.Context) {
}
// getFreeChapterIDs 从 system_config 读取免费章节 ID 列表free_chapters 或 chapter_config.freeChapters
// Redis 缓存 5min后台更新时失效
func getFreeChapterIDs(db *gorm.DB) map[string]bool {
ids := make(map[string]bool)
var ids map[string]bool
if cache.Get(context.Background(), cache.KeyFreeChapterIDs, &ids) {
if ids == nil {
return make(map[string]bool)
}
return ids
}
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 {
@@ -388,6 +482,7 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool {
}
}
}
cache.Set(context.Background(), cache.KeyFreeChapterIDs, ids, cache.FreeChapterIDsTTL)
return ids
}
@@ -550,6 +645,38 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
"sectionTitle": ch.SectionTitle,
"isFree": isFree,
}
// 文章详情内直接输出上一篇/下一篇,省去单独请求
if list := getOrderedChapterList(); len(list) > 0 {
idx := -1
for i, item := range list {
if item.ID == ch.ID {
idx = i
break
}
}
if idx >= 0 {
toItem := func(c *model.Chapter) gin.H {
if c == nil {
return nil
}
t := c.SectionTitle
if t == "" {
t = c.ChapterTitle
}
return gin.H{"id": c.ID, "mid": c.MID, "title": t}
}
if idx > 0 {
out["prev"] = toItem(&list[idx-1])
} else {
out["prev"] = nil
}
if idx < len(list)-1 {
out["next"] = toItem(&list[idx+1])
} else {
out["next"] = nil
}
}
}
if isFreeFromConfig {
out["price"] = float64(0)
} else if ch.Price != nil {
@@ -773,13 +900,18 @@ func BookRecommended(c *gin.Context) {
}
// BookLatestChapters GET /api/book/latest-chapters 最新更新(按 updated_at 降序,排除序言/尾声/附录)
// Redis 缓存 5min首页「最新更新」主接口
func BookLatestChapters(c *gin.Context) {
var list []model.Chapter
if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
return
}
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
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
@@ -799,9 +931,42 @@ func BookLatestChapters(c *gin.Context) {
list[i].Price = &z
}
}
cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL)
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// WarmLatestChaptersCache 启动时预热最新章节 Redis 缓存(首页主接口)
func WarmLatestChaptersCache() {
var list []model.Chapter
if cache.Get(context.Background(), cache.KeyBookLatestChapters, &list) && len(list) > 0 {
return
}
db := database.DB()
q := db.Model(&model.Chapter{}).Select(allChaptersSelectCols)
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
if err := q.Order("updated_at DESC, id ASC").Limit(20).Find(&list).Error; err != nil {
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
}
}
cache.Set(context.Background(), cache.KeyBookLatestChapters, list, cache.BookRelatedTTL)
}
func escapeLikeBook(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "%", "\\%")

View File

@@ -17,14 +17,8 @@ import (
"github.com/gin-gonic/gin"
)
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
// 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
}
// buildMiniprogramConfig 从 DB 构建小程序配置,供 GetPublicDBConfig 与 WarmConfigCache 复用
func buildMiniprogramConfig() gin.H {
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
apiDomain := "https://soulapi.quwanzhi.com"
@@ -32,17 +26,19 @@ func GetPublicDBConfig(c *gin.Context) {
apiDomain = cfg.BaseURL
}
defaultMp := gin.H{
"appId": "wxb8bbb2b10dec74aa",
"apiDomain": apiDomain,
"buyerDiscount": 5,
"referralBindDays": 30,
"minWithdraw": 10,
"appId": "wxb8bbb2b10dec74aa",
"apiDomain": apiDomain,
"buyerDiscount": 5,
"referralBindDays": 30,
"minWithdraw": 10,
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
"mchId": "1318592501",
"mchId": "1318592501",
"auditMode": false,
"supportWechat": true,
}
out := gin.H{
"success": true,
"success": true,
"prices": defaultPrices,
"features": defaultFeatures,
"mpConfig": defaultMp,
@@ -134,10 +130,149 @@ func GetPublicDBConfig(c *gin.Context) {
if _, has := out["linkedMiniprograms"]; !has {
out["linkedMiniprograms"] = []gin.H{}
}
// 明确归一化 auditMode仅当 DB 显式为 true 时返回 true否则一律 false避免历史脏数据/类型异常导致误判)
if mp, ok := out["mpConfig"].(gin.H); ok {
if v, ok := mp["auditMode"].(bool); ok && v {
mp["auditMode"] = true
} else {
mp["auditMode"] = false
}
}
return out
}
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
// Redis 缓存 10min配置变更时失效
//
// Deprecated: 计划迁移至 /config/core + /config/audit-mode + /config/read-extras保留以兼容线上小程序
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
}
out := buildMiniprogramConfig()
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
// GetAuditMode GET /api/miniprogram/config/audit-mode 审核模式独立接口,管理端开关后快速生效
func GetAuditMode(c *gin.Context) {
var cached gin.H
if cache.Get(context.Background(), cache.KeyConfigAuditMode, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
full := buildMiniprogramConfig()
auditMode := false
if mp, ok := full["mpConfig"].(gin.H); ok {
if v, ok := mp["auditMode"].(bool); ok && v {
auditMode = true
}
}
out := gin.H{"auditMode": auditMode}
cache.Set(context.Background(), cache.KeyConfigAuditMode, out, cache.AuditModeTTL)
c.JSON(http.StatusOK, out)
}
// GetCoreConfig GET /api/miniprogram/config/core 核心配置prices、features、userDiscount、mpConfig首屏/Tab 用
func GetCoreConfig(c *gin.Context) {
var cached gin.H
if cache.Get(context.Background(), cache.KeyConfigCore, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
full := buildMiniprogramConfig()
out := gin.H{
"success": true,
"prices": full["prices"],
"features": full["features"],
"userDiscount": full["userDiscount"],
"mpConfig": full["mpConfig"],
}
if out["prices"] == nil {
out["prices"] = gin.H{"section": float64(1), "fullbook": 9.9}
}
if out["features"] == nil {
out["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
}
if out["userDiscount"] == nil {
out["userDiscount"] = float64(5)
}
if out["mpConfig"] == nil {
out["mpConfig"] = gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigCore, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
// GetReadExtras GET /api/miniprogram/config/read-extras 阅读页扩展linkTags、linkedMiniprograms懒加载
func GetReadExtras(c *gin.Context) {
var cached gin.H
if cache.Get(context.Background(), cache.KeyConfigReadExtras, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
full := buildMiniprogramConfig()
out := gin.H{
"linkTags": full["linkTags"],
"linkedMiniprograms": full["linkedMiniprograms"],
}
if out["linkTags"] == nil {
out["linkTags"] = []gin.H{}
}
if out["linkedMiniprograms"] == nil {
out["linkedMiniprograms"] = []gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigReadExtras, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
// WarmConfigCache 启动时预热 config 及拆分接口缓存,避免首请求冷启动
func WarmConfigCache() {
out := buildMiniprogramConfig()
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
// 拆分接口预热
auditMode := false
if mp, ok := out["mpConfig"].(gin.H); ok {
if v, ok := mp["auditMode"].(bool); ok && v {
auditMode = true
}
}
cache.Set(context.Background(), cache.KeyConfigAuditMode, gin.H{"auditMode": auditMode}, cache.AuditModeTTL)
core := gin.H{
"success": true,
"prices": out["prices"],
"features": out["features"],
"userDiscount": out["userDiscount"],
"mpConfig": out["mpConfig"],
}
if core["prices"] == nil {
core["prices"] = gin.H{"section": float64(1), "fullbook": 9.9}
}
if core["features"] == nil {
core["features"] = gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
}
if core["userDiscount"] == nil {
core["userDiscount"] = float64(5)
}
if core["mpConfig"] == nil {
core["mpConfig"] = gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigCore, core, cache.ConfigTTL)
readExtras := gin.H{
"linkTags": out["linkTags"],
"linkedMiniprograms": out["linkedMiniprograms"],
}
if readExtras["linkTags"] == nil {
readExtras["linkTags"] = []gin.H{}
}
if readExtras["linkedMiniprograms"] == nil {
readExtras["linkedMiniprograms"] = []gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigReadExtras, readExtras, cache.ConfigTTL)
}
// DBConfigGet GET /api/db/config管理端鉴权后同路径由 db 组处理时用)
func DBConfigGet(c *gin.Context) {
key := c.Query("key")
@@ -174,15 +309,17 @@ func AdminSettingsGet(c *gin.Context) {
apiDomain = cfg.BaseURL
}
defaultMp := gin.H{
"appId": "wxb8bbb2b10dec74aa",
"apiDomain": apiDomain,
"appId": "wxb8bbb2b10dec74aa",
"apiDomain": apiDomain,
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
"mchId": "1318592501",
"minWithdraw": float64(10),
"mchId": "1318592501",
"minWithdraw": float64(10),
"auditMode": false,
"supportWechat": true,
}
out := gin.H{
"success": true,
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true},
"featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true},
"siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}},
"mpConfig": defaultMp,
"ossConfig": gin.H{},
@@ -289,12 +426,12 @@ func AdminReferralSettingsGet(c *gin.Context) {
db := database.DB()
defaultConfig := gin.H{
"distributorShare": float64(90),
"minWithdrawAmount": float64(10),
"bindingDays": float64(30),
"userDiscount": float64(5),
"withdrawFee": float64(5),
"enableAutoWithdraw": false,
"vipOrderShareVip": float64(20),
"minWithdrawAmount": float64(10),
"bindingDays": float64(30),
"userDiscount": float64(5),
"withdrawFee": float64(5),
"enableAutoWithdraw": false,
"vipOrderShareVip": float64(20),
"vipOrderShareNonVip": float64(10),
}
var row model.SystemConfig
@@ -337,11 +474,11 @@ func AdminReferralSettingsPost(c *gin.Context) {
val := gin.H{
"distributorShare": body.DistributorShare,
"minWithdrawAmount": body.MinWithdrawAmount,
"bindingDays": body.BindingDays,
"userDiscount": body.UserDiscount,
"withdrawFee": body.WithdrawFee,
"enableAutoWithdraw": body.EnableAutoWithdraw,
"vipOrderShareVip": vipOrderShareVip,
"bindingDays": body.BindingDays,
"userDiscount": body.UserDiscount,
"withdrawFee": body.WithdrawFee,
"enableAutoWithdraw": body.EnableAutoWithdraw,
"vipOrderShareVip": vipOrderShareVip,
"vipOrderShareNonVip": vipOrderShareNonVip,
}
valBytes, err := json.Marshal(val)
@@ -456,12 +593,12 @@ func AdminAuthorSettingsPost(c *gin.Context) {
err := db.First(&row).Error
if err != nil {
row = model.AuthorConfig{
Name: name,
Avatar: avatar,
AvatarImg: str("avatarImg"),
Title: str("title"),
Bio: str("bio"),
Stats: string(statsBytes),
Name: name,
Avatar: avatar,
AvatarImg: str("avatarImg"),
Title: str("title"),
Bio: str("bio"),
Stats: string(statsBytes),
Highlights: string(highlightsBytes),
}
err = db.Create(&row).Error
@@ -547,7 +684,7 @@ func DBUsersList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
search := strings.TrimSpace(c.DefaultQuery("search", ""))
vipFilter := c.Query("vip") // "true" 时仅返回 VIPhasFullBook
vipFilter := c.Query("vip") // "true" 时仅返回 VIPhasFullBook
poolFilter := c.Query("pool") // "complete" 时仅返回已完善资料的用户
if page < 1 {
page = 1
@@ -720,21 +857,21 @@ func DBUsersList(c *gin.Context) {
})
}
func ptrBool(b bool) *bool { return &b }
func ptrBool(b bool) *bool { return &b }
func ptrFloat64(f float64) *float64 { v := f; return &v }
func ptrInt(n int) *int { return &n }
func ptrInt(n int) *int { return &n }
// DBUsersAction POST /api/db/users创建、PUT /api/db/users更新
func DBUsersAction(c *gin.Context) {
db := database.DB()
if c.Request.Method == http.MethodPost {
var body struct {
OpenID *string `json:"openId"`
Phone *string `json:"phone"`
Nickname *string `json:"nickname"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
IsAdmin *bool `json:"isAdmin"`
OpenID *string `json:"openId"`
Phone *string `json:"phone"`
Nickname *string `json:"nickname"`
WechatID *string `json:"wechatId"`
Avatar *string `json:"avatar"`
IsAdmin *bool `json:"isAdmin"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
@@ -764,25 +901,25 @@ func DBUsersAction(c *gin.Context) {
}
// PUT 更新(含 VIP 手动设置is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_biotags 存 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"`
PendingEarnings *float64 `json:"pendingEarnings"`
IsVip *bool `json:"isVip"`
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
VipSort *int `json:"vipSort"` // 手动排序,越小越前
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
VipName *string `json:"vipName"`
VipAvatar *string `json:"vipAvatar"`
VipProject *string `json:"vipProject"`
VipContact *string `json:"vipContact"`
VipBio *string `json:"vipBio"`
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"`
PendingEarnings *float64 `json:"pendingEarnings"`
IsVip *bool `json:"isVip"`
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
VipSort *int `json:"vipSort"` // 手动排序,越小越前
VipRole *string `json:"vipRole"` // 角色:从 vip_roles 选或手动填写
VipName *string `json:"vipName"`
VipAvatar *string `json:"vipAvatar"`
VipProject *string `json:"vipProject"`
VipContact *string `json:"vipContact"`
VipBio *string `json:"vipBio"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
@@ -902,7 +1039,7 @@ func randomSuffix() string {
return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff)
}
// DBUsersDelete DELETE /api/db/users
// DBUsersDelete DELETE /api/db/users(软删除:仅设置 deleted_at用户再次登录会新建账号
func DBUsersDelete(c *gin.Context) {
id := c.Query("id")
if id == "" {
@@ -910,29 +1047,16 @@ func DBUsersDelete(c *gin.Context) {
return
}
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()})
result := db.Where("id = ?", id).Delete(&model.User{})
if result.Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": result.Error.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户删除成功"})
if result.RowsAffected == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在或已被删除"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户已删除(假删除),该用户再次登录将创建新账号"})
}
// DBUsersReferrals GET /api/db/users/referrals绑定关系详情弹窗收益与「已付费」与小程序口径一致订单+提现表实时计算)
@@ -990,9 +1114,9 @@ func DBUsersReferrals(c *gin.Context) {
displayStatus := bindingStatusDisplay(hasPaid, hasFullBook) // vip | paid | free供前端徽章展示
referrals = append(referrals, gin.H{
"id": b.RefereeID, "nickname": nick, "avatar": avatar, "phone": phone,
"hasFullBook": hasFullBook || status == "converted",
"hasFullBook": hasFullBook || status == "converted",
"purchasedSections": getBindingPurchaseCount(b),
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
"status": displayStatus,
})
}
@@ -1099,7 +1223,7 @@ func DBDistribution(c *gin.Context) {
if statusFilter != "" && statusFilter != "all" {
query = query.Where("status = ?", statusFilter)
}
if err := query.Offset((page-1)*pageSize).Limit(pageSize).Find(&bindings).Error; err != nil {
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&bindings).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "bindings": []interface{}{}, "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0})
return
}

View File

@@ -448,6 +448,7 @@ func DBBookAction(c *gin.Context) {
switch body.Action {
case "sync":
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成Gin 无文件源时可从 DB 已存在数据视为已同步)"})
return
@@ -501,6 +502,7 @@ func DBBookAction(c *gin.Context) {
imported++
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
return
@@ -566,6 +568,7 @@ func DBBookAction(c *gin.Context) {
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
}()
return
@@ -582,6 +585,7 @@ func DBBookAction(c *gin.Context) {
}
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
}()
return
@@ -607,6 +611,7 @@ func DBBookAction(c *gin.Context) {
return
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
return
@@ -716,6 +721,7 @@ func DBBookAction(c *gin.Context) {
}
cache.InvalidateChapterContent(ch.MID)
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
return
@@ -731,6 +737,7 @@ func DBBookAction(c *gin.Context) {
}
cache.InvalidateChapterContentByID(body.ID)
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
return
@@ -778,6 +785,7 @@ func DBBookDelete(c *gin.Context) {
return
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
@@ -14,9 +15,24 @@ import (
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const giftPayExpireHours = 24
const wechatAttachMaxBytes = 128
// truncateStr 截断字符串至最多 n 字节UTF-8 安全)
func truncateStr(s string, n int) string {
b := []byte(s)
if len(b) <= n {
return s
}
b = b[:n]
for len(b) > 0 && b[len(b)-1] >= 0x80 {
b = b[:len(b)-1]
}
return string(b)
}
// giftPayPreviewContent 取内容前 20%,用于代付页营销展示
func giftPayPreviewContent(content string) string {
@@ -38,17 +54,23 @@ func giftPayPreviewContent(content string) string {
return string(runes[:limit]) + "……"
}
// GiftPayCreate POST /api/miniprogram/gift-pay/create 创建代付请求
// 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"`
Quantity int `json:"quantity"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
return
}
quantity := req.Quantity
if quantity < 1 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "发放份数须为正整数"})
return
}
db := database.DB()
// 校验发起人
@@ -70,11 +92,15 @@ func GiftPayCreate(c *gin.Context) {
productID = "fullbook"
}
}
amount, priceErr := getStandardPrice(db, req.ProductType, productID)
unitPrice, priceErr := getStandardPrice(db, req.ProductType, productID)
if priceErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
return
}
amount := unitPrice * float64(quantity)
if amount < 0.01 {
amount = 0.01
}
// 发起人若有推荐人绑定,享受好友优惠
var referrerID *string
var binding struct {
@@ -91,7 +117,11 @@ func GiftPayCreate(c *gin.Context) {
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)
unitPrice = unitPrice * (1 - userDiscount/100)
if unitPrice < 0.01 {
unitPrice = 0.01
}
amount = unitPrice * float64(quantity)
if amount < 0.01 {
amount = 0.01
}
@@ -101,28 +131,7 @@ func GiftPayCreate(c *gin.Context) {
}
_ = 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 := ""
@@ -155,95 +164,44 @@ func GiftPayCreate(c *gin.Context) {
ProductType: req.ProductType,
ProductID: productID,
Amount: amount,
Description: desc,
Status: "pending",
Description: desc,
Status: "pending_pay",
Quantity: quantity,
RedeemedCount: 0,
ExpireAt: expireAt,
}
if err := db.Create(&gpr).Error; err != nil {
fmt.Printf("[GiftPayCreate] 创建失败: %v\n", err)
// 若报 unknown column 'quantity' 等,需执行 soul-api/scripts/add-gift-pay-quantity.sql
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建失败"})
return
}
sectionTitle := desc
if req.ProductType == "section" && productID != "" {
var ch model.Chapter
if err := db.Select("section_title").Where("id = ?", productID).First(&ch).Error; err == nil && ch.SectionTitle != "" {
sectionTitle = ch.SectionTitle
}
}
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),
"success": true,
"requestSn": requestSN,
"path": path,
"amount": amount,
"quantity": quantity,
"sectionTitle": sectionTitle,
"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) {
// GiftPayInitiatorPay POST /api/miniprogram/gift-pay/initiator-pay 发起人支付(改造后:我帮别人付款
func GiftPayInitiatorPay(c *gin.Context) {
var req struct {
RequestSn string `json:"requestSn" binding:"required"`
OpenID string `json:"openId" binding:"required"`
UserID string `json:"userId"` // 代付人ID用于校验不能自己付
UserID string `json:"userId" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
@@ -252,8 +210,8 @@ func GiftPayPay(c *gin.Context) {
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": "代付请求不存在或已处理"})
if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "pending_pay").First(&gpr).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或已支付"})
return
}
if time.Now().After(gpr.ExpireAt) {
@@ -261,55 +219,54 @@ func GiftPayPay(c *gin.Context) {
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": "不能为自己代付"})
if 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 {
var initiator model.User
if err := db.Where("open_id = ?", req.OpenID).First(&initiator).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": "不能为自己代付"})
if initiator.ID != gpr.InitiatorUserID {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "登录用户与发起人不一致"})
return
}
// 创建订单(归属发起人,记录代付信息)
orderSn := wechat.GenerateOrderSn()
status := "created"
pm := "wechat"
productType := "gift_pay_batch"
productID := gpr.ProductID
desc := gpr.Description
desc := fmt.Sprintf("代付分享 - %s × %d 份", gpr.Description, gpr.Quantity)
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,
ID: orderSn,
OrderSN: orderSn,
UserID: gpr.InitiatorUserID,
OpenID: req.OpenID,
ProductType: productType,
ProductID: &productID,
Amount: gpr.Amount,
Description: &desc,
Status: &status,
PaymentMethod: &pm,
GiftPayRequestID: &gprID,
PayerUserID: &gpr.InitiatorUserID,
}
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)
// 微信 attach 最大 128 字节发起人付订单已存在PayNotify 从 order 取 giftPayRequestSn
attach := `{"ip":1}`
totalFee := int(math.Round(gpr.Amount * 100)) // 与正常章节支付一致,避免浮点精度导致分额错误
if totalFee < 1 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "金额异常,无法发起支付"})
return
}
ctx := c.Request.Context()
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, "代付-"+gpr.Description, attach)
if err != nil {
@@ -322,9 +279,6 @@ func GiftPayPay(c *gin.Context) {
return
}
// 预占:更新请求状态为 paying可选防并发
// 简化不预占PayNotify 时再更新
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
@@ -335,6 +289,277 @@ func GiftPayPay(c *gin.Context) {
})
}
// GiftPayDetail GET /api/miniprogram/gift-pay/detail?requestSn=xxx&userId= 或 ?sectionId=xxx&userId= 预览态
func GiftPayDetail(c *gin.Context) {
requestSn := strings.TrimSpace(c.Query("requestSn"))
sectionId := strings.TrimSpace(c.Query("sectionId"))
callerUserID := strings.TrimSpace(c.Query("userId"))
db := database.DB()
// 预览态:无 requestSn 有 sectionId返回文章信息供创建代付
if requestSn == "" && sectionId != "" {
if callerUserID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
return
}
unitPrice, priceErr := getStandardPrice(db, "section", sectionId)
if priceErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
return
}
// 发起人若有推荐人,享受折扣(与 create 一致)
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
`, callerUserID).Scan(&binding).Error; err == nil && 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 {
unitPrice = unitPrice * (1 - userDiscount/100)
if unitPrice < 0.01 {
unitPrice = 0.01
}
}
}
}
}
var ch model.Chapter
sectionTitle := ""
productMid := 0
if err := db.Select("section_title", "mid").Where("id = ?", sectionId).First(&ch).Error; err == nil {
sectionTitle = ch.SectionTitle
productMid = ch.MID
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"mode": "create",
"sectionId": sectionId,
"sectionTitle": sectionTitle,
"productMid": productMid,
"unitPrice": unitPrice,
"isInitiator": true,
"action": "create",
})
return
}
if requestSn == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少代付请求号"})
return
}
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" && gpr.Status != "pending_pay" && gpr.Status != "paid" {
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
}
isInitiator := callerUserID != "" && callerUserID == gpr.InitiatorUserID
// 发起人昵称与头像(完整展示)
var initiator model.User
nickname := "好友"
initiatorAvatar := ""
if err := db.Where("id = ?", gpr.InitiatorUserID).Select("nickname", "avatar").First(&initiator).Error; err == nil {
if initiator.Nickname != nil && *initiator.Nickname != "" {
nickname = *initiator.Nickname
}
if initiator.Avatar != nil && *initiator.Avatar != "" {
initiatorAvatar = *initiator.Avatar
}
}
// 营销:章节类型时返回标题和内容预览
sectionTitle := gpr.Description
contentPreview := ""
productMid := 0
if gpr.ProductType == "section" && gpr.ProductID != "" {
var ch model.Chapter
if err := db.Select("section_title", "content", "mid").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
if ch.SectionTitle != "" {
sectionTitle = ch.SectionTitle
}
contentPreview = giftPayPreviewContent(ch.Content)
productMid = ch.MID
}
}
// 领取记录(发起人查看)
var redeemList []gin.H
if isInitiator {
var orders []model.Order
db.Where("gift_pay_request_id = ? AND product_type = ? AND status = ?",
gpr.ID, "section", "paid").Order("created_at ASC").Find(&orders)
for _, o := range orders {
if o.UserID == "" {
continue
}
var u model.User
nickname := "用户"
avatar := ""
if err := db.Where("id = ?", o.UserID).Select("nickname", "avatar").First(&u).Error; err == nil {
if u.Nickname != nil && *u.Nickname != "" {
nickname = *u.Nickname
}
if u.Avatar != nil && *u.Avatar != "" {
avatar = *u.Avatar
}
}
redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": o.CreatedAt.Format("2006-01-02 15:04")})
}
}
// action: pay=发起人待支付 | share=发起人已支付可分享 | redeem=好友可领取 | wait=好友待发起人支付
action := ""
if isInitiator {
if gpr.Status == "pending_pay" {
action = "pay"
} else if gpr.Status == "paid" {
action = "share"
} else if gpr.Status == "pending" {
action = "share" // 旧版:待好友付
}
} else {
if gpr.Status == "pending_pay" || gpr.Status == "pending" {
action = "wait"
} else if gpr.Status == "paid" {
// 好友已领取过:返回 alreadyRedeemed供前端直接跳转 read
var existCnt int64
db.Model(&model.Order{}).Where(
"user_id = ? AND gift_pay_request_id = ? AND product_type = ? AND status = ?",
callerUserID, gpr.ID, "section", "paid",
).Count(&existCnt)
if existCnt > 0 {
action = "alreadyRedeemed"
} else {
action = "redeem"
}
}
}
resp := gin.H{
"success": true,
"requestSn": gpr.RequestSN,
"productType": gpr.ProductType,
"productId": gpr.ProductID,
"productMid": productMid,
"amount": gpr.Amount,
"quantity": gpr.Quantity,
"redeemedCount": gpr.RedeemedCount,
"redeemList": redeemList,
"description": gpr.Description,
"sectionTitle": sectionTitle,
"contentPreview": contentPreview,
"initiatorNickname": nickname,
"initiatorAvatar": initiatorAvatar,
"initiatorUserId": gpr.InitiatorUserID,
"isInitiator": isInitiator,
"action": action,
"status": gpr.Status,
"expireAt": gpr.ExpireAt.Format(time.RFC3339),
}
c.JSON(http.StatusOK, resp)
}
// GiftPayRedeem POST /api/miniprogram/gift-pay/redeem 好友领取(改造后:免费获得章节)
func GiftPayRedeem(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 = ? AND status = ?", req.RequestSn, "paid").First(&gpr).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或未支付"})
return
}
if req.UserID == gpr.InitiatorUserID {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "发起人无需领取"})
return
}
if gpr.RedeemedCount >= gpr.Quantity {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "已领完"})
return
}
// 同一用户同一 requestSn 只能领一次
var existCnt int64
db.Model(&model.Order{}).Where(
"user_id = ? AND gift_pay_request_id = ? AND product_type = ? AND status = ?",
req.UserID, gpr.ID, "section", "paid",
).Count(&existCnt)
if existCnt > 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已领取过"})
return
}
// 创建好友订单productType=section, status=paid, paymentMethod=gift_pay
orderSn := wechat.GenerateOrderSn()
status := "paid"
pm := "gift_pay"
productID := gpr.ProductID
desc := fmt.Sprintf("代付领取 - %s", gpr.Description)
gprID := gpr.ID
amount := 0.0
order := model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: req.UserID,
ProductType: "section",
ProductID: &productID,
Amount: amount,
Description: &desc,
Status: &status,
PaymentMethod: &pm,
GiftPayRequestID: &gprID,
PayerUserID: &gpr.InitiatorUserID,
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err
}
return tx.Model(&model.GiftPayRequest{}).Where("id = ?", gpr.ID).
Update("redeemed_count", gorm.Expr("redeemed_count + 1")).Error
}); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "领取失败"})
return
}
_ = amount
productMid := 0
if gpr.ProductType == "section" && gpr.ProductID != "" {
var ch model.Chapter
if err := db.Select("mid").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil {
productMid = ch.MID
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"sectionId": gpr.ProductID,
"sectionMid": productMid,
})
}
// GiftPayCancel POST /api/miniprogram/gift-pay/cancel 发起人取消
func GiftPayCancel(c *gin.Context) {
var req struct {
@@ -356,7 +581,7 @@ func GiftPayCancel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权取消"})
return
}
if gpr.Status != "pending" {
if gpr.Status != "pending" && gpr.Status != "pending_pay" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
return
}
@@ -365,7 +590,7 @@ func GiftPayCancel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已取消"})
}
// GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的
// GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的(含领取记录)
func GiftPayMyRequests(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
@@ -375,45 +600,45 @@ func GiftPayMyRequests(c *gin.Context) {
db := database.DB()
var list []model.GiftPayRequest
db.Where("initiator_user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&list)
db.Where("initiator_user_id = ? AND status != ?", userID, "cancelled").Order("created_at DESC").Limit(50).Find(&list)
out := make([]gin.H, 0, len(list))
for _, r := range list {
// 领取记录orders 表 gift_pay_request_id + product_type=section + payment_method=gift_pay
var redeemList []gin.H
var orders []model.Order
db.Where("gift_pay_request_id = ? AND product_type = ? AND status = ?",
r.ID, "section", "paid").Order("created_at ASC").Find(&orders) // 好友领取订单
for _, o := range orders {
if o.UserID == "" {
continue
}
var u model.User
nickname := "用户"
avatar := ""
if err := db.Where("id = ?", o.UserID).Select("nickname", "avatar").First(&u).Error; err == nil {
if u.Nickname != nil && *u.Nickname != "" {
nickname = *u.Nickname
}
if u.Avatar != nil && *u.Avatar != "" {
avatar = *u.Avatar
}
}
redeemAt := o.CreatedAt.Format("2006-01-02 15:04")
redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": redeemAt})
}
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),
"requestSn": r.RequestSN,
"productType": r.ProductType,
"productId": r.ProductID,
"amount": r.Amount,
"quantity": r.Quantity,
"redeemedCount": r.RedeemedCount,
"description": r.Description,
"status": r.Status,
"expireAt": r.ExpireAt.Format(time.RFC3339),
"createdAt": r.CreatedAt.Format(time.RFC3339),
"redeemList": redeemList,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "list": out})
@@ -479,6 +704,8 @@ func AdminGiftPayRequestsList(c *gin.Context) {
"productType": r.ProductType,
"productId": r.ProductID,
"amount": r.Amount,
"quantity": r.Quantity,
"redeemedCount": r.RedeemedCount,
"description": r.Description,
"status": r.Status,
"payerUserId": r.PayerUserID,

View File

@@ -69,8 +69,8 @@ func MiniprogramLogin(c *gin.Context) {
isNewUser := result.Error != nil
if isNewUser {
// 创建新用户
userID := openID // 直接使用 openid 作为用户 ID
// 创建新用户(含软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突)
userID := "user_" + randomSuffix()
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
nickname := "微信用户" + openID[len(openID)-4:]
avatar := ""
@@ -408,9 +408,17 @@ func miniprogramPayPost(c *gin.Context) {
clientIP = "127.0.0.1"
}
// userID优先用客户端传入为空时按 openid 查用户(排除软删除,避免订单归属到旧账号)
userID := req.UserID
if userID == "" {
userID = req.OpenID
if userID == "" && req.OpenID != "" {
var u model.User
if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil {
userID = u.ID
} else {
// 查不到用户:可能是未登录或软删除后未重新登录,避免用 openid 导致订单归属到旧账号
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请先登录后再支付"})
return
}
}
productID := req.ProductID
@@ -538,13 +546,38 @@ 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"`
GiftPayRequestSn string `json:"giftPayRequestSn"`
ProductType string `json:"productType"`
ProductID string `json:"productId"`
UserID string `json:"userId"`
GiftPayRequestSn string `json:"giftPayRequestSn"`
GiftPayInitiatorPay bool `json:"giftPayInitiatorPay"`
PT string `json:"pt"`
PID string `json:"pid"`
UID string `json:"uid"`
SN string `json:"sn"`
IP int `json:"ip"`
}
if attachStr != "" {
_ = json.Unmarshal([]byte(attachStr), &attach)
if attach.ProductType == "" {
if attach.PT == "gpb" {
attach.ProductType = "gift_pay_batch"
} else {
attach.ProductType = attach.PT
}
}
if attach.ProductID == "" {
attach.ProductID = attach.PID
}
if attach.UserID == "" {
attach.UserID = attach.UID
}
if attach.GiftPayRequestSn == "" {
attach.GiftPayRequestSn = attach.SN
}
if attach.IP != 0 {
attach.GiftPayInitiatorPay = true
}
}
db := database.DB()
@@ -612,13 +645,23 @@ func MiniprogramPayNotify(c *gin.Context) {
}
// 代付订单:更新 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)
// 权益归属与分佣:旧版好友付归发起人;新版发起人付不发放权益(好友领取时再发)
giftPayRequestSn := attach.GiftPayRequestSn
if giftPayRequestSn == "" && order.GiftPayRequestID != nil && *order.GiftPayRequestID != "" {
var gpr model.GiftPayRequest
if err := db.Where("id = ?", *order.GiftPayRequestID).Select("request_sn").First(&gpr).Error; err == nil {
giftPayRequestSn = gpr.RequestSN
}
}
if attach.GiftPayRequestSn != "" {
beneficiaryUserID := buyerUserID
if giftPayRequestSn != "" && order.UserID != "" && !attach.GiftPayInitiatorPay {
beneficiaryUserID = order.UserID
fmt.Printf("[PayNotify] 代付订单(好友付),权益归属发起人: %s\n", beneficiaryUserID)
}
if attach.GiftPayInitiatorPay {
fmt.Printf("[PayNotify] 代付订单(发起人付),不发放权益,好友领取时再发\n")
}
if giftPayRequestSn != "" {
var payerUserID string
if openID != "" {
var payer model.User
@@ -627,7 +670,7 @@ func MiniprogramPayNotify(c *gin.Context) {
db.Model(&order).Update("payer_user_id", payerUserID)
}
}
db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", attach.GiftPayRequestSn).
db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", giftPayRequestSn).
Updates(map[string]interface{}{
"status": "paid",
"payer_user_id": payerUserID,

View File

@@ -664,6 +664,11 @@ func MiniprogramTrackPost(c *gin.Context) {
if body.Target != "" {
t.ChapterID = &chID
}
if body.ExtraData != nil {
if b, err := json.Marshal(body.ExtraData); err == nil {
t.ExtraData = b
}
}
if err := db.Create(&t).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -54,6 +54,8 @@ func WechatPhoneLogin(c *gin.Context) {
isNewUser := result.Error != nil
if isNewUser {
// 软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突
userID := "user_" + randomSuffix()
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
nickname := "微信用户" + openID[len(openID)-4:]
avatar := ""
@@ -67,7 +69,7 @@ func WechatPhoneLogin(c *gin.Context) {
phone = "+" + countryCode + " " + phoneNumber
}
user = model.User{
ID: openID,
ID: userID,
OpenID: &openID,
SessionKey: &sessionKey,
Nickname: &nickname,