1741 lines
57 KiB
Go
1741 lines
57 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"soul-api/internal/cache"
|
||
"soul-api/internal/config"
|
||
"soul-api/internal/database"
|
||
"soul-api/internal/model"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// parseConfigBool 将 JSON/map 中可能出现的 bool、字符串、数字归一为开关态(auditMode 等)
|
||
func parseConfigBool(v interface{}) bool {
|
||
if v == nil {
|
||
return false
|
||
}
|
||
switch t := v.(type) {
|
||
case bool:
|
||
return t
|
||
case string:
|
||
s := strings.ToLower(strings.TrimSpace(t))
|
||
return s == "1" || s == "true" || s == "yes" || s == "on"
|
||
case float64:
|
||
return t != 0
|
||
case int:
|
||
return t != 0
|
||
case int64:
|
||
return t != 0
|
||
case json.Number:
|
||
if i, err := t.Int64(); err == nil {
|
||
return i != 0
|
||
}
|
||
if f, err := t.Float64(); err == nil {
|
||
return f != 0
|
||
}
|
||
return false
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// defaultMpUi 小程序文案与导航默认值,存于 mp_config.mpUi;管理端系统设置可部分覆盖(深合并)
|
||
func defaultMpUi() gin.H {
|
||
return gin.H{
|
||
"tabBar": gin.H{
|
||
"home": "首页", "chapters": "目录", "match": "找伙伴", "my": "我的",
|
||
},
|
||
"chaptersPage": gin.H{
|
||
"bookTitle": "一场SOUL的创业实验场",
|
||
"bookSubtitle": "来自Soul派对房的真实商业故事",
|
||
},
|
||
"homePage": gin.H{
|
||
"logoTitle": "卡若创业派对", "logoSubtitle": "来自派对房的真实故事",
|
||
"linkKaruoText": "点击链接卡若", "linkKaruoAvatar": "",
|
||
"searchPlaceholder": "搜索章节标题或内容...",
|
||
"bannerTag": "推荐", "bannerReadMoreText": "点击阅读",
|
||
"superSectionTitle": "超级个体", "superSectionLinkText": "获客入口",
|
||
"superSectionLinkPath": "/pages/match/match",
|
||
"pickSectionTitle": "精选推荐",
|
||
"latestSectionTitle": "最新新增",
|
||
},
|
||
"myPage": gin.H{
|
||
"cardLabel": "名片", "vipLabelVip": "会员中心", "vipLabelGuest": "成为会员",
|
||
"cardPath": "", "vipPath": "/pages/vip/vip",
|
||
"readStatLabel": "已读章节", "recentReadTitle": "最近阅读",
|
||
"readStatPath": "/pages/reading-records/reading-records?focus=all",
|
||
"recentReadPath": "/pages/reading-records/reading-records?focus=recent",
|
||
},
|
||
}
|
||
}
|
||
|
||
func asStringMap(v interface{}) map[string]interface{} {
|
||
if v == nil {
|
||
return map[string]interface{}{}
|
||
}
|
||
m, ok := v.(map[string]interface{})
|
||
if !ok {
|
||
return map[string]interface{}{}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// deepMergeMpUi 将 DB 中的 mpUi 与默认值深合并(嵌套 map)
|
||
func deepMergeMpUi(base gin.H, overRaw interface{}) gin.H {
|
||
over := asStringMap(overRaw)
|
||
out := gin.H{}
|
||
for k, v := range base {
|
||
out[k] = v
|
||
}
|
||
for k, v := range over {
|
||
if v == nil {
|
||
continue
|
||
}
|
||
bv := out[k]
|
||
vm := asStringMap(v)
|
||
if len(vm) == 0 && v != nil {
|
||
// 非 map 覆盖
|
||
out[k] = v
|
||
continue
|
||
}
|
||
if len(vm) > 0 {
|
||
bm := asStringMap(bv)
|
||
if len(bm) == 0 {
|
||
out[k] = deepMergeMpUi(gin.H{}, vm)
|
||
} else {
|
||
sub := gin.H{}
|
||
for sk, sv := range bm {
|
||
sub[sk] = sv
|
||
}
|
||
out[k] = deepMergeMpUi(sub, vm)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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"
|
||
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
|
||
apiDomain = cfg.BaseURL
|
||
}
|
||
defaultMp := gin.H{
|
||
"appId": "wxb8bbb2b10dec74aa",
|
||
"apiDomain": apiDomain,
|
||
"buyerDiscount": 5,
|
||
"referralBindDays": 30,
|
||
"minWithdraw": 10,
|
||
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
|
||
"mchId": "1318592501",
|
||
"auditMode": false,
|
||
"supportWechat": true,
|
||
"shareIcon": "", // 分享图标URL,由管理端配置
|
||
"mpUi": defaultMpUi(),
|
||
}
|
||
|
||
out := gin.H{
|
||
"success": true,
|
||
"prices": defaultPrices,
|
||
"features": defaultFeatures,
|
||
"mpConfig": defaultMp,
|
||
"configs": gin.H{},
|
||
}
|
||
db := database.DB()
|
||
|
||
keys := []string{"chapter_config", "feature_config", "mp_config"}
|
||
for _, k := range keys {
|
||
var row model.SystemConfig
|
||
if err := db.Where("config_key = ?", k).First(&row).Error; err != nil {
|
||
continue
|
||
}
|
||
var val interface{}
|
||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
|
||
continue
|
||
}
|
||
switch k {
|
||
case "chapter_config":
|
||
if m, ok := val.(map[string]interface{}); ok {
|
||
if v, ok := m["prices"].(map[string]interface{}); ok {
|
||
out["prices"] = v
|
||
}
|
||
if v, ok := m["features"].(map[string]interface{}); ok {
|
||
out["features"] = v
|
||
}
|
||
out["configs"].(gin.H)["chapter_config"] = m
|
||
}
|
||
case "feature_config":
|
||
if m, ok := val.(map[string]interface{}); ok {
|
||
// 合并到 features,不整体覆盖以保留 chapter_config 里的
|
||
cur := out["features"].(gin.H)
|
||
for kk, vv := range m {
|
||
cur[kk] = vv
|
||
}
|
||
out["configs"].(gin.H)["feature_config"] = m
|
||
}
|
||
case "mp_config":
|
||
if m, ok := val.(map[string]interface{}); ok {
|
||
// 合并默认值,DB 有则覆盖
|
||
merged := make(gin.H)
|
||
for k, v := range defaultMp {
|
||
merged[k] = v
|
||
}
|
||
for k, v := range m {
|
||
merged[k] = v
|
||
}
|
||
merged["mpUi"] = deepMergeMpUi(defaultMpUi(), m["mpUi"])
|
||
out["mpConfig"] = merged
|
||
out["configs"].(gin.H)["mp_config"] = merged
|
||
}
|
||
}
|
||
}
|
||
// 价格:以管理端「站点与作者」site_settings 为准(运营唯一配置入口),无则用 chapter_config 或默认值
|
||
var siteRow model.SystemConfig
|
||
if err := db.Where("config_key = ?", "site_settings").First(&siteRow).Error; err == nil && len(siteRow.ConfigValue) > 0 {
|
||
var siteVal map[string]interface{}
|
||
if err := json.Unmarshal(siteRow.ConfigValue, &siteVal); err == nil {
|
||
cur := out["prices"].(gin.H)
|
||
if v, ok := siteVal["sectionPrice"].(float64); ok && v > 0 {
|
||
cur["section"] = v
|
||
}
|
||
if v, ok := siteVal["baseBookPrice"].(float64); ok && v > 0 {
|
||
cur["fullbook"] = v
|
||
}
|
||
}
|
||
}
|
||
// 好友优惠(用于 read 页展示优惠价)
|
||
var refRow model.SystemConfig
|
||
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil {
|
||
var refVal map[string]interface{}
|
||
if err := json.Unmarshal(refRow.ConfigValue, &refVal); err == nil {
|
||
if v, ok := refVal["userDiscount"].(float64); ok {
|
||
out["userDiscount"] = v
|
||
}
|
||
}
|
||
}
|
||
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
|
||
} else {
|
||
// JSON解析失败,使用空数组
|
||
out["linkedMiniprograms"] = []gin.H{}
|
||
}
|
||
} else {
|
||
// 未找到配置或查询失败,使用空数组作为默认值
|
||
out["linkedMiniprograms"] = []gin.H{}
|
||
}
|
||
// 归一化 auditMode(兼容历史 bool / 字符串 / 数字)
|
||
if mp, ok := out["mpConfig"].(gin.H); ok {
|
||
mp["auditMode"] = parseConfigBool(mp["auditMode"])
|
||
}
|
||
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 审核模式独立接口,管理端开关后快速生效
|
||
// 缓存未命中时仅查 mp_config 一条记录,避免 buildMiniprogramConfig 全量查询导致超时
|
||
// Redis 不可用时 cache 包自动降级到内存备用
|
||
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
|
||
}
|
||
auditMode := getAuditModeFromDB()
|
||
out := gin.H{"auditMode": auditMode}
|
||
cache.Set(context.Background(), cache.KeyConfigAuditMode, out, cache.AuditModeTTL)
|
||
c.JSON(http.StatusOK, out)
|
||
}
|
||
|
||
// getAuditModeFromDB 仅查询 mp_config 的 auditMode,轻量级避免超时
|
||
func getAuditModeFromDB() bool {
|
||
var row model.SystemConfig
|
||
if err := database.DB().Where("config_key = ?", "mp_config").First(&row).Error; err != nil {
|
||
return false
|
||
}
|
||
var mp map[string]interface{}
|
||
if err := json.Unmarshal(row.ConfigValue, &mp); err != nil {
|
||
return false
|
||
}
|
||
return parseConfigBool(mp["auditMode"])
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// buildMentionPersonsForRead 阅读页 @ 自动解析用:与后台 persons 表一致(含 name/label/aliases/token)。
|
||
// inPool=true 表示已绑定会员 user_id(超级个体/可进匹配流量池),仍参与 @ 匹配以便正文与后台人物库对齐。
|
||
func buildMentionPersonsForRead() []gin.H {
|
||
db := database.DB()
|
||
var rows []model.Person
|
||
if err := db.Select("person_id", "token", "name", "aliases", "label", "user_id").
|
||
Where("token IS NOT NULL AND token != ?", "").
|
||
Order("name ASC").
|
||
Find(&rows).Error; err != nil {
|
||
return []gin.H{}
|
||
}
|
||
out := make([]gin.H, 0, len(rows))
|
||
for _, p := range rows {
|
||
inPool := p.UserID != nil && strings.TrimSpace(*p.UserID) != ""
|
||
out = append(out, gin.H{
|
||
"personId": p.PersonID,
|
||
"token": p.Token,
|
||
"name": strings.TrimSpace(p.Name),
|
||
"aliases": strings.TrimSpace(p.Aliases),
|
||
"label": strings.TrimSpace(p.Label),
|
||
"inPool": inPool,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// GetReadExtras GET /api/miniprogram/config/read-extras 阅读页扩展(linkTags、linkedMiniprograms、mentionPersons),懒加载
|
||
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"],
|
||
"mentionPersons": buildMentionPersonsForRead(),
|
||
}
|
||
if out["linkTags"] == nil {
|
||
out["linkTags"] = []gin.H{}
|
||
}
|
||
if out["linkedMiniprograms"] == nil {
|
||
out["linkedMiniprograms"] = []gin.H{}
|
||
}
|
||
if out["mentionPersons"] == nil {
|
||
out["mentionPersons"] = []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 {
|
||
auditMode = parseConfigBool(mp["auditMode"])
|
||
}
|
||
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"],
|
||
"mentionPersons": buildMentionPersonsForRead(),
|
||
}
|
||
if readExtras["linkTags"] == nil {
|
||
readExtras["linkTags"] = []gin.H{}
|
||
}
|
||
if readExtras["linkedMiniprograms"] == nil {
|
||
readExtras["linkedMiniprograms"] = []gin.H{}
|
||
}
|
||
if readExtras["mentionPersons"] == nil {
|
||
readExtras["mentionPersons"] = []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")
|
||
db := database.DB()
|
||
var list []model.SystemConfig
|
||
q := db.Table("system_config")
|
||
if key != "" {
|
||
q = q.Where("config_key = ?", key)
|
||
}
|
||
if err := q.Find(&list).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
if key != "" && len(list) == 1 {
|
||
var val interface{}
|
||
_ = json.Unmarshal(list[0].ConfigValue, &val)
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
|
||
return
|
||
}
|
||
data := make([]gin.H, 0, len(list))
|
||
for _, row := range list {
|
||
var val interface{}
|
||
_ = json.Unmarshal(row.ConfigValue, &val)
|
||
data = append(data, gin.H{"configKey": row.ConfigKey, "configValue": val})
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": data})
|
||
}
|
||
|
||
// AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回功能开关、站点/作者与价格、小程序配置
|
||
func AdminSettingsGet(c *gin.Context) {
|
||
db := database.DB()
|
||
apiDomain := "https://soulapi.quwanzhi.com"
|
||
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
|
||
apiDomain = cfg.BaseURL
|
||
}
|
||
defaultMp := gin.H{
|
||
"appId": "wxb8bbb2b10dec74aa",
|
||
"apiDomain": apiDomain,
|
||
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
|
||
"mchId": "1318592501",
|
||
"minWithdraw": float64(10),
|
||
"auditMode": false,
|
||
"supportWechat": true,
|
||
"mpUi": defaultMpUi(),
|
||
}
|
||
out := gin.H{
|
||
"success": 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{},
|
||
}
|
||
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 {
|
||
continue
|
||
}
|
||
var val interface{}
|
||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
|
||
continue
|
||
}
|
||
switch k {
|
||
case "feature_config":
|
||
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
|
||
out["featureConfig"] = m
|
||
}
|
||
case "site_settings":
|
||
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
|
||
out["siteSettings"] = m
|
||
}
|
||
case "mp_config":
|
||
if m, ok := val.(map[string]interface{}); ok {
|
||
merged := make(gin.H)
|
||
for k, v := range defaultMp {
|
||
merged[k] = v
|
||
}
|
||
for k, v := range m {
|
||
merged[k] = v
|
||
}
|
||
merged["mpUi"] = deepMergeMpUi(defaultMpUi(), m["mpUi"])
|
||
out["mpConfig"] = merged
|
||
}
|
||
case "oss_config":
|
||
if m, ok := val.(map[string]interface{}); ok {
|
||
safe := make(map[string]interface{})
|
||
for k, v := range m {
|
||
if k == "accessKeySecret" {
|
||
safe[k] = "****"
|
||
} else {
|
||
safe[k] = v
|
||
}
|
||
}
|
||
out["ossConfig"] = safe
|
||
}
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, out)
|
||
}
|
||
|
||
// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存功能开关、站点/作者与价格、小程序配置
|
||
func AdminSettingsPost(c *gin.Context) {
|
||
var body struct {
|
||
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": "请求体无效"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
saveKey := func(key, desc string, value interface{}) error {
|
||
valBytes, err := json.Marshal(value)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var row model.SystemConfig
|
||
err = db.Where("config_key = ?", key).First(&row).Error
|
||
if err != nil {
|
||
row = model.SystemConfig{ConfigKey: key, ConfigValue: valBytes, Description: &desc}
|
||
return db.Create(&row).Error
|
||
}
|
||
row.ConfigValue = valBytes
|
||
if desc != "" {
|
||
row.Description = &desc
|
||
}
|
||
return db.Save(&row).Error
|
||
}
|
||
if body.FeatureConfig != nil {
|
||
if err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存功能开关失败: " + err.Error()})
|
||
return
|
||
}
|
||
}
|
||
if body.SiteSettings != nil {
|
||
if err := saveKey("site_settings", "站点与作者配置", body.SiteSettings); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存站点设置失败: " + err.Error()})
|
||
return
|
||
}
|
||
}
|
||
if body.MpConfig != nil {
|
||
if err := saveKey("mp_config", "小程序专用配置", body.MpConfig); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存小程序配置失败: " + err.Error()})
|
||
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": "设置已保存"})
|
||
}
|
||
|
||
// AdminReferralSettingsGet GET /api/admin/referral-settings 推广设置页专用:仅返回 referral_config
|
||
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),
|
||
"vipOrderShareNonVip": float64(10),
|
||
}
|
||
var row model.SystemConfig
|
||
if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig})
|
||
return
|
||
}
|
||
var val map[string]interface{}
|
||
if err := json.Unmarshal(row.ConfigValue, &val); err != nil || len(val) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
|
||
}
|
||
|
||
// AdminReferralSettingsPost POST /api/admin/referral-settings 推广设置页专用:仅保存 referral_config(请求体为完整配置对象)
|
||
func AdminReferralSettingsPost(c *gin.Context) {
|
||
var body struct {
|
||
DistributorShare float64 `json:"distributorShare"`
|
||
MinWithdrawAmount float64 `json:"minWithdrawAmount"`
|
||
BindingDays float64 `json:"bindingDays"`
|
||
UserDiscount float64 `json:"userDiscount"`
|
||
WithdrawFee float64 `json:"withdrawFee"`
|
||
EnableAutoWithdraw bool `json:"enableAutoWithdraw"`
|
||
VipOrderShareVip float64 `json:"vipOrderShareVip"`
|
||
VipOrderShareNonVip float64 `json:"vipOrderShareNonVip"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||
return
|
||
}
|
||
vipOrderShareVip := body.VipOrderShareVip
|
||
if vipOrderShareVip == 0 {
|
||
vipOrderShareVip = 20
|
||
}
|
||
vipOrderShareNonVip := body.VipOrderShareNonVip
|
||
if vipOrderShareNonVip == 0 {
|
||
vipOrderShareNonVip = 10
|
||
}
|
||
val := gin.H{
|
||
"distributorShare": body.DistributorShare,
|
||
"minWithdrawAmount": body.MinWithdrawAmount,
|
||
"bindingDays": body.BindingDays,
|
||
"userDiscount": body.UserDiscount,
|
||
"withdrawFee": body.WithdrawFee,
|
||
"enableAutoWithdraw": body.EnableAutoWithdraw,
|
||
"vipOrderShareVip": vipOrderShareVip,
|
||
"vipOrderShareNonVip": vipOrderShareNonVip,
|
||
}
|
||
valBytes, err := json.Marshal(val)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
desc := "分销 / 推广规则配置"
|
||
var row model.SystemConfig
|
||
if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil {
|
||
row = model.SystemConfig{ConfigKey: "referral_config", ConfigValue: valBytes, Description: &desc}
|
||
err = db.Create(&row).Error
|
||
} else {
|
||
row.ConfigValue = valBytes
|
||
row.Description = &desc
|
||
err = db.Save(&row).Error
|
||
}
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
cache.InvalidateConfig()
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
|
||
}
|
||
|
||
func authorConfigToResponse(row *model.AuthorConfig) gin.H {
|
||
defaultStats := []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}
|
||
defaultHighlights := []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"}
|
||
var stats []gin.H
|
||
if row.Stats != "" {
|
||
_ = json.Unmarshal([]byte(row.Stats), &stats)
|
||
}
|
||
if len(stats) == 0 {
|
||
stats = defaultStats
|
||
}
|
||
var highlights []string
|
||
if row.Highlights != "" {
|
||
_ = json.Unmarshal([]byte(row.Highlights), &highlights)
|
||
}
|
||
if len(highlights) == 0 {
|
||
highlights = defaultHighlights
|
||
}
|
||
return gin.H{
|
||
"name": row.Name,
|
||
"avatar": row.Avatar,
|
||
"avatarImg": row.AvatarImg,
|
||
"title": row.Title,
|
||
"bio": row.Bio,
|
||
"stats": stats,
|
||
"highlights": highlights,
|
||
}
|
||
}
|
||
|
||
// AdminAuthorSettingsGet GET /api/admin/author-settings 作者详情配置(管理端专用)
|
||
func AdminAuthorSettingsGet(c *gin.Context) {
|
||
defaultAuthor := gin.H{
|
||
"name": "卡若",
|
||
"avatar": "K",
|
||
"avatarImg": "",
|
||
"title": "Soul派对房主理人 · 私域运营专家",
|
||
"bio": "每天早上6点到9点,在Soul派对房分享真实的创业故事。专注私域运营与项目变现,用云阿米巴模式帮助创业者构建可持续的商业体系。",
|
||
"stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}},
|
||
"highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"},
|
||
}
|
||
db := database.DB()
|
||
var row model.AuthorConfig
|
||
if err := db.First(&row).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)})
|
||
}
|
||
|
||
// AdminAuthorSettingsPost POST /api/admin/author-settings 保存作者详情配置
|
||
func AdminAuthorSettingsPost(c *gin.Context) {
|
||
var body map[string]interface{}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||
return
|
||
}
|
||
str := func(k string) string {
|
||
if v, ok := body[k]; ok && v != nil {
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
return fmt.Sprintf("%v", v)
|
||
}
|
||
return ""
|
||
}
|
||
name := str("name")
|
||
if name == "" {
|
||
name = "卡若"
|
||
}
|
||
avatar := str("avatar")
|
||
if avatar == "" {
|
||
avatar = "K"
|
||
}
|
||
statsVal := body["stats"]
|
||
if statsVal == nil {
|
||
statsVal = []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}}
|
||
}
|
||
highlightsVal := body["highlights"]
|
||
if highlightsVal == nil {
|
||
highlightsVal = []string{}
|
||
}
|
||
statsBytes, _ := json.Marshal(statsVal)
|
||
highlightsBytes, _ := json.Marshal(highlightsVal)
|
||
|
||
db := database.DB()
|
||
var row model.AuthorConfig
|
||
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),
|
||
Highlights: string(highlightsBytes),
|
||
}
|
||
err = db.Create(&row).Error
|
||
} else {
|
||
row.Name = name
|
||
row.Avatar = avatar
|
||
row.AvatarImg = str("avatarImg")
|
||
row.Title = str("title")
|
||
row.Bio = str("bio")
|
||
row.Stats = string(statsBytes)
|
||
row.Highlights = string(highlightsBytes)
|
||
err = db.Save(&row).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": "作者设置已保存"})
|
||
}
|
||
|
||
// MiniprogramAboutAuthor GET /api/miniprogram/about/author 小程序-关于作者页拉取作者配置(公开,无需鉴权)
|
||
func MiniprogramAboutAuthor(c *gin.Context) {
|
||
defaultAuthor := gin.H{
|
||
"name": "卡若",
|
||
"avatar": "K",
|
||
"avatarImg": "",
|
||
"title": "Soul派对房主理人 · 私域运营专家",
|
||
"bio": "每天早上6点到9点,在Soul派对房分享真实的创业故事。",
|
||
"stats": []gin.H{{"label": "商业案例", "value": "62"}, {"label": "连续直播", "value": "365天"}, {"label": "派对分享", "value": "1000+"}},
|
||
"highlights": []string{"5年私域运营经验", "帮助100+品牌从0到1增长", "连续创业者,擅长商业模式设计"},
|
||
}
|
||
db := database.DB()
|
||
var row model.AuthorConfig
|
||
if err := db.First(&row).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultAuthor})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": authorConfigToResponse(&row)})
|
||
}
|
||
|
||
// DBConfigPost POST /api/db/config
|
||
func DBConfigPost(c *gin.Context) {
|
||
var body struct {
|
||
Key string `json:"key"`
|
||
Value interface{} `json:"value"`
|
||
Description string `json:"description"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil || body.Key == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
|
||
return
|
||
}
|
||
valBytes, err := json.Marshal(body.Value)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
desc := body.Description
|
||
var row model.SystemConfig
|
||
err = db.Where("config_key = ?", body.Key).First(&row).Error
|
||
if err != nil {
|
||
row = model.SystemConfig{ConfigKey: body.Key, ConfigValue: valBytes, Description: &desc}
|
||
err = db.Create(&row).Error
|
||
} else {
|
||
row.ConfigValue = valBytes
|
||
if body.Description != "" {
|
||
row.Description = &desc
|
||
}
|
||
err = db.Save(&row).Error
|
||
}
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
cache.InvalidateConfig()
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
|
||
}
|
||
|
||
// DBUsersList GET /api/db/users(支持分页 page、pageSize,可选搜索 search;有 id 时返回单个 user;购买状态、分销收益、绑定人数从订单/绑定表实时计算)
|
||
func DBUsersList(c *gin.Context) {
|
||
db := database.DB()
|
||
id := strings.TrimSpace(c.Query("id"))
|
||
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" 时仅返回 VIP(hasFullBook)
|
||
poolFilter := c.Query("pool") // "complete" 时仅返回已完善资料的用户
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 100 {
|
||
pageSize = 10
|
||
}
|
||
|
||
// 有 id 时返回单个用户(供 UserDetailModal 等使用)
|
||
if id != "" {
|
||
var user model.User
|
||
if err := db.Where("id = ?", id).First(&user).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "user": nil})
|
||
return
|
||
}
|
||
// 填充 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)
|
||
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
|
||
}
|
||
|
||
q := db.Model(&model.User{})
|
||
if search != "" {
|
||
pattern := "%" + search + "%"
|
||
q = q.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
|
||
}
|
||
if poolFilter == "complete" {
|
||
q = q.Where("(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')")
|
||
} else if vipFilter == "true" || vipFilter == "1" {
|
||
q = q.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND (status = ? OR status = ?)) OR (is_vip = 1 AND vip_expire_date > ?)",
|
||
[]string{"fullbook", "vip"}, "paid", "completed", time.Now())
|
||
}
|
||
var total int64
|
||
q.Count(&total)
|
||
|
||
var users []model.User
|
||
query := db.Model(&model.User{})
|
||
if search != "" {
|
||
pattern := "%" + search + "%"
|
||
query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
|
||
}
|
||
if poolFilter == "complete" {
|
||
query = query.Where("(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')")
|
||
} else if vipFilter == "true" || vipFilter == "1" {
|
||
query = query.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND (status = ? OR status = ?)) OR (is_vip = 1 AND vip_expire_date > ?)",
|
||
[]string{"fullbook", "vip"}, "paid", "completed", time.Now())
|
||
}
|
||
if err := query.Order("created_at DESC").
|
||
Offset((page - 1) * pageSize).
|
||
Limit(pageSize).
|
||
Find(&users).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "users": []interface{}{}})
|
||
return
|
||
}
|
||
totalPages := int(total) / pageSize
|
||
if int(total)%pageSize > 0 {
|
||
totalPages++
|
||
}
|
||
|
||
if len(users) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true, "users": users,
|
||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||
})
|
||
return
|
||
}
|
||
|
||
userIDs := make([]string, 0, len(users))
|
||
for _, u := range users {
|
||
userIDs = append(userIDs, u.ID)
|
||
}
|
||
|
||
// 1. 购买状态:全书已购、已付费章节数(从 orders 计算)
|
||
hasFullBookMap := make(map[string]bool)
|
||
sectionCountMap := make(map[string]int)
|
||
var fullbookRows []struct {
|
||
UserID string
|
||
}
|
||
db.Model(&model.Order{}).Select("user_id").Where("product_type IN ? AND status IN ?", []string{"fullbook", "vip"}, []string{"paid", "completed", "success"}).Find(&fullbookRows)
|
||
for _, r := range fullbookRows {
|
||
hasFullBookMap[r.UserID] = true
|
||
}
|
||
var sectionRows []struct {
|
||
UserID string
|
||
Count int64
|
||
}
|
||
db.Model(&model.Order{}).Select("user_id, COUNT(*) as count").
|
||
Where("product_type = ? AND status IN ?", "section", []string{"paid", "completed", "success"}).
|
||
Group("user_id").Find(§ionRows)
|
||
for _, r := range sectionRows {
|
||
sectionCountMap[r.UserID] = int(r.Count)
|
||
}
|
||
|
||
// 2. 分销收益:从 referrer 订单逐条 computeOrderCommission 求和(会员订单 20%/10%,内容订单 90%)
|
||
referrerEarningsMap := make(map[string]float64)
|
||
var referrerOrders []model.Order
|
||
db.Where("referrer_id IS NOT NULL AND referrer_id != '' AND status = ?", "paid").Find(&referrerOrders)
|
||
for i := range referrerOrders {
|
||
rid := referrerOrders[i].ReferrerID
|
||
if rid != nil && *rid != "" {
|
||
referrerEarningsMap[*rid] += computeOrderCommission(db, &referrerOrders[i], nil)
|
||
}
|
||
}
|
||
withdrawnMap := make(map[string]float64)
|
||
var withdrawnRows []struct {
|
||
UserID string
|
||
Total float64
|
||
}
|
||
db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total").
|
||
Where("status = ?", "success").
|
||
Group("user_id").Find(&withdrawnRows)
|
||
for _, r := range withdrawnRows {
|
||
withdrawnMap[r.UserID] = r.Total
|
||
}
|
||
pendingWithdrawMap := make(map[string]float64)
|
||
var pendingRows []struct {
|
||
UserID string
|
||
Total float64
|
||
}
|
||
db.Model(&model.Withdrawal{}).Select("user_id, COALESCE(SUM(amount), 0) as total").
|
||
Where("status IN ?", []string{"pending", "processing", "pending_confirm"}).
|
||
Group("user_id").Find(&pendingRows)
|
||
for _, r := range pendingRows {
|
||
pendingWithdrawMap[r.UserID] = r.Total
|
||
}
|
||
|
||
// 3. 绑定人数:综合 referral_bindings + orders.referrer_id + users.referral_count 三源取最大值
|
||
referralCountMap := make(map[string]int)
|
||
var refCountRows []struct {
|
||
ReferrerID string `gorm:"column:referrer_id"`
|
||
Count int64 `gorm:"column:count"`
|
||
}
|
||
db.Model(&model.ReferralBinding{}).Select("referrer_id, COUNT(*) as count").
|
||
Group("referrer_id").Scan(&refCountRows)
|
||
for _, r := range refCountRows {
|
||
referralCountMap[r.ReferrerID] = int(r.Count)
|
||
}
|
||
var orderRefRows []struct {
|
||
ReferrerID string `gorm:"column:referrer_id"`
|
||
Count int64 `gorm:"column:count"`
|
||
}
|
||
db.Model(&model.Order{}).Select("referrer_id, COUNT(DISTINCT user_id) as count").
|
||
Where("referrer_id IS NOT NULL AND referrer_id != '' AND status = ?", "paid").
|
||
Group("referrer_id").Scan(&orderRefRows)
|
||
for _, r := range orderRefRows {
|
||
if int(r.Count) > referralCountMap[r.ReferrerID] {
|
||
referralCountMap[r.ReferrerID] = int(r.Count)
|
||
}
|
||
}
|
||
|
||
// 4. RFM 实时打分:对当前页用户批量计算(只查当前页 userIDs 的聚合)
|
||
type rfmAgg struct {
|
||
UserID string
|
||
OrderCount int
|
||
TotalAmount float64
|
||
LastOrderAt time.Time
|
||
}
|
||
var rfmAggs []rfmAgg
|
||
db.Raw(`SELECT user_id, COUNT(*) as order_count, SUM(amount) as total_amount, MAX(created_at) as last_order_at
|
||
FROM orders WHERE user_id IN ? AND status IN ('paid','success','completed')
|
||
GROUP BY user_id`, userIDs).Scan(&rfmAggs)
|
||
rfmAggMap := make(map[string]rfmAgg, len(rfmAggs))
|
||
var rfmMaxRecency, rfmMaxFreq int
|
||
var rfmMaxMonetary float64
|
||
now := time.Now()
|
||
for _, a := range rfmAggs {
|
||
rfmAggMap[a.UserID] = a
|
||
days := int(now.Sub(a.LastOrderAt).Hours() / 24)
|
||
if days > rfmMaxRecency {
|
||
rfmMaxRecency = days
|
||
}
|
||
if a.OrderCount > rfmMaxFreq {
|
||
rfmMaxFreq = a.OrderCount
|
||
}
|
||
if a.TotalAmount > rfmMaxMonetary {
|
||
rfmMaxMonetary = a.TotalAmount
|
||
}
|
||
}
|
||
|
||
// 填充每个用户的实时计算字段
|
||
for i := range users {
|
||
uid := users[i].ID
|
||
// 购买状态(含订单、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]
|
||
// 分销收益
|
||
totalE := referrerEarningsMap[uid]
|
||
withdrawn := withdrawnMap[uid]
|
||
pendingWd := pendingWithdrawMap[uid]
|
||
available := totalE - withdrawn - pendingWd
|
||
if available < 0 {
|
||
available = 0
|
||
}
|
||
users[i].Earnings = ptrFloat64(totalE)
|
||
users[i].PendingEarnings = ptrFloat64(available)
|
||
bindCount := referralCountMap[uid]
|
||
dbCount := 0
|
||
if users[i].ReferralCount != nil {
|
||
dbCount = *users[i].ReferralCount
|
||
}
|
||
if dbCount > bindCount {
|
||
bindCount = dbCount
|
||
}
|
||
users[i].ReferralCount = ptrInt(bindCount)
|
||
|
||
// RFM 打分(有订单的用户才有分数)
|
||
if agg, ok := rfmAggMap[uid]; ok {
|
||
recencyDays := int(now.Sub(agg.LastOrderAt).Hours() / 24)
|
||
score := calcRFMScoreForUser(recencyDays, agg.OrderCount, agg.TotalAmount,
|
||
rfmMaxRecency, rfmMaxFreq, rfmMaxMonetary)
|
||
level := calcRFMLevel(score)
|
||
users[i].RFMScore = ptrFloat64(score)
|
||
users[i].RFMLevel = &level
|
||
}
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true, "users": users,
|
||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||
})
|
||
}
|
||
|
||
func ptrBool(b bool) *bool { return &b }
|
||
func ptrFloat64(f float64) *float64 { v := f; return &v }
|
||
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"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||
return
|
||
}
|
||
userID := "user_" + randomSuffix()
|
||
code := "SOUL" + randomSuffix()[:4]
|
||
nick := "用户"
|
||
if body.Nickname != nil && *body.Nickname != "" {
|
||
nick = *body.Nickname
|
||
} else {
|
||
nick = nick + userID[len(userID)-4:]
|
||
}
|
||
u := model.User{
|
||
ID: userID, Nickname: &nick, ReferralCode: &code,
|
||
OpenID: body.OpenID, Phone: body.Phone, WechatID: body.WechatID, Avatar: body.Avatar,
|
||
}
|
||
if body.IsAdmin != nil {
|
||
u.IsAdmin = body.IsAdmin
|
||
}
|
||
if err := db.Create(&u).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
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;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"`
|
||
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不能为空"})
|
||
return
|
||
}
|
||
// 手动设置 VIP 时,必须提供有效到期日
|
||
if body.IsVip != nil && *body.IsVip {
|
||
if body.VipExpireDate == nil || strings.TrimSpace(*body.VipExpireDate) == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "开启 VIP 时请填写有效到期日"})
|
||
return
|
||
}
|
||
if _, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(*body.VipExpireDate), time.Local); err != nil {
|
||
if _, err2 := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(*body.VipExpireDate), time.Local); err2 != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "到期日格式无效,请使用 YYYY-MM-DD"})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
updates := map[string]interface{}{}
|
||
if body.Nickname != nil {
|
||
updates["nickname"] = *body.Nickname
|
||
}
|
||
if body.Phone != nil {
|
||
updates["phone"] = *body.Phone
|
||
}
|
||
if body.WechatID != nil {
|
||
updates["wechat_id"] = *body.WechatID
|
||
}
|
||
if body.Avatar != nil {
|
||
updates["avatar"] = avatarToPath(*body.Avatar)
|
||
}
|
||
if body.Tags != nil {
|
||
updates["ckb_tags"] = *body.Tags
|
||
}
|
||
if body.HasFullBook != nil {
|
||
updates["has_full_book"] = *body.HasFullBook
|
||
}
|
||
if body.IsAdmin != nil {
|
||
updates["is_admin"] = *body.IsAdmin
|
||
}
|
||
if body.Earnings != nil {
|
||
updates["earnings"] = *body.Earnings
|
||
}
|
||
if body.PendingEarnings != nil {
|
||
updates["pending_earnings"] = *body.PendingEarnings
|
||
}
|
||
if body.IsVip != nil {
|
||
updates["is_vip"] = *body.IsVip
|
||
if *body.IsVip {
|
||
now := time.Now()
|
||
updates["vip_activated_at"] = now // 手动设置时与付款一致:按时间排序,最新在前
|
||
} else {
|
||
updates["vip_activated_at"] = nil
|
||
}
|
||
}
|
||
if body.VipExpireDate != nil {
|
||
if *body.VipExpireDate == "" {
|
||
updates["vip_expire_date"] = nil
|
||
} else {
|
||
if t, err := time.ParseInLocation("2006-01-02", *body.VipExpireDate, time.Local); err == nil {
|
||
updates["vip_expire_date"] = t
|
||
} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", *body.VipExpireDate, time.Local); err == nil {
|
||
updates["vip_expire_date"] = t
|
||
}
|
||
}
|
||
}
|
||
if body.VipSort != nil {
|
||
updates["vip_sort"] = *body.VipSort
|
||
}
|
||
if body.VipRole != nil {
|
||
s := strings.TrimSpace(*body.VipRole)
|
||
if s == "" {
|
||
updates["vip_role"] = nil
|
||
} else {
|
||
updates["vip_role"] = s
|
||
}
|
||
}
|
||
if body.VipName != nil {
|
||
updates["vip_name"] = *body.VipName
|
||
}
|
||
if body.VipAvatar != nil {
|
||
updates["vip_avatar"] = avatarToPath(*body.VipAvatar)
|
||
}
|
||
if body.VipProject != nil {
|
||
updates["vip_project"] = *body.VipProject
|
||
}
|
||
if body.VipContact != nil {
|
||
updates["vip_contact"] = *body.VipContact
|
||
}
|
||
if body.VipBio != nil {
|
||
updates["vip_bio"] = *body.VipBio
|
||
}
|
||
if len(updates) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
|
||
return
|
||
}
|
||
// VIP 相关更新时记录日志(手动设置)
|
||
if body.IsVip != nil || body.VipExpireDate != nil || body.VipName != nil || body.VipAvatar != nil || body.VipProject != nil || body.VipContact != nil || body.VipBio != nil {
|
||
isVipStr := "-"
|
||
if body.IsVip != nil {
|
||
isVipStr = fmt.Sprintf("%v", *body.IsVip)
|
||
}
|
||
vipExpire := "-"
|
||
if body.VipExpireDate != nil {
|
||
vipExpire = *body.VipExpireDate
|
||
}
|
||
fmt.Printf("[VIP] 设置方式=手动设置, userId=%s, isVip=%s, vipExpireDate=%s\n", body.ID, isVipStr, vipExpire)
|
||
}
|
||
if err := db.Model(&model.User{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "用户更新成功"})
|
||
}
|
||
|
||
func randomSuffix() string {
|
||
return fmt.Sprintf("%d%x", time.Now().UnixNano()%100000, time.Now().UnixNano()&0xfff)
|
||
}
|
||
|
||
// DBUsersDelete DELETE /api/db/users(软删除:仅设置 deleted_at,用户再次登录会新建账号)
|
||
func DBUsersDelete(c *gin.Context) {
|
||
id := c.Query("id")
|
||
if id == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
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
|
||
}
|
||
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(绑定关系详情弹窗;收益与「已付费」与小程序口径一致:订单+提现表实时计算)
|
||
func DBUsersReferrals(c *gin.Context) {
|
||
userId := c.Query("userId")
|
||
if userId == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
|
||
// 入站来源链路:即使未完成绑定,也保留“通过谁的分享链接点击进入”的历史
|
||
var currentUser model.User
|
||
_ = db.Select("id,open_id").Where("id = ?", userId).First(¤tUser).Error
|
||
var inboundVisits []model.ReferralVisit
|
||
visitQ := db.Model(&model.ReferralVisit{}).Where("visitor_id = ?", userId)
|
||
if currentUser.OpenID != nil && strings.TrimSpace(*currentUser.OpenID) != "" {
|
||
visitQ = visitQ.Or("visitor_openid = ?", strings.TrimSpace(*currentUser.OpenID))
|
||
}
|
||
_ = visitQ.Order("created_at ASC").Limit(300).Find(&inboundVisits).Error
|
||
|
||
referrerVisitIDs := make(map[string]bool)
|
||
for _, v := range inboundVisits {
|
||
if strings.TrimSpace(v.ReferrerID) != "" {
|
||
referrerVisitIDs[strings.TrimSpace(v.ReferrerID)] = true
|
||
}
|
||
}
|
||
referrerVisitList := make([]string, 0, len(referrerVisitIDs))
|
||
for id := range referrerVisitIDs {
|
||
referrerVisitList = append(referrerVisitList, id)
|
||
}
|
||
referrerVisitUserMap := make(map[string]*model.User)
|
||
if len(referrerVisitList) > 0 {
|
||
var rs []model.User
|
||
_ = db.Where("id IN ?", referrerVisitList).Find(&rs).Error
|
||
for i := range rs {
|
||
referrerVisitUserMap[rs[i].ID] = &rs[i]
|
||
}
|
||
}
|
||
|
||
inboundVisitItems := make([]gin.H, 0, len(inboundVisits))
|
||
firstInbound := gin.H{}
|
||
latestInbound := gin.H{}
|
||
for i, v := range inboundVisits {
|
||
nickname := "微信用户"
|
||
avatar := ""
|
||
if u := referrerVisitUserMap[v.ReferrerID]; u != nil {
|
||
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
|
||
nickname = strings.TrimSpace(*u.Nickname)
|
||
}
|
||
if u.Avatar != nil {
|
||
avatar = resolveAvatarURL(strings.TrimSpace(*u.Avatar))
|
||
}
|
||
}
|
||
source := ""
|
||
page := ""
|
||
if v.Source != nil {
|
||
source = strings.TrimSpace(*v.Source)
|
||
}
|
||
if v.Page != nil {
|
||
page = strings.TrimSpace(*v.Page)
|
||
}
|
||
item := gin.H{
|
||
"seq": i + 1,
|
||
"visitedAt": v.CreatedAt,
|
||
"referrerId": v.ReferrerID,
|
||
"referrerNickname": nickname,
|
||
"referrerAvatar": avatar,
|
||
"source": source,
|
||
"page": page,
|
||
}
|
||
if i == 0 {
|
||
firstInbound = item
|
||
}
|
||
latestInbound = item
|
||
inboundVisitItems = append(inboundVisitItems, item)
|
||
}
|
||
|
||
activeBinding := gin.H{}
|
||
var activeRef model.ReferralBinding
|
||
if err := db.Where("referee_id = ? AND status = ?", userId, "active").Order("binding_date DESC").First(&activeRef).Error; err == nil {
|
||
bindNick := "微信用户"
|
||
bindAvatar := ""
|
||
if u := referrerVisitUserMap[activeRef.ReferrerID]; u != nil {
|
||
if u.Nickname != nil && strings.TrimSpace(*u.Nickname) != "" {
|
||
bindNick = strings.TrimSpace(*u.Nickname)
|
||
}
|
||
if u.Avatar != nil {
|
||
bindAvatar = resolveAvatarURL(strings.TrimSpace(*u.Avatar))
|
||
}
|
||
} else {
|
||
var ru model.User
|
||
if err := db.Select("id,nickname,avatar").Where("id = ?", activeRef.ReferrerID).First(&ru).Error; err == nil {
|
||
if ru.Nickname != nil && strings.TrimSpace(*ru.Nickname) != "" {
|
||
bindNick = strings.TrimSpace(*ru.Nickname)
|
||
}
|
||
if ru.Avatar != nil {
|
||
bindAvatar = resolveAvatarURL(strings.TrimSpace(*ru.Avatar))
|
||
}
|
||
}
|
||
}
|
||
activeBinding = gin.H{
|
||
"referrerId": activeRef.ReferrerID,
|
||
"referrerNickname": bindNick,
|
||
"referrerAvatar": bindAvatar,
|
||
"referralCode": activeRef.ReferralCode,
|
||
"bindingDate": activeRef.BindingDate,
|
||
"expiryDate": activeRef.ExpiryDate,
|
||
}
|
||
}
|
||
|
||
var bindings []model.ReferralBinding
|
||
if err := db.Where("referrer_id = ?", userId).Order("binding_date DESC").Find(&bindings).Error; err != nil {
|
||
bindings = []model.ReferralBinding{}
|
||
}
|
||
refereeIdSet := make(map[string]bool, len(bindings))
|
||
for _, b := range bindings {
|
||
refereeIdSet[b.RefereeID] = true
|
||
}
|
||
|
||
// 补充:从 orders.referrer_id 找到通过推荐码购买但未在 referral_bindings 中的用户
|
||
var orderBuyerIDs []string
|
||
db.Model(&model.Order{}).Select("DISTINCT user_id").
|
||
Where("referrer_id = ? AND status = ? AND user_id != ''", userId, "paid").
|
||
Pluck("user_id", &orderBuyerIDs)
|
||
for _, buyerID := range orderBuyerIDs {
|
||
if !refereeIdSet[buyerID] {
|
||
refereeIdSet[buyerID] = true
|
||
}
|
||
}
|
||
|
||
allRefereeIds := make([]string, 0, len(refereeIdSet))
|
||
for id := range refereeIdSet {
|
||
allRefereeIds = append(allRefereeIds, id)
|
||
}
|
||
var users []model.User
|
||
if len(allRefereeIds) > 0 {
|
||
db.Where("id IN ?", allRefereeIds).Find(&users)
|
||
}
|
||
userMap := make(map[string]*model.User)
|
||
for i := range users {
|
||
userMap[users[i].ID] = &users[i]
|
||
}
|
||
referrals := make([]gin.H, 0, len(bindings)+len(orderBuyerIDs))
|
||
bindingRefereeSet := make(map[string]bool, len(bindings))
|
||
for _, b := range bindings {
|
||
bindingRefereeSet[b.RefereeID] = true
|
||
u := userMap[b.RefereeID]
|
||
nick := "微信用户"
|
||
var avatar *string
|
||
var phone *string
|
||
hasFullBook := false
|
||
if u != nil {
|
||
if u.Nickname != nil {
|
||
nick = *u.Nickname
|
||
}
|
||
avatar, phone = u.Avatar, u.Phone
|
||
if u.HasFullBook != nil {
|
||
hasFullBook = *u.HasFullBook
|
||
}
|
||
}
|
||
status := "active"
|
||
if b.Status != nil {
|
||
status = *b.Status
|
||
}
|
||
daysRemaining := 0
|
||
if b.ExpiryDate.After(time.Now()) {
|
||
daysRemaining = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
|
||
}
|
||
hasPaid := b.PurchaseCount != nil && *b.PurchaseCount > 0
|
||
displayStatus := bindingStatusDisplay(hasPaid, hasFullBook)
|
||
avStr := ""
|
||
if avatar != nil {
|
||
avStr = resolveAvatarURL(*avatar)
|
||
}
|
||
referrals = append(referrals, gin.H{
|
||
"id": b.RefereeID, "nickname": nick, "avatar": avStr, "phone": phone,
|
||
"hasFullBook": hasFullBook || status == "converted",
|
||
"purchasedSections": getBindingPurchaseCount(b),
|
||
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
|
||
"status": displayStatus,
|
||
})
|
||
}
|
||
for _, buyerID := range orderBuyerIDs {
|
||
if bindingRefereeSet[buyerID] {
|
||
continue
|
||
}
|
||
u := userMap[buyerID]
|
||
nick := "微信用户"
|
||
avStr := ""
|
||
var phone *string
|
||
hasFullBook := false
|
||
if u != nil {
|
||
if u.Nickname != nil {
|
||
nick = *u.Nickname
|
||
}
|
||
if u.Avatar != nil {
|
||
avStr = resolveAvatarURL(*u.Avatar)
|
||
}
|
||
phone = u.Phone
|
||
if u.HasFullBook != nil {
|
||
hasFullBook = *u.HasFullBook
|
||
}
|
||
}
|
||
referrals = append(referrals, gin.H{
|
||
"id": buyerID, "nickname": nick, "avatar": avStr, "phone": phone,
|
||
"hasFullBook": hasFullBook, "purchasedSections": 0,
|
||
"createdAt": nil, "bindingStatus": "order_only", "daysRemaining": 0, "commission": nil,
|
||
"status": "paid",
|
||
})
|
||
}
|
||
|
||
// 累计收益、待提现:与小程序 MyEarnings 一致,从订单逐条 computeOrderCommission 求和
|
||
var refOrders []model.Order
|
||
db.Where("referrer_id = ? AND status = ?", userId, "paid").Find(&refOrders)
|
||
earningsE := 0.0
|
||
for i := range refOrders {
|
||
earningsE += computeOrderCommission(db, &refOrders[i], nil)
|
||
}
|
||
|
||
var withdrawnSum struct{ Total float64 }
|
||
db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total").
|
||
Where("user_id = ? AND status = ?", userId, "success").
|
||
Scan(&withdrawnSum)
|
||
withdrawnE := withdrawnSum.Total
|
||
|
||
var pendingWdSum struct{ Total float64 }
|
||
db.Model(&model.Withdrawal{}).Select("COALESCE(SUM(amount), 0) as total").
|
||
Where("user_id = ? AND status IN ?", userId, []string{"pending", "processing", "pending_confirm"}).
|
||
Scan(&pendingWdSum)
|
||
availableE := earningsE - withdrawnE - pendingWdSum.Total
|
||
if availableE < 0 {
|
||
availableE = 0
|
||
}
|
||
|
||
// 已付费人数:绑定中 purchase_count > 0 的 + 仅订单用户(order_only 都已付费)
|
||
purchased := 0
|
||
for _, b := range bindings {
|
||
if b.PurchaseCount != nil && *b.PurchaseCount > 0 {
|
||
purchased++
|
||
}
|
||
}
|
||
orderOnlyCount := 0
|
||
for _, buyerID := range orderBuyerIDs {
|
||
if !bindingRefereeSet[buyerID] {
|
||
orderOnlyCount++
|
||
purchased++
|
||
}
|
||
}
|
||
totalReferrals := len(bindings) + orderOnlyCount
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true, "referrals": referrals,
|
||
"inboundSource": gin.H{
|
||
"totalVisits": len(inboundVisitItems),
|
||
"firstVisit": firstInbound,
|
||
"latestVisit": latestInbound,
|
||
"activeBinding": activeBinding,
|
||
"visits": inboundVisitItems,
|
||
},
|
||
"stats": gin.H{
|
||
"total": totalReferrals, "purchased": purchased, "free": totalReferrals - purchased,
|
||
"earnings": roundFloat(earningsE, 2), "pendingEarnings": roundFloat(availableE, 2), "withdrawnEarnings": roundFloat(withdrawnE, 2),
|
||
},
|
||
})
|
||
}
|
||
|
||
func getBindingPurchaseCount(b model.ReferralBinding) int {
|
||
if b.PurchaseCount == nil {
|
||
return 0
|
||
}
|
||
return *b.PurchaseCount
|
||
}
|
||
|
||
func bindingStatusDisplay(hasPaid bool, hasFullBook bool) string {
|
||
if hasFullBook {
|
||
return "vip"
|
||
}
|
||
if hasPaid {
|
||
return "paid"
|
||
}
|
||
return "free"
|
||
}
|
||
|
||
func roundFloat(v float64, prec int) float64 {
|
||
ratio := 1.0
|
||
for i := 0; i < prec; i++ {
|
||
ratio *= 10
|
||
}
|
||
return float64(int(v*ratio+0.5)) / ratio
|
||
}
|
||
|
||
// DBInit POST /api/db/init
|
||
func DBInit(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "初始化接口已就绪(表结构由迁移维护)"}})
|
||
}
|
||
|
||
// DBDistribution GET /api/db/distribution(支持分页 page、pageSize,筛选 status、search)
|
||
func DBDistribution(c *gin.Context) {
|
||
userId := c.Query("userId")
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||
statusFilter := c.Query("status")
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 100 {
|
||
pageSize = 10
|
||
}
|
||
|
||
db := database.DB()
|
||
q := db.Model(&model.ReferralBinding{})
|
||
if userId != "" {
|
||
q = q.Where("referrer_id = ?", userId)
|
||
}
|
||
if statusFilter != "" && statusFilter != "all" {
|
||
q = q.Where("status = ?", statusFilter)
|
||
}
|
||
var total int64
|
||
q.Count(&total)
|
||
|
||
var bindings []model.ReferralBinding
|
||
query := db.Model(&model.ReferralBinding{}).Order("binding_date DESC")
|
||
if userId != "" {
|
||
query = query.Where("referrer_id = ?", userId)
|
||
}
|
||
if statusFilter != "" && statusFilter != "all" {
|
||
query = query.Where("status = ?", statusFilter)
|
||
}
|
||
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
|
||
}
|
||
referrerIds := make(map[string]bool)
|
||
refereeIds := make(map[string]bool)
|
||
for _, b := range bindings {
|
||
referrerIds[b.ReferrerID] = true
|
||
refereeIds[b.RefereeID] = true
|
||
}
|
||
allIds := make([]string, 0, len(referrerIds)+len(refereeIds))
|
||
for id := range referrerIds {
|
||
allIds = append(allIds, id)
|
||
}
|
||
for id := range refereeIds {
|
||
if !referrerIds[id] {
|
||
allIds = append(allIds, id)
|
||
}
|
||
}
|
||
var users []model.User
|
||
if len(allIds) > 0 {
|
||
db.Where("id IN ?", allIds).Find(&users)
|
||
}
|
||
userMap := make(map[string]*model.User)
|
||
for i := range users {
|
||
userMap[users[i].ID] = &users[i]
|
||
}
|
||
getStr := func(s *string) string {
|
||
if s == nil || *s == "" {
|
||
return ""
|
||
}
|
||
return *s
|
||
}
|
||
out := make([]gin.H, 0, len(bindings))
|
||
for _, b := range bindings {
|
||
refNick := "微信用户"
|
||
var refereePhone, refereeAvatar *string
|
||
if u := userMap[b.RefereeID]; u != nil {
|
||
if u.Nickname != nil && *u.Nickname != "" {
|
||
refNick = *u.Nickname
|
||
} else {
|
||
refNick = "微信用户"
|
||
}
|
||
refereePhone = u.Phone
|
||
refereeAvatar = u.Avatar
|
||
}
|
||
var referrerName, referrerAvatar *string
|
||
if u := userMap[b.ReferrerID]; u != nil {
|
||
referrerName = u.Nickname
|
||
referrerAvatar = u.Avatar
|
||
}
|
||
days := 0
|
||
if b.ExpiryDate.After(time.Now()) {
|
||
days = int(b.ExpiryDate.Sub(time.Now()).Hours() / 24)
|
||
}
|
||
// 佣金展示用累计佣金 total_commission(支付回调累加),无则用 commission_amount
|
||
commissionVal := b.TotalCommission
|
||
if commissionVal == nil {
|
||
commissionVal = b.CommissionAmount
|
||
}
|
||
statusVal := ""
|
||
if b.Status != nil {
|
||
statusVal = *b.Status
|
||
}
|
||
out = append(out, gin.H{
|
||
"id": b.ID, "referrerId": b.ReferrerID, "referrerName": getStr(referrerName), "referrerCode": b.ReferralCode, "referrerAvatar": resolveAvatarURL(getStr(referrerAvatar)),
|
||
"refereeId": b.RefereeID, "refereeNickname": refNick, "refereePhone": getStr(refereePhone), "refereeAvatar": resolveAvatarURL(getStr(refereeAvatar)),
|
||
"boundAt": b.BindingDate, "expiresAt": b.ExpiryDate, "status": statusVal,
|
||
"daysRemaining": days, "commission": commissionVal, "totalCommission": commissionVal, "source": "miniprogram",
|
||
})
|
||
}
|
||
totalPages := int(total) / pageSize
|
||
if int(total)%pageSize > 0 {
|
||
totalPages++
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true, "bindings": out,
|
||
"total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
|
||
})
|
||
}
|
||
|
||
// DBChapters GET/POST /api/db/chapters
|
||
func DBChapters(c *gin.Context) {
|
||
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": false, "error": err.Error(), "data": []interface{}{}})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||
}
|
||
|
||
// DBConfigDelete DELETE /api/db/config
|
||
func DBConfigDelete(c *gin.Context) {
|
||
key := c.Query("key")
|
||
if key == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置键不能为空"})
|
||
return
|
||
}
|
||
if err := database.DB().Where("config_key = ?", key).Delete(&model.SystemConfig{}).Error; err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||
}
|
||
|
||
// DBInitGet GET /api/db/init
|
||
func DBInitGet(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "ok"}})
|
||
}
|
||
|
||
// DBMigrateGet GET /api/db/migrate
|
||
func DBMigrateGet(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移状态查询(由 Prisma/外部维护)"})
|
||
}
|
||
|
||
// DBMigratePost POST /api/db/migrate
|
||
func DBMigratePost(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "迁移由 Prisma/外部执行"})
|
||
}
|