2026-03-07 22:58:43 +08:00
package handler
import (
2026-03-17 14:02:09 +08:00
"context"
2026-03-07 22:58:43 +08:00
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
2026-03-17 14:02:09 +08:00
"soul-api/internal/cache"
2026-03-07 22:58:43 +08:00
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
2026-03-24 01:22:50 +08:00
// 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
}
}
2026-03-24 12:00:04 +08:00
// isLikelyWxMiniProgramAppID 判断是否为微信小程序 AppID 常见形态: wx + 16 位十六进制(共 18 字符)。
// 后台「链接标签」若 type=miniprogram 且在此列直接填真实 AppID, C 端会把该值当作 mpKey 去 linkedMiniprograms 里匹配 key;
// 若未单独配置 linked_miniprograms, 会提示「未找到关联小程序配置」。mergeDirectMiniProgramLinksFromLinkTags 会据此自动补全映射。
func isLikelyWxMiniProgramAppID ( s string ) bool {
s = strings . TrimSpace ( s )
if len ( s ) != 18 || ! strings . HasPrefix ( s , "wx" ) {
return false
}
for i := 2 ; i < len ( s ) ; i ++ {
c := s [ i ]
if ( c >= '0' && c <= '9' ) || ( c >= 'a' && c <= 'f' ) || ( c >= 'A' && c <= 'F' ) {
continue
}
return false
}
return true
}
func linkedMiniprogramItemKey ( item gin . H ) string {
if v , ok := item [ "key" ] ; ok && v != nil {
if s , ok := v . ( string ) ; ok {
return strings . TrimSpace ( s )
}
}
return ""
}
func linkedMiniprogramItemAppIDEmpty ( item gin . H ) bool {
v , ok := item [ "appId" ]
if ! ok || v == nil {
return true
}
s , ok := v . ( string )
return ! ok || strings . TrimSpace ( s ) == ""
}
func linkedMiniprogramItemPathEmpty ( item gin . H ) bool {
v , ok := item [ "path" ]
if ! ok || v == nil {
return true
}
s , ok := v . ( string )
return ! ok || strings . TrimSpace ( s ) == ""
}
// mergeDirectMiniProgramLinksFromLinkTags 将「直接填写微信 AppID」的链接标签并入 linkedMiniprograms, 兼容现有小程序 navigateToMiniProgram 查表逻辑(不改 C 端)。
func mergeDirectMiniProgramLinksFromLinkTags ( linked * [ ] gin . H , tags [ ] model . LinkTag ) {
if linked == nil {
return
}
byKey := make ( map [ string ] int )
for i := range * linked {
k := linkedMiniprogramItemKey ( ( * linked ) [ i ] )
if k != "" {
byKey [ k ] = i
}
}
for _ , t := range tags {
if strings . TrimSpace ( strings . ToLower ( t . Type ) ) != "miniprogram" {
continue
}
app := strings . TrimSpace ( t . AppID )
if app == "" || ! isLikelyWxMiniProgramAppID ( app ) {
continue
}
path := strings . TrimSpace ( t . PagePath )
if idx , ok := byKey [ app ] ; ok {
item := ( * linked ) [ idx ]
if linkedMiniprogramItemAppIDEmpty ( item ) {
item [ "appId" ] = app
}
if path != "" && linkedMiniprogramItemPathEmpty ( item ) {
item [ "path" ] = path
}
( * linked ) [ idx ] = item
continue
}
entry := gin . H { "key" : app , "appId" : app }
if path != "" {
entry [ "path" ] = path
}
* linked = append ( * linked , entry )
byKey [ app ] = len ( * linked ) - 1
}
}
2026-03-23 18:38:23 +08:00
// 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" : "来自派对房的真实故事" ,
2026-03-24 01:22:50 +08:00
"linkKaruoText" : "点击链接卡若" , "linkKaruoAvatar" : "" ,
"searchPlaceholder" : "搜索章节标题或内容..." ,
2026-03-23 18:38:23 +08:00
"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
}
2026-03-18 12:56:34 +08:00
// buildMiniprogramConfig 从 DB 构建小程序配置,供 GetPublicDBConfig 与 WarmConfigCache 复用
func buildMiniprogramConfig ( ) gin . H {
2026-03-07 22:58:43 +08:00
defaultPrices := gin . H { "section" : float64 ( 1 ) , "fullbook" : 9.9 }
2026-03-17 13:17:49 +08:00
defaultFeatures := gin . H { "matchEnabled" : true , "referralEnabled" : true , "searchEnabled" : true }
2026-03-07 22:58:43 +08:00
apiDomain := "https://soulapi.quwanzhi.com"
if cfg := config . Get ( ) ; cfg != nil && cfg . BaseURL != "" {
apiDomain = cfg . BaseURL
}
defaultMp := gin . H {
2026-03-18 16:00:57 +08:00
"appId" : "wxb8bbb2b10dec74aa" ,
"apiDomain" : apiDomain ,
"buyerDiscount" : 5 ,
"referralBindDays" : 30 ,
"minWithdraw" : 10 ,
2026-03-07 22:58:43 +08:00
"withdrawSubscribeTmplId" : "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE" ,
2026-03-18 16:00:57 +08:00
"mchId" : "1318592501" ,
"auditMode" : false ,
"supportWechat" : true ,
2026-03-22 08:34:28 +08:00
"shareIcon" : "" , // 分享图标URL, 由管理端配置
2026-03-23 18:38:23 +08:00
"mpUi" : defaultMpUi ( ) ,
2026-03-07 22:58:43 +08:00
}
out := gin . H {
2026-03-18 16:00:57 +08:00
"success" : true ,
2026-03-07 22:58:43 +08:00
"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
}
2026-03-23 18:38:23 +08:00
merged [ "mpUi" ] = deepMergeMpUi ( defaultMpUi ( ) , m [ "mpUi" ] )
2026-03-07 22:58:43 +08:00
out [ "mpConfig" ] = merged
out [ "configs" ] . ( gin . H ) [ "mp_config" ] = merged
}
}
}
2026-03-20 10:58:25 +08:00
// 价格: 以管理端「站点与作者」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
}
}
}
2026-03-07 22:58:43 +08:00
// 好友优惠(用于 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 )
}
2026-03-24 12:00:04 +08:00
// 链接标签列表(小程序 onLinkTagTap: miniprogram 类型下发 mpKey=C 端用其匹配 linkedMiniprograms[].key; 历史设计为「密钥→appId」, 现支持 app_id 列直接填微信 AppID 并由下方 merge 自动补 linkedMiniprograms)
2026-03-10 18:06:10 +08:00
var linkTagRows [ ] model . LinkTag
2026-03-24 12:00:04 +08:00
_ = db . Order ( "label ASC" ) . Find ( & linkTagRows ) . Error
tags := make ( [ ] gin . H , 0 , len ( linkTagRows ) )
for _ , t := range linkTagRows {
2026-03-24 12:29:46 +08:00
cType := t . Type
cURL := t . URL
// wxlink( 小程序短链) : 下发给 C 端时转为 url 类型,现有 read.js web-view 可直接跳转,无需升级小程序
if strings . EqualFold ( cType , "wxlink" ) {
cType = "url"
if cURL == "" {
cURL = t . AppID
}
}
h := gin . H { "tagId" : t . TagID , "label" : t . Label , "url" : cURL , "type" : cType , "pagePath" : t . PagePath }
2026-03-24 12:00:04 +08:00
if t . Type == "miniprogram" {
2026-03-24 12:29:46 +08:00
h [ "mpKey" ] = t . AppID
2026-03-24 12:00:04 +08:00
} else {
h [ "appId" ] = t . AppID
2026-03-10 18:06:10 +08:00
}
2026-03-24 12:00:04 +08:00
tags = append ( tags , h )
2026-03-10 18:06:10 +08:00
}
2026-03-24 12:00:04 +08:00
out [ "linkTags" ] = tags
// 关联小程序列表( 小程序: find(m => m.key === mpKey) → navigateToMiniProgram)
var linkedList [ ] gin . H
2026-03-12 16:51:12 +08:00
var linkedMpRow model . SystemConfig
if err := db . Where ( "config_key = ?" , "linked_miniprograms" ) . First ( & linkedMpRow ) . Error ; err == nil && len ( linkedMpRow . ConfigValue ) > 0 {
2026-03-24 12:00:04 +08:00
if err := json . Unmarshal ( linkedMpRow . ConfigValue , & linkedList ) ; err != nil {
linkedList = nil
2026-03-12 16:51:12 +08:00
}
}
2026-03-24 12:00:04 +08:00
if linkedList == nil {
linkedList = [ ] gin . H { }
}
mergeDirectMiniProgramLinksFromLinkTags ( & linkedList , linkTagRows )
out [ "linkedMiniprograms" ] = linkedList
2026-03-24 01:22:50 +08:00
// 归一化 auditMode( 兼容历史 bool / 字符串 / 数字)
2026-03-17 18:22:06 +08:00
if mp , ok := out [ "mpConfig" ] . ( gin . H ) ; ok {
2026-03-24 01:22:50 +08:00
mp [ "auditMode" ] = parseConfigBool ( mp [ "auditMode" ] )
2026-03-17 18:22:06 +08:00
}
2026-03-18 12:56:34 +08:00
return out
}
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
// Redis 缓存 10min, 配置变更时失效
2026-03-18 16:00:57 +08:00
//
// Deprecated: 计划迁移至 /config/core + /config/audit-mode + /config/read-extras, 保留以兼容线上小程序
2026-03-18 12:56:34 +08:00
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 ( )
2026-03-17 14:02:09 +08:00
cache . Set ( context . Background ( ) , cache . KeyConfigMiniprogram , out , cache . ConfigTTL )
2026-03-07 22:58:43 +08:00
c . JSON ( http . StatusOK , out )
}
2026-03-18 16:00:57 +08:00
// GetAuditMode GET /api/miniprogram/config/audit-mode 审核模式独立接口,管理端开关后快速生效
2026-03-19 18:26:45 +08:00
// 缓存未命中时仅查 mp_config 一条记录,避免 buildMiniprogramConfig 全量查询导致超时
2026-03-20 10:58:25 +08:00
// Redis 不可用时 cache 包自动降级到内存备用
2026-03-18 16:00:57 +08:00
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
}
2026-03-19 18:26:45 +08:00
auditMode := getAuditModeFromDB ( )
2026-03-18 16:00:57 +08:00
out := gin . H { "auditMode" : auditMode }
cache . Set ( context . Background ( ) , cache . KeyConfigAuditMode , out , cache . AuditModeTTL )
c . JSON ( http . StatusOK , out )
}
2026-03-19 18:26:45 +08:00
// 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
}
2026-03-24 01:22:50 +08:00
return parseConfigBool ( mp [ "auditMode" ] )
2026-03-19 18:26:45 +08:00
}
2026-03-18 16:00:57 +08:00
// 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 )
}
2026-03-22 08:34:28 +08:00
// 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) , 懒加载
2026-03-18 16:00:57 +08:00
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" ] ,
2026-03-22 08:34:28 +08:00
"mentionPersons" : buildMentionPersonsForRead ( ) ,
2026-03-18 16:00:57 +08:00
}
if out [ "linkTags" ] == nil {
out [ "linkTags" ] = [ ] gin . H { }
}
if out [ "linkedMiniprograms" ] == nil {
out [ "linkedMiniprograms" ] = [ ] gin . H { }
}
2026-03-22 08:34:28 +08:00
if out [ "mentionPersons" ] == nil {
out [ "mentionPersons" ] = [ ] gin . H { }
}
2026-03-18 16:00:57 +08:00
cache . Set ( context . Background ( ) , cache . KeyConfigReadExtras , out , cache . ConfigTTL )
c . JSON ( http . StatusOK , out )
}
// WarmConfigCache 启动时预热 config 及拆分接口缓存,避免首请求冷启动
2026-03-18 12:56:34 +08:00
func WarmConfigCache ( ) {
out := buildMiniprogramConfig ( )
cache . Set ( context . Background ( ) , cache . KeyConfigMiniprogram , out , cache . ConfigTTL )
2026-03-18 16:00:57 +08:00
// 拆分接口预热
auditMode := false
if mp , ok := out [ "mpConfig" ] . ( gin . H ) ; ok {
2026-03-24 01:22:50 +08:00
auditMode = parseConfigBool ( mp [ "auditMode" ] )
2026-03-18 16:00:57 +08:00
}
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" ] ,
2026-03-22 08:34:28 +08:00
"mentionPersons" : buildMentionPersonsForRead ( ) ,
2026-03-18 16:00:57 +08:00
}
if readExtras [ "linkTags" ] == nil {
readExtras [ "linkTags" ] = [ ] gin . H { }
}
if readExtras [ "linkedMiniprograms" ] == nil {
readExtras [ "linkedMiniprograms" ] = [ ] gin . H { }
}
2026-03-22 08:34:28 +08:00
if readExtras [ "mentionPersons" ] == nil {
readExtras [ "mentionPersons" ] = [ ] gin . H { }
}
2026-03-18 16:00:57 +08:00
cache . Set ( context . Background ( ) , cache . KeyConfigReadExtras , readExtras , cache . ConfigTTL )
2026-03-18 12:56:34 +08:00
}
2026-03-07 22:58:43 +08:00
// 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 {
2026-03-18 16:00:57 +08:00
"appId" : "wxb8bbb2b10dec74aa" ,
"apiDomain" : apiDomain ,
2026-03-07 22:58:43 +08:00
"withdrawSubscribeTmplId" : "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE" ,
2026-03-18 16:00:57 +08:00
"mchId" : "1318592501" ,
"minWithdraw" : float64 ( 10 ) ,
"auditMode" : false ,
"supportWechat" : true ,
2026-03-23 18:38:23 +08:00
"mpUi" : defaultMpUi ( ) ,
2026-03-07 22:58:43 +08:00
}
out := gin . H {
"success" : true ,
2026-03-17 18:22:06 +08:00
"featureConfig" : gin . H { "matchEnabled" : true , "referralEnabled" : true , "searchEnabled" : true , "aboutEnabled" : true } ,
2026-03-07 22:58:43 +08:00
"siteSettings" : gin . H { "sectionPrice" : float64 ( 1 ) , "baseBookPrice" : 9.9 , "distributorShare" : float64 ( 90 ) , "authorInfo" : gin . H { } } ,
"mpConfig" : defaultMp ,
2026-03-17 13:17:49 +08:00
"ossConfig" : gin . H { } ,
2026-03-07 22:58:43 +08:00
}
2026-03-17 13:17:49 +08:00
keys := [ ] string { "feature_config" , "site_settings" , "mp_config" , "oss_config" }
2026-03-07 22:58:43 +08:00
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
}
2026-03-23 18:38:23 +08:00
merged [ "mpUi" ] = deepMergeMpUi ( defaultMpUi ( ) , m [ "mpUi" ] )
2026-03-07 22:58:43 +08:00
out [ "mpConfig" ] = merged
}
2026-03-17 13:17:49 +08:00
case "oss_config" :
if m , ok := val . ( map [ string ] interface { } ) ; ok {
2026-03-22 08:34:28 +08:00
safe := make ( map [ string ] interface { } )
for k , v := range m {
if k == "accessKeySecret" {
safe [ k ] = "****"
} else {
safe [ k ] = v
}
}
out [ "ossConfig" ] = safe
2026-03-17 13:17:49 +08:00
}
2026-03-07 22:58:43 +08:00
}
}
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" `
2026-03-17 13:17:49 +08:00
OssConfig map [ string ] interface { } ` json:"ossConfig" `
2026-03-07 22:58:43 +08:00
}
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 ( ) } )
2026-03-17 13:17:49 +08:00
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 ( ) } )
2026-03-07 22:58:43 +08:00
return
}
}
2026-03-17 14:02:09 +08:00
cache . InvalidateConfig ( )
2026-03-07 22:58:43 +08:00
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 ) ,
2026-03-18 16:00:57 +08:00
"minWithdrawAmount" : float64 ( 10 ) ,
"bindingDays" : float64 ( 30 ) ,
"userDiscount" : float64 ( 5 ) ,
"withdrawFee" : float64 ( 5 ) ,
"enableAutoWithdraw" : false ,
"vipOrderShareVip" : float64 ( 20 ) ,
2026-03-07 22:58:43 +08:00
"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 ,
2026-03-18 16:00:57 +08:00
"bindingDays" : body . BindingDays ,
"userDiscount" : body . UserDiscount ,
"withdrawFee" : body . WithdrawFee ,
"enableAutoWithdraw" : body . EnableAutoWithdraw ,
"vipOrderShareVip" : vipOrderShareVip ,
2026-03-07 22:58:43 +08:00
"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
}
2026-03-17 14:02:09 +08:00
cache . InvalidateConfig ( )
2026-03-07 22:58:43 +08:00
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 {
2026-03-18 16:00:57 +08:00
Name : name ,
Avatar : avatar ,
AvatarImg : str ( "avatarImg" ) ,
Title : str ( "title" ) ,
Bio : str ( "bio" ) ,
Stats : string ( statsBytes ) ,
2026-03-07 22:58:43 +08:00
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
}
2026-03-17 14:02:09 +08:00
cache . InvalidateConfig ( )
2026-03-07 22:58:43 +08:00
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" , "" ) )
2026-03-18 16:00:57 +08:00
vipFilter := c . Query ( "vip" ) // "true" 时仅返回 VIP( hasFullBook)
2026-03-08 11:05:40 +08:00
poolFilter := c . Query ( "pool" ) // "complete" 时仅返回已完善资料的用户
2026-03-07 22:58:43 +08:00
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
}
2026-03-14 17:13:06 +08:00
// 填充 hasFullBook( 含 orders、is_vip、手动设置的 has_full_book)
2026-03-07 22:58:43 +08:00
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 )
2026-03-14 17:13:06 +08:00
hasFull := cnt > 0 || ( user . IsVip != nil && * user . IsVip ) || ( user . HasFullBook != nil && * user . HasFullBook )
user . HasFullBook = ptrBool ( hasFull )
2026-03-07 22:58:43 +08:00
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 )
}
2026-03-08 11:05:47 +08:00
if poolFilter == "complete" {
2026-03-08 11:34:18 +08:00
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 != '')" )
2026-03-08 11:05:47 +08:00
} else if vipFilter == "true" || vipFilter == "1" {
2026-03-07 22:58:43 +08:00
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 )
}
2026-03-24 01:22:50 +08:00
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" {
2026-03-07 22:58:43 +08:00
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
}
2026-03-24 01:22:50 +08:00
db . Model ( & model . Order { } ) . Select ( "user_id" ) . Where ( "product_type IN ? AND status IN ?" , [ ] string { "fullbook" , "vip" } , [ ] string { "paid" , "completed" , "success" } ) . Find ( & fullbookRows )
2026-03-07 22:58:43 +08:00
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" ) .
2026-03-24 01:22:50 +08:00
Where ( "product_type = ? AND status IN ?" , "section" , [ ] string { "paid" , "completed" , "success" } ) .
2026-03-07 22:58:43 +08:00
Group ( "user_id" ) . Find ( & sectionRows )
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
}
2026-03-22 08:34:28 +08:00
// 3. 绑定人数:综合 referral_bindings + orders.referrer_id + users.referral_count 三源取最大值
2026-03-07 22:58:43 +08:00
referralCountMap := make ( map [ string ] int )
var refCountRows [ ] struct {
2026-03-22 08:34:28 +08:00
ReferrerID string ` gorm:"column:referrer_id" `
Count int64 ` gorm:"column:count" `
2026-03-07 22:58:43 +08:00
}
db . Model ( & model . ReferralBinding { } ) . Select ( "referrer_id, COUNT(*) as count" ) .
2026-03-22 08:34:28 +08:00
Group ( "referrer_id" ) . Scan ( & refCountRows )
2026-03-07 22:58:43 +08:00
for _ , r := range refCountRows {
referralCountMap [ r . ReferrerID ] = int ( r . Count )
}
2026-03-22 08:34:28 +08:00
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 )
}
}
2026-03-07 22:58:43 +08:00
2026-03-24 01:22:50 +08:00
// 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
}
}
2026-03-07 22:58:43 +08:00
// 填充每个用户的实时计算字段
for i := range users {
uid := users [ i ] . ID
2026-03-14 17:13:06 +08:00
// 购买状态( 含订单、is_vip、手动设置的 has_full_book)
2026-03-07 22:58:43 +08:00
hasFull := hasFullBookMap [ uid ]
if users [ i ] . IsVip != nil && * users [ i ] . IsVip && users [ i ] . VipExpireDate != nil && users [ i ] . VipExpireDate . After ( time . Now ( ) ) {
hasFull = true
}
2026-03-14 17:13:06 +08:00
if users [ i ] . HasFullBook != nil && * users [ i ] . HasFullBook {
hasFull = true
}
2026-03-07 22:58:43 +08:00
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 )
2026-03-22 08:34:28 +08:00
bindCount := referralCountMap [ uid ]
dbCount := 0
if users [ i ] . ReferralCount != nil {
dbCount = * users [ i ] . ReferralCount
}
if dbCount > bindCount {
bindCount = dbCount
}
users [ i ] . ReferralCount = ptrInt ( bindCount )
2026-03-24 01:22:50 +08:00
// 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
}
2026-03-07 22:58:43 +08:00
}
c . JSON ( http . StatusOK , gin . H {
"success" : true , "users" : users ,
"total" : total , "page" : page , "pageSize" : pageSize , "totalPages" : totalPages ,
} )
}
2026-03-18 16:00:57 +08:00
func ptrBool ( b bool ) * bool { return & b }
2026-03-07 22:58:43 +08:00
func ptrFloat64 ( f float64 ) * float64 { v := f ; return & v }
2026-03-18 16:00:57 +08:00
func ptrInt ( n int ) * int { return & n }
2026-03-07 22:58:43 +08:00
// 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 {
2026-03-18 16:00:57 +08:00
OpenID * string ` json:"openId" `
Phone * string ` json:"phone" `
Nickname * string ` json:"nickname" `
WechatID * string ` json:"wechatId" `
Avatar * string ` json:"avatar" `
IsAdmin * bool ` json:"isAdmin" `
2026-03-07 22:58:43 +08:00
}
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
}
2026-03-17 11:44:36 +08:00
// PUT 更新(含 VIP 手动设置: is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio; tags 存 ckb_tags)
2026-03-07 22:58:43 +08:00
var body struct {
2026-03-18 16:00:57 +08:00
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" `
2026-03-07 22:58:43 +08:00
}
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 {
2026-03-19 18:26:45 +08:00
updates [ "avatar" ] = avatarToPath ( * body . Avatar )
2026-03-07 22:58:43 +08:00
}
2026-03-17 11:44:36 +08:00
if body . Tags != nil {
updates [ "ckb_tags" ] = * body . Tags
}
2026-03-07 22:58:43 +08:00
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 {
2026-03-19 18:26:45 +08:00
updates [ "vip_avatar" ] = avatarToPath ( * body . VipAvatar )
2026-03-07 22:58:43 +08:00
}
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 )
}
2026-03-17 18:22:06 +08:00
// DBUsersDelete DELETE /api/db/users( 软删除: 仅设置 deleted_at, 用户再次登录会新建账号)
2026-03-07 22:58:43 +08:00
func DBUsersDelete ( c * gin . Context ) {
id := c . Query ( "id" )
if id == "" {
c . JSON ( http . StatusOK , gin . H { "success" : false , "error" : "用户ID不能为空" } )
return
}
2026-03-17 18:22:06 +08:00
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" : "用户不存在或已被删除" } )
2026-03-07 22:58:43 +08:00
return
}
2026-03-17 18:22:06 +08:00
c . JSON ( http . StatusOK , gin . H { "success" : true , "message" : "用户已删除(假删除),该用户再次登录将创建新账号" } )
2026-03-07 22:58:43 +08:00
}
// 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 ( )
2026-03-24 01:22:50 +08:00
// 入站来源链路:即使未完成绑定,也保留“通过谁的分享链接点击进入”的历史
var currentUser model . User
_ = db . Select ( "id,open_id" ) . Where ( "id = ?" , userId ) . First ( & currentUser ) . 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 ,
}
}
2026-03-07 22:58:43 +08:00
var bindings [ ] model . ReferralBinding
if err := db . Where ( "referrer_id = ?" , userId ) . Order ( "binding_date DESC" ) . Find ( & bindings ) . Error ; err != nil {
2026-03-22 08:34:28 +08:00
bindings = [ ] model . ReferralBinding { }
2026-03-07 22:58:43 +08:00
}
2026-03-22 08:34:28 +08:00
refereeIdSet := make ( map [ string ] bool , len ( bindings ) )
2026-03-07 22:58:43 +08:00
for _ , b := range bindings {
2026-03-22 08:34:28 +08:00
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 )
2026-03-07 22:58:43 +08:00
}
var users [ ] model . User
2026-03-22 08:34:28 +08:00
if len ( allRefereeIds ) > 0 {
db . Where ( "id IN ?" , allRefereeIds ) . Find ( & users )
2026-03-07 22:58:43 +08:00
}
userMap := make ( map [ string ] * model . User )
for i := range users {
userMap [ users [ i ] . ID ] = & users [ i ]
}
2026-03-22 08:34:28 +08:00
referrals := make ( [ ] gin . H , 0 , len ( bindings ) + len ( orderBuyerIDs ) )
bindingRefereeSet := make ( map [ string ] bool , len ( bindings ) )
2026-03-07 22:58:43 +08:00
for _ , b := range bindings {
2026-03-22 08:34:28 +08:00
bindingRefereeSet [ b . RefereeID ] = true
2026-03-07 22:58:43 +08:00
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
2026-03-22 08:34:28 +08:00
displayStatus := bindingStatusDisplay ( hasPaid , hasFullBook )
2026-03-19 18:26:45 +08:00
avStr := ""
if avatar != nil {
avStr = resolveAvatarURL ( * avatar )
}
2026-03-07 22:58:43 +08:00
referrals = append ( referrals , gin . H {
2026-03-19 18:26:45 +08:00
"id" : b . RefereeID , "nickname" : nick , "avatar" : avStr , "phone" : phone ,
2026-03-18 16:00:57 +08:00
"hasFullBook" : hasFullBook || status == "converted" ,
2026-03-07 22:58:43 +08:00
"purchasedSections" : getBindingPurchaseCount ( b ) ,
2026-03-18 16:00:57 +08:00
"createdAt" : b . BindingDate , "bindingStatus" : status , "daysRemaining" : daysRemaining , "commission" : b . TotalCommission ,
2026-03-07 22:58:43 +08:00
"status" : displayStatus ,
} )
}
2026-03-22 08:34:28 +08:00
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" ,
} )
}
2026-03-07 22:58:43 +08:00
// 累计收益、待提现:与小程序 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
}
2026-03-22 08:34:28 +08:00
// 已付费人数:绑定中 purchase_count > 0 的 + 仅订单用户( order_only 都已付费)
2026-03-07 22:58:43 +08:00
purchased := 0
for _ , b := range bindings {
if b . PurchaseCount != nil && * b . PurchaseCount > 0 {
purchased ++
}
}
2026-03-22 08:34:28 +08:00
orderOnlyCount := 0
for _ , buyerID := range orderBuyerIDs {
if ! bindingRefereeSet [ buyerID ] {
orderOnlyCount ++
purchased ++
}
}
totalReferrals := len ( bindings ) + orderOnlyCount
2026-03-07 22:58:43 +08:00
c . JSON ( http . StatusOK , gin . H {
"success" : true , "referrals" : referrals ,
2026-03-24 01:22:50 +08:00
"inboundSource" : gin . H {
"totalVisits" : len ( inboundVisitItems ) ,
"firstVisit" : firstInbound ,
"latestVisit" : latestInbound ,
"activeBinding" : activeBinding ,
"visits" : inboundVisitItems ,
} ,
2026-03-07 22:58:43 +08:00
"stats" : gin . H {
2026-03-22 08:34:28 +08:00
"total" : totalReferrals , "purchased" : purchased , "free" : totalReferrals - purchased ,
2026-03-07 22:58:43 +08:00
"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 )
}
2026-03-18 16:00:57 +08:00
if err := query . Offset ( ( page - 1 ) * pageSize ) . Limit ( pageSize ) . Find ( & bindings ) . Error ; err != nil {
2026-03-07 22:58:43 +08:00
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 {
2026-03-19 18:26:45 +08:00
"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 ) ) ,
2026-03-07 22:58:43 +08:00
"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/外部执行" } )
}