Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	miniprogram/pages/profile-edit/profile-edit.js
#	miniprogram/pages/profile-edit/profile-edit.wxml
#	miniprogram/pages/settings/settings.js
#	miniprogram/utils/ruleEngine.js
#	soul-admin/src/pages/distribution/DistributionPage.tsx
#	soul-admin/src/pages/users/UsersPage.tsx
#	soul-api/.env.production
#	soul-api/.gitignore
#	soul-api/internal/handler/db_ckb_leads.go
#	soul-api/internal/handler/miniprogram.go
#	soul-api/internal/handler/referral.go
#	开发文档/1、需求/archive/链接人与事-存客宝同步-需求规划.md
#	开发文档/1、需求/archive/链接人与事-实现方案.md
This commit is contained in:
Alex-larget
2026-03-20 14:48:02 +08:00
247 changed files with 8990 additions and 6983 deletions

View File

@@ -48,10 +48,15 @@ const KeyConfigAuditMode = "soul:config:audit-mode"
const KeyConfigCore = "soul:config:core"
const KeyConfigReadExtras = "soul:config:read-extras"
// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB
// Get 从 Redis 读取,未配置或失败时尝试内存备用;均失败返回 false(调用方回退 DB
func Get(ctx context.Context, key string, dest interface{}) bool {
client := redis.Client()
if client == nil {
// Redis 不可用,使用内存备用
if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 {
_ = json.Unmarshal(data, dest)
return true
}
return false
}
if ctx == nil {
@@ -61,6 +66,11 @@ func Get(ctx context.Context, key string, dest interface{}) bool {
defer cancel()
val, err := client.Get(ctx, key).Bytes()
if err != nil {
// Redis 超时/失败时尝试内存备用
if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 {
_ = json.Unmarshal(data, dest)
return true
}
return false
}
if dest != nil && len(val) > 0 {
@@ -69,10 +79,16 @@ func Get(ctx context.Context, key string, dest interface{}) bool {
return true
}
// Set 写入 Redis失败仅打日志不阻塞
// Set 写入 RedisRedis 不可用时写入内存备用;失败仅打日志不阻塞
func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
data, err := json.Marshal(val)
if err != nil {
log.Printf("cache.Set marshal %s: %v", key, err)
return
}
client := redis.Client()
if client == nil {
memorySet(key, data, ttl)
return
}
if ctx == nil {
@@ -80,22 +96,20 @@ func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
data, err := json.Marshal(val)
if err != nil {
log.Printf("cache.Set marshal %s: %v", key, err)
return
}
if err := client.Set(ctx, key, data, ttl).Err(); err != nil {
log.Printf("cache.Set %s: %v (非致命)", key, err)
log.Printf("cache.Set %s: %v (非致命),已写入内存备用", key, err)
memorySet(key, data, ttl)
}
}
// Del 删除 key失败仅打日志
// Del 删除 keyRedis 不可用时删除内存备用
func Del(ctx context.Context, key string) {
client := redis.Client()
if client == nil {
memoryDel(key)
return
}
memoryDel(key)
if ctx == nil {
ctx = context.Background()
}
@@ -106,12 +120,14 @@ func Del(ctx context.Context, key string) {
}
}
// DelPattern 按模式删除 key如 soul:book:chapters-by-part:*),用于批量失效
// DelPattern 按模式删除 key如 soul:book:chapters-by-part:*Redis 不可用时删除内存备
func DelPattern(ctx context.Context, pattern string) {
client := redis.Client()
if client == nil {
memoryDelPattern(pattern)
return
}
memoryDelPattern(pattern)
if ctx == nil {
ctx = context.Background()
}
@@ -183,10 +199,13 @@ func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sp
// ChapterContentTTL 章节正文 TTL后台更新时主动 Del
const ChapterContentTTL = 30 * time.Minute
// GetString 读取字符串(不经过 JSON适合大文本 content
// GetString 读取字符串(不经过 JSON适合大文本 contentRedis 不可用时尝试内存备用
func GetString(ctx context.Context, key string) (string, bool) {
client := redis.Client()
if client == nil {
if data, ok := memoryGet(key); ok {
return string(data), true
}
return "", false
}
if ctx == nil {
@@ -196,15 +215,19 @@ func GetString(ctx context.Context, key string) (string, bool) {
defer cancel()
val, err := client.Get(ctx, key).Result()
if err != nil {
if data, ok := memoryGet(key); ok {
return string(data), true
}
return "", false
}
return val, true
}
// SetString 写入字符串(不经过 JSON适合大文本 content
// SetString 写入字符串(不经过 JSON适合大文本 contentRedis 不可用时写入内存备用
func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
client := redis.Client()
if client == nil {
memorySet(key, []byte(val), ttl)
return
}
if ctx == nil {
@@ -213,7 +236,8 @@ func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
if err := client.Set(ctx, key, val, ttl).Err(); err != nil {
log.Printf("cache.SetString %s: %v (非致命)", key, err)
log.Printf("cache.SetString %s: %v (非致命),已写入内存备用", key, err)
memorySet(key, []byte(val), ttl)
}
}

52
soul-api/internal/cache/memory.go vendored Normal file
View File

@@ -0,0 +1,52 @@
package cache
import (
"strings"
"sync"
"time"
)
// memoryFallback Redis 不可用时的内存备用缓存,保证服务可用
var (
memoryMu sync.RWMutex
memoryData = make(map[string]*memoryEntry)
)
type memoryEntry struct {
Data []byte
Expiry time.Time
}
func memoryGet(key string) ([]byte, bool) {
memoryMu.RLock()
defer memoryMu.RUnlock()
e, ok := memoryData[key]
if !ok || e == nil || time.Now().After(e.Expiry) {
return nil, false
}
return e.Data, true
}
func memorySet(key string, data []byte, ttl time.Duration) {
memoryMu.Lock()
defer memoryMu.Unlock()
memoryData[key] = &memoryEntry{Data: data, Expiry: time.Now().Add(ttl)}
}
func memoryDel(key string) {
memoryMu.Lock()
defer memoryMu.Unlock()
delete(memoryData, key)
}
// memoryDelPattern 按前缀删除pattern 如 soul:book:chapters-by-part:* 转为前缀 soul:book:chapters-by-part:
func memoryDelPattern(pattern string) {
prefix := strings.TrimSuffix(pattern, "*")
memoryMu.Lock()
defer memoryMu.Unlock()
for k := range memoryData {
if strings.HasPrefix(k, prefix) {
delete(memoryData, k)
}
}
}

View File

@@ -287,7 +287,8 @@ func Load() (*Config, error) {
}
// 生产环境GIN_MODE=release强制校验敏感配置禁止使用默认值
if cfg.Mode == "release" {
// SKIP_PROD_SECRET_CHECK=staging 时跳过校验(仅用于测试/预发环境,正式环境请配置真实密钥)
if cfg.Mode == "release" && strings.TrimSpace(os.Getenv("SKIP_PROD_SECRET_CHECK")) != "staging" {
sensitive := []struct {
name string
val string
@@ -303,7 +304,7 @@ func Load() (*Config, error) {
strings.HasPrefix(s.val, "wx3e31b068") ||
s.val == "admin123" ||
s.val == "soul-admin-secret-change-in-prod" {
log.Fatalf("生产环境必须配置 %s禁止使用默认值", s.name)
log.Fatalf("生产环境必须配置 %s禁止使用默认值。测试环境可设置 SKIP_PROD_SECRET_CHECK=staging 跳过", s.name)
}
}
}

View File

@@ -178,7 +178,7 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
_ = json.Unmarshal(b, &m)
if u := userMap[o.UserID]; u != nil {
m["userNickname"] = dashStr(u.Nickname)
m["userAvatar"] = dashStr(u.Avatar)
m["userAvatar"] = resolveAvatarURL(dashStr(u.Avatar))
} else {
m["userNickname"] = ""
m["userAvatar"] = ""

View File

@@ -168,7 +168,7 @@ func DBUsersRFM(c *gin.Context) {
ru.Phone = *u.Phone
}
if u.Avatar != nil {
ru.Avatar = *u.Avatar
ru.Avatar = resolveAvatarURL(*u.Avatar)
}
result = append(result, ru)
}

View File

@@ -8,13 +8,66 @@ import (
"strconv"
"time"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AdminWithdrawalsAutoApproveGet GET /api/admin/withdrawals/auto-approve 获取自动审批开关状态
func AdminWithdrawalsAutoApproveGet(c *gin.Context) {
db := database.DB()
enabled := false
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var val map[string]interface{}
if err := json.Unmarshal(refCfg.ConfigValue, &val); err == nil {
if v, ok := val["enableAutoWithdraw"].(bool); ok {
enabled = v
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "enableAutoApprove": enabled})
}
// AdminWithdrawalsAutoApprovePut PUT /api/admin/withdrawals/auto-approve 设置自动审批开关
func AdminWithdrawalsAutoApprovePut(c *gin.Context) {
var body struct {
EnableAutoApprove bool `json:"enableAutoApprove"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
db := database.DB()
var refCfg model.SystemConfig
val := map[string]interface{}{
"distributorShare": float64(90), "minWithdrawAmount": float64(10), "bindingDays": float64(30),
"userDiscount": float64(5), "withdrawFee": float64(5), "enableAutoWithdraw": body.EnableAutoApprove,
"vipOrderShareVip": float64(20), "vipOrderShareNonVip": float64(10),
}
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
if err := json.Unmarshal(refCfg.ConfigValue, &val); err == nil {
val["enableAutoWithdraw"] = body.EnableAutoApprove
}
}
valBytes, _ := json.Marshal(val)
desc := "分销 / 推广规则配置"
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err != nil {
refCfg = model.SystemConfig{ConfigKey: "referral_config", ConfigValue: valBytes, Description: &desc}
_ = db.Create(&refCfg)
} else {
refCfg.ConfigValue = valBytes
refCfg.Description = &desc
_ = db.Save(&refCfg)
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "enableAutoApprove": body.EnableAutoApprove, "message": "已更新"})
}
// AdminWithdrawalsList GET /api/admin/withdrawals支持分页 page、pageSize筛选 status
func AdminWithdrawalsList(c *gin.Context) {
statusFilter := c.Query("status")
@@ -93,11 +146,28 @@ func AdminWithdrawalsList(c *gin.Context) {
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
userConfirmedAt = w.UserConfirmedAt.Format("2006-01-02 15:04:05")
}
avStr := ""
if userAvatar != nil {
avStr = resolveAvatarURL(*userAvatar)
}
// 备注:失败时显示 failReason/errorMessage否则显示用户 remark
remark := ""
if st == "rejected" || st == "failed" {
if w.FailReason != nil && *w.FailReason != "" {
remark = *w.FailReason
} else if w.ErrorMessage != nil && *w.ErrorMessage != "" {
remark = *w.ErrorMessage
}
}
if remark == "" && w.Remark != nil && *w.Remark != "" {
remark = *w.Remark
}
withdrawals = append(withdrawals, gin.H{
"id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": userAvatar,
"id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": avStr,
"amount": w.Amount, "status": st, "createdAt": w.CreatedAt,
"method": "wechat", "account": account,
"userConfirmedAt": userConfirmedAt,
"remark": remark,
})
}
@@ -123,6 +193,109 @@ func AdminWithdrawalsList(c *gin.Context) {
})
}
// doApproveWithdrawal 执行提现审批逻辑(打款),供 AdminWithdrawalsAction 与自动审批共用
// 返回 (successMessage, error),成功时 err 为 nil
func doApproveWithdrawal(db *gorm.DB, id string) (string, error) {
now := time.Now()
var w model.Withdrawal
if err := db.Where("id = ?", id).First(&w).Error; err != nil {
return "", fmt.Errorf("提现记录不存在")
}
st := ""
if w.Status != nil {
st = *w.Status
}
if st != "pending" && st != "processing" && st != "pending_confirm" {
return "", fmt.Errorf("当前状态不允许批准")
}
openID := ""
if w.WechatOpenid != nil && *w.WechatOpenid != "" {
openID = *w.WechatOpenid
}
if openID == "" {
var u model.User
if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil {
openID = *u.OpenID
}
}
if openID == "" {
return "", fmt.Errorf("用户未绑定微信 openid无法打款")
}
_, totalCommission, withdrawn, pending, _ := computeAvailableWithdraw(db, w.UserID)
availableRaw := totalCommission - withdrawn - pending
if availableRaw < -0.01 {
return "", fmt.Errorf("用户当前可提现不足,无法批准")
}
remark := "提现"
if w.Remark != nil && *w.Remark != "" {
remark = *w.Remark
}
withdrawFee := 0.0
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var refVal map[string]interface{}
if err := json.Unmarshal(refCfg.ConfigValue, &refVal); err == nil {
if v, ok := refVal["withdrawFee"].(float64); ok {
withdrawFee = v / 100
}
}
}
actualAmount := w.Amount * (1 - withdrawFee)
if actualAmount < 0.01 {
actualAmount = 0.01
}
amountFen := int(actualAmount * 100)
if amountFen < 1 {
return "", fmt.Errorf("提现金额异常")
}
params := wechat.FundAppTransferParams{
OutBillNo: w.ID, OpenID: openID, Amount: amountFen, Remark: remark,
NotifyURL: "", TransferSceneId: "1005",
}
result, err := wechat.InitiateTransferByFundApp(params)
if err != nil {
errMsg := err.Error()
if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" {
_ = db.Model(&w).Updates(map[string]interface{}{"status": "success", "processed_at": now}).Error
return "已标记为已打款。当前未接入微信转账,请线下打款。", nil
}
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed", "fail_reason": errMsg, "error_message": errMsg, "processed_at": now,
}).Error
return "", fmt.Errorf("%s", errMsg)
}
if result.OutBillNo == "" {
failMsg := "微信未返回商户单号,请检查商户平台(如 IP 白名单)或查看服务端日志"
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed", "fail_reason": failMsg, "error_message": failMsg, "processed_at": now,
}).Error
return "", fmt.Errorf("%s", failMsg)
}
rowStatus := "processing"
if result.State == "WAIT_USER_CONFIRM" {
rowStatus = "pending_confirm"
}
upd := map[string]interface{}{
"status": rowStatus, "detail_no": result.OutBillNo, "batch_no": result.OutBillNo,
"batch_id": result.TransferBillNo, "processed_at": now,
}
if result.PackageInfo != "" {
upd["package_info"] = result.PackageInfo
}
if err := db.Model(&w).Updates(upd).Error; err != nil {
return "", fmt.Errorf("更新状态失败: %w", err)
}
if openID != "" {
go func() {
ctx := context.Background()
if e := wechat.SendWithdrawSubscribeMessage(ctx, openID, w.Amount, true); e != nil {
fmt.Printf("[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n", id, e)
}
}()
}
return "已发起打款,微信处理中", nil
}
// AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款
// approve先调微信转账接口打款成功则标为 processing失败则标为 failed 并返回错误。
// 若未初始化微信转账客户端,则仅将状态标为 success线下打款后批准
@@ -165,167 +338,12 @@ func AdminWithdrawalsAction(c *gin.Context) {
return
case "approve":
var w model.Withdrawal
if err := db.Where("id = ?", body.ID).First(&w).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现记录不存在"})
return
}
st := ""
if w.Status != nil {
st = *w.Status
}
if st != "pending" && st != "processing" && st != "pending_confirm" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "当前状态不允许批准"})
return
}
openID := ""
if w.WechatOpenid != nil && *w.WechatOpenid != "" {
openID = *w.WechatOpenid
}
if openID == "" {
var u model.User
if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil {
openID = *u.OpenID
}
}
if openID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户未绑定微信 openid无法打款"})
return
}
// 批准前二次校验可提现金额,与申请时口径一致,防止退款/冲正后超额打款
available, _, _, _, _ := computeAvailableWithdraw(db, w.UserID)
if w.Amount > available {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "用户当前可提现不足,无法批准",
"message": fmt.Sprintf("用户当前可提现 ¥%.2f,本笔申请 ¥%.2f,可能因退款/冲正导致。请核对后再批或联系用户。", available, w.Amount),
})
return
}
// 调用微信转账接口按提现手续费扣除后打款例如申请100元、手续费5%则实际打款95元
remark := "提现"
if w.Remark != nil && *w.Remark != "" {
remark = *w.Remark
}
withdrawFee := 0.0
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var refVal map[string]interface{}
if err := json.Unmarshal(refCfg.ConfigValue, &refVal); err == nil {
if v, ok := refVal["withdrawFee"].(float64); ok {
withdrawFee = v / 100
}
}
}
actualAmount := w.Amount * (1 - withdrawFee)
if actualAmount < 0.01 {
actualAmount = 0.01
}
amountFen := int(actualAmount * 100)
if amountFen < 1 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现金额异常"})
return
}
outBillNo := w.ID // 商户单号,回调时 out_bill_no 即此值,用于更新该条提现
params := wechat.FundAppTransferParams{
OutBillNo: outBillNo,
OpenID: openID,
Amount: amountFen,
Remark: remark,
NotifyURL: "", // 由 wechat 包从配置读取 WechatTransferURL
TransferSceneId: "1005",
}
result, err := wechat.InitiateTransferByFundApp(params)
msg, err := doApproveWithdrawal(db, body.ID)
if err != nil {
errMsg := err.Error()
fmt.Printf("[AdminWithdrawals] 发起转账失败 id=%s: %s\n", body.ID, errMsg)
// 未初始化或未配置转账:仅标记为已打款并提示线下处理
if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" {
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "success",
"processed_at": now,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已标记为已打款。当前未接入微信转账,请线下打款。",
})
return
}
// 微信接口报错或其它失败:把微信/具体原因返回给管理端展示,不返回「微信处理中」
failMsg := errMsg
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed",
"fail_reason": failMsg,
"error_message": failMsg,
"processed_at": now,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "发起打款失败",
"message": failMsg, // 管理端直接展示微信报错信息(如 IP 白名单、参数错误等)
})
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "message": err.Error()})
return
}
// 防护:微信未返回商户单号时也按失败返回,避免管理端显示「已发起打款」却无单号
if result.OutBillNo == "" {
failMsg := "微信未返回商户单号,请检查商户平台(如 IP 白名单)或查看服务端日志"
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed",
"fail_reason": failMsg,
"error_message": failMsg,
"processed_at": now,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "发起打款失败",
"message": failMsg,
})
return
}
// 打款已受理微信同步返回立即落库商户单号、微信单号、package_info、按 state 设 status不依赖回调
fmt.Printf("[AdminWithdrawals] 微信已受理 id=%s out_bill_no=%s transfer_bill_no=%s state=%s\n", body.ID, result.OutBillNo, result.TransferBillNo, result.State)
rowStatus := "processing"
if result.State == "WAIT_USER_CONFIRM" {
rowStatus = "pending_confirm" // 待用户在小程序点击确认收款,回调在用户确认后才触发
}
upd := map[string]interface{}{
"status": rowStatus,
"detail_no": result.OutBillNo,
"batch_no": result.OutBillNo,
"batch_id": result.TransferBillNo,
"processed_at": now,
}
if result.PackageInfo != "" {
upd["package_info"] = result.PackageInfo
}
if err := db.Model(&w).Updates(upd).Error; err != nil {
fmt.Printf("[AdminWithdrawals] 更新提现状态失败 id=%s: %v\n", body.ID, err)
c.JSON(http.StatusOK, gin.H{"success": false, "error": "更新状态失败: " + err.Error()})
return
}
// 发起转账成功后发订阅消息(异步,失败不影响接口返回)
if openID != "" {
go func() {
ctx := context.Background()
if err := wechat.SendWithdrawSubscribeMessage(ctx, openID, w.Amount, true); err != nil {
fmt.Printf("[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n", body.ID, err)
}
}()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已发起打款,微信处理中",
"data": gin.H{
"out_bill_no": result.OutBillNo,
"transfer_bill_no": result.TransferBillNo,
},
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
return
default:

View File

@@ -91,6 +91,20 @@ func buildMiniprogramConfig() gin.H {
}
}
}
// 价格以管理端「站点与作者」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 {
@@ -157,24 +171,36 @@ func GetPublicDBConfig(c *gin.Context) {
}
// 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
}
full := buildMiniprogramConfig()
auditMode := false
if mp, ok := full["mpConfig"].(gin.H); ok {
if v, ok := mp["auditMode"].(bool); ok && v {
auditMode = true
}
}
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
}
if v, ok := mp["auditMode"].(bool); ok && v {
return true
}
return false
}
// GetCoreConfig GET /api/miniprogram/config/core 核心配置prices、features、userDiscount、mpConfig首屏/Tab 用
func GetCoreConfig(c *gin.Context) {
var cached gin.H
@@ -949,7 +975,7 @@ func DBUsersAction(c *gin.Context) {
updates["wechat_id"] = *body.WechatID
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
updates["avatar"] = avatarToPath(*body.Avatar)
}
if body.Tags != nil {
updates["ckb_tags"] = *body.Tags
@@ -1001,7 +1027,7 @@ func DBUsersAction(c *gin.Context) {
updates["vip_name"] = *body.VipName
}
if body.VipAvatar != nil {
updates["vip_avatar"] = *body.VipAvatar
updates["vip_avatar"] = avatarToPath(*body.VipAvatar)
}
if body.VipProject != nil {
updates["vip_project"] = *body.VipProject
@@ -1112,8 +1138,12 @@ func DBUsersReferrals(c *gin.Context) {
// 已付费:与小程序一致,以绑定记录的 purchase_count > 0 为准(支付回调会更新该字段)
hasPaid := b.PurchaseCount != nil && *b.PurchaseCount > 0
displayStatus := bindingStatusDisplay(hasPaid, hasFullBook) // vip | paid | free供前端徽章展示
avStr := ""
if avatar != nil {
avStr = resolveAvatarURL(*avatar)
}
referrals = append(referrals, gin.H{
"id": b.RefereeID, "nickname": nick, "avatar": avatar, "phone": phone,
"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,
@@ -1288,8 +1318,8 @@ func DBDistribution(c *gin.Context) {
statusVal = *b.Status
}
out = append(out, gin.H{
"id": b.ID, "referrerId": b.ReferrerID, "referrerName": getStr(referrerName), "referrerCode": b.ReferralCode, "referrerAvatar": getStr(referrerAvatar),
"refereeId": b.RefereeID, "refereeNickname": refNick, "refereePhone": getStr(refereePhone), "refereeAvatar": getStr(refereeAvatar),
"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",
})

View File

@@ -616,6 +616,38 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
return
}
// update-chapter-pricing按篇+章批量更新该章下所有「节」行的 price / is_free管理端章节统一定价
if body.Action == "update-chapter-pricing" {
if body.PartID == "" || body.ChapterID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 partId 或 chapterId"})
return
}
p := 1.0
if body.Price != nil {
p = *body.Price
}
free := false
if body.IsFree != nil {
free = *body.IsFree
}
if free {
p = 0
}
up := map[string]interface{}{
"price": p,
"is_free": free,
}
res := db.Model(&model.Chapter{}).Where("part_id = ? AND chapter_id = ?", body.PartID, body.ChapterID).Updates(up)
if res.Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": res.Error.Error()})
return
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已更新本章全部节的定价", "affected": res.RowsAffected})
return
}
if body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return

View File

@@ -26,55 +26,67 @@ func DBCKBLeadList(c *gin.Context) {
pageSize = 20
}
dedup := c.DefaultQuery("dedup", "true")
if mode == "contact" {
// ckb_lead_records链接卡若留资关联 persons 获取 @人 与获客计划
q := db.Model(&model.CkbLeadRecord{})
var total int64
var records []model.CkbLeadRecord
if dedup == "true" {
subQ := db.Model(&model.CkbLeadRecord{}).
Select("MAX(id) as id").
Group("COALESCE(NULLIF(user_id,''), COALESCE(NULLIF(phone,''), COALESCE(NULLIF(wechat_id,''), CAST(id AS CHAR))))")
q = db.Model(&model.CkbLeadRecord{}).Where("id IN (?)", subQ)
}
q.Count(&total)
var records []model.CkbLeadRecord
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
// 批量查 persons 获取 personName、ckbPlanId
personIDs := make([]string, 0)
for _, r := range records {
if r.TargetPersonID != "" {
personIDs = append(personIDs, r.TargetPersonID)
}
}
personMap := make(map[string]*model.Person)
if len(personIDs) > 0 {
var persons []model.Person
db.Where("person_id IN ?", personIDs).Find(&persons)
for i := range persons {
personMap[persons[i].PersonID] = &persons[i]
}
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
personName := ""
ckbPlanId := int64(0)
if p := personMap[r.TargetPersonID]; p != nil {
personName = p.Name
ckbPlanId = p.CkbPlanID
}
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"userNickname": r.Nickname,
"matchType": "lead",
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"createdAt": r.CreatedAt,
"id": r.ID,
"userId": r.UserID,
"userNickname": r.Nickname,
"matchType": "lead",
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"source": r.Source,
"targetPersonId": r.TargetPersonID,
"personName": personName,
"ckbPlanId": ckbPlanId,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
return
}
// mode=submitted: ckb_submit_records
q := db.Model(&model.CkbSubmitRecord{})
if matchType != "" {
// matchType 对应 action: join 时 type 在 params 中match 时 matchType 在 params 中
// 简化:仅按 action 过滤join 时 params 含 type
if matchType == "join" || matchType == "match" {
q = q.Where("action = ?", matchType)
}
}
if dedup == "true" {
subQ := db.Model(&model.CkbSubmitRecord{}).
Select("MAX(id) as id").
Group("COALESCE(NULLIF(user_id,''), CAST(id AS CHAR))")
if matchType == "join" || matchType == "match" {
subQ = subQ.Where("action = ?", matchType)
}
q = db.Model(&model.CkbSubmitRecord{}).Where("id IN (?)", subQ)
}
var total int64
q.Count(&total)
var records []model.CkbSubmitRecord
@@ -121,95 +133,6 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
}
// CKBPersonLeadStats GET /api/db/ckb-person-leads 每个人物的获客线索统计及明细
func CKBPersonLeadStats(c *gin.Context) {
db := database.DB()
personToken := c.Query("token")
if personToken != "" {
// 返回某人物的线索明细(通过 token → Person → 用 PersonID 和 Token 匹配 CkbLeadRecord.TargetPersonID
var person model.Person
if err := db.Where("token = ?", personToken).First(&person).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
q := db.Model(&model.CkbLeadRecord{}).Where("target_person_id IN ?", []string{person.PersonID, person.Token})
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records)
out := make([]gin.H, 0, len(records))
for _, r := range records {
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"nickname": r.Nickname,
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"source": r.Source,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"personName": person.Name,
"records": out,
"total": total,
"page": page,
"pageSize": pageSize,
})
return
}
// 无 token 参数:返回所有人物的获客数量汇总
type PersonLeadStat struct {
TargetPersonID string `gorm:"column:target_person_id"`
Total int64 `gorm:"column:total"`
}
var stats []PersonLeadStat
db.Raw("SELECT target_person_id, COUNT(*) as total FROM ckb_lead_records WHERE target_person_id != '' GROUP BY target_person_id").Scan(&stats)
// 构建 personId/token → Person.Token 的映射,使前端能用 token 匹配
var persons []model.Person
db.Select("person_id, token").Find(&persons)
pidToToken := make(map[string]string, len(persons))
for _, p := range persons {
pidToToken[p.PersonID] = p.Token
pidToToken[p.Token] = p.Token
}
merged := make(map[string]int64)
for _, s := range stats {
key := pidToToken[s.TargetPersonID]
if key == "" {
key = s.TargetPersonID
}
merged[key] += s.Total
}
byPerson := make([]gin.H, 0, len(merged))
for token, total := range merged {
byPerson = append(byPerson, gin.H{"token": token, "total": total})
}
// 同时统计全局(无特定人物的)线索
var globalTotal int64
db.Model(&model.CkbLeadRecord{}).Where("target_person_id = '' OR target_person_id IS NULL").Count(&globalTotal)
c.JSON(http.StatusOK, gin.H{
"success": true,
"byPerson": byPerson,
"globalLeads": globalTotal,
})
}
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records
func CKBPlanStats(c *gin.Context) {
db := database.DB()

View File

@@ -380,7 +380,7 @@ func GiftPayDetail(c *gin.Context) {
nickname = *initiator.Nickname
}
if initiator.Avatar != nil && *initiator.Avatar != "" {
initiatorAvatar = *initiator.Avatar
initiatorAvatar = resolveAvatarURL(*initiator.Avatar)
}
}
@@ -417,7 +417,7 @@ func GiftPayDetail(c *gin.Context) {
nickname = *u.Nickname
}
if u.Avatar != nil && *u.Avatar != "" {
avatar = *u.Avatar
avatar = resolveAvatarURL(*u.Avatar)
}
}
redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": o.CreatedAt.Format("2006-01-02 15:04")})
@@ -625,7 +625,7 @@ func GiftPayMyRequests(c *gin.Context) {
nickname = *u.Nickname
}
if u.Avatar != nil && *u.Avatar != "" {
avatar = *u.Avatar
avatar = resolveAvatarURL(*u.Avatar)
}
}
redeemAt := o.CreatedAt.Format("2006-01-02 15:04")

View File

@@ -293,7 +293,7 @@ func MatchUsers(c *gin.Context) {
}
avatar := ""
if r.Avatar != nil {
avatar = *r.Avatar
avatar = resolveAvatarURL(*r.Avatar)
}
wechat := ""
if r.WechatID != nil {

View File

@@ -104,10 +104,10 @@ func DBMatchRecordsList(c *gin.Context) {
userAvatar := ""
matchedUserAvatar := ""
if u != nil && u.Avatar != nil {
userAvatar = *u.Avatar
userAvatar = resolveAvatarURL(*u.Avatar)
}
if mu != nil && mu.Avatar != nil {
matchedUserAvatar = *mu.Avatar
matchedUserAvatar = resolveAvatarURL(*mu.Avatar)
}
userNickname := ""
if u != nil {

View File

@@ -43,6 +43,7 @@ func MiniprogramMentorsList(c *gin.Context) {
result := make([]mentorItem, len(list))
for i, m := range list {
result[i] = mentorItem{Mentor: m}
result[i].Avatar = resolveAvatarURL(m.Avatar) // OSS 时 DB 存路径,返回需解析为完整 URL
if m.Tags != "" {
result[i].TagsArr = strings.Split(m.Tags, ",")
for j := range result[i].TagsArr {
@@ -79,7 +80,7 @@ func MiniprogramMentorsDetail(c *gin.Context) {
"success": true,
"data": gin.H{
"id": m.ID,
"avatar": m.Avatar,
"avatar": resolveAvatarURL(m.Avatar),
"name": m.Name,
"intro": m.Intro,
"tags": m.Tags,
@@ -257,7 +258,7 @@ func DBMentorsAction(c *gin.Context) {
updates["name"] = *body.Name
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
updates["avatar"] = avatarToPath(*body.Avatar)
}
if body.Intro != nil {
updates["intro"] = *body.Intro

View File

@@ -98,9 +98,6 @@ func MiniprogramLogin(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
return
}
// 记录注册行为到 user_tracks
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
db.Create(&model.UserTrack{ID: trackID, UserID: user.ID, Action: "register"})
// 新用户异步调用神射手自动打标手机号尚未绑定phone 为空时暂不调用)
AdminShensheShouAutoTag(userID, "")
} else {
@@ -137,7 +134,7 @@ func MiniprogramLogin(c *gin.Context) {
"id": user.ID,
"openId": getStringValue(user.OpenID),
"nickname": getStringValue(user.Nickname),
"avatar": getUrlValue(user.Avatar),
"avatar": resolveAvatarURL(getStringValue(user.Avatar)),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"referralCode": getStringValue(user.ReferralCode),
@@ -215,7 +212,89 @@ func MiniprogramDevLoginAs(c *gin.Context) {
"id": user.ID,
"openId": openID,
"nickname": getStringValue(user.Nickname),
"avatar": getUrlValue(user.Avatar),
"avatar": resolveAvatarURL(getStringValue(user.Avatar)),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"referralCode": getStringValue(user.ReferralCode),
"hasFullBook": getBoolValue(user.HasFullBook),
"purchasedSections": purchasedSections,
"earnings": getFloatValue(user.Earnings),
"pendingEarnings": getFloatValue(user.PendingEarnings),
"referralCount": getIntValue(user.ReferralCount),
"createdAt": user.CreatedAt,
}
if user.IsVip != nil {
responseUser["isVip"] = *user.IsVip
}
if user.VipExpireDate != nil {
responseUser["vipExpireDate"] = user.VipExpireDate.Format("2006-01-02")
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"openId": openID,
"user": responseUser,
"token": token,
},
})
}
// MiniprogramDevLoginByPhone POST /api/miniprogram/dev/login-by-phone 开发专用:按手机号登录(仅 APP_ENV=development 可用,密码可空)
func MiniprogramDevLoginByPhone(c *gin.Context) {
if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) != "development" {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "仅开发环境可用"})
return
}
var req struct {
Phone string `json:"phone" binding:"required"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少手机号"})
return
}
phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", ""))
if phone == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "手机号不能为空"})
return
}
db := database.DB()
var user model.User
// 支持纯数字或带 +86 前缀
if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "该手机号未注册"})
return
}
openID := getStringValue(user.OpenID)
if openID == "" {
openID = user.ID
}
tokenSuffix := openID
if len(openID) >= 8 {
tokenSuffix = openID[len(openID)-8:]
}
token := fmt.Sprintf("tk_%s_%d", tokenSuffix, time.Now().Unix())
var purchasedSections []string
var orderRows []struct {
ProductID string `gorm:"column:product_id"`
}
db.Raw(`SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'`, user.ID).Scan(&orderRows)
for _, row := range orderRows {
if row.ProductID != "" {
purchasedSections = append(purchasedSections, row.ProductID)
}
}
if purchasedSections == nil {
purchasedSections = []string{}
}
responseUser := map[string]interface{}{
"id": user.ID,
"openId": openID,
"nickname": getStringValue(user.Nickname),
"avatar": resolveAvatarURL(getStringValue(user.Avatar)),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"referralCode": getStringValue(user.ReferralCode),
@@ -251,18 +330,6 @@ func getStringValue(ptr *string) string {
return *ptr
}
// getUrlValue 取字符串指针值并修复缺少冒号的 URL"https//..." → "https://..."
func getUrlValue(ptr *string) string {
s := getStringValue(ptr)
if strings.HasPrefix(s, "https//") {
return "https://" + s[7:]
}
if strings.HasPrefix(s, "http//") {
return "http://" + s[6:]
}
return s
}
func getBoolValue(ptr *bool) bool {
if ptr == nil {
return false
@@ -834,13 +901,7 @@ func MiniprogramPhone(c *gin.Context) {
if req.UserID != "" {
db := database.DB()
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
// 记录绑定手机号行为到 user_tracks
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
db.Create(&model.UserTrack{ID: trackID, UserID: req.UserID, Action: "bind_phone"})
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
// 记录绑定手机行为
bindTrackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
database.DB().Create(&model.UserTrack{ID: bindTrackID, UserID: req.UserID, Action: "bind_phone"})
// 绑定手机号后,异步调用神射手自动完善标签
AdminShensheShouAutoTag(req.UserID, phoneNumber)
}
@@ -1042,11 +1103,11 @@ func MiniprogramUsers(c *gin.Context) {
item := gin.H{
"id": user.ID,
"nickname": getStringValue(user.Nickname),
"avatar": getUrlValue(user.Avatar),
"avatar": resolveAvatarURL(getStringValue(user.Avatar)),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"vipName": getStringValue(user.VipName),
"vipAvatar": getStringValue(user.VipAvatar),
"vipAvatar": resolveAvatarURL(getStringValue(user.VipAvatar)),
"vipContact": getStringValue(user.VipContact),
"vipProject": getStringValue(user.VipProject),
"vipBio": getStringValue(user.VipBio),
@@ -1081,7 +1142,7 @@ func MiniprogramUsers(c *gin.Context) {
list = append(list, gin.H{
"id": u.ID,
"nickname": getStringValue(u.Nickname),
"avatar": getUrlValue(u.Avatar),
"avatar": resolveAvatarURL(getStringValue(u.Avatar)),
"is_vip": uvip,
})
}
@@ -1162,7 +1223,7 @@ func getStandardPrice(db *gorm.DB, productType, productID string) (float64, erro
}
return 0, fmt.Errorf("未知商品类型: %s", productType)
case "section", "gift":
case "section":
if productID == "" {
return 0, fmt.Errorf("单章购买缺少 productId")
}
@@ -1178,19 +1239,6 @@ func getStandardPrice(db *gorm.DB, productType, productID string) (float64, erro
}
return *ch.Price, nil
case "balance_recharge":
if productID == "" {
return 0, fmt.Errorf("充值订单号缺失")
}
var order model.Order
if err := db.Where("order_sn = ? AND product_type = ?", productID, "balance_recharge").First(&order).Error; err != nil {
return 0, fmt.Errorf("充值订单不存在: %s", productID)
}
if order.Amount <= 0 {
return 0, fmt.Errorf("充值金额无效")
}
return order.Amount, nil
default:
return 0, fmt.Errorf("未知商品类型: %s", productType)
}

View File

@@ -146,7 +146,7 @@ func OrdersList(c *gin.Context) {
if u := userMap[o.UserID]; u != nil {
m["userNickname"] = getStr(u.Nickname)
m["userPhone"] = getStr(u.Phone)
m["userAvatar"] = getStr(u.Avatar)
m["userAvatar"] = resolveAvatarURL(getStr(u.Avatar))
} else {
m["userNickname"] = ""
m["userPhone"] = ""

View File

@@ -283,7 +283,7 @@ func ReferralData(c *gin.Context) {
activeUsers = append(activeUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getUrlValue(referee.Avatar),
"avatar": resolveAvatarURL(getStringValue(referee.Avatar)),
"daysRemaining": daysRemaining,
"hasFullBook": getBoolValue(referee.HasFullBook),
"bindingDate": b.BindingDate,
@@ -312,7 +312,7 @@ func ReferralData(c *gin.Context) {
convertedUsers = append(convertedUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getUrlValue(referee.Avatar),
"avatar": resolveAvatarURL(getStringValue(referee.Avatar)),
"commission": commission,
"orderAmount": orderAmount,
"purchaseCount": getIntValue(b.PurchaseCount),
@@ -336,7 +336,7 @@ func ReferralData(c *gin.Context) {
expiredUsers = append(expiredUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getUrlValue(referee.Avatar),
"avatar": resolveAvatarURL(getStringValue(referee.Avatar)),
"bindingDate": b.BindingDate,
"expiryDate": b.ExpiryDate,
"status": "expired",
@@ -366,7 +366,7 @@ func ReferralData(c *gin.Context) {
"productId": getStringValue(e.ProductID),
"description": getStringValue(e.Description),
"buyerNickname": getStringValue(buyer.Nickname),
"buyerAvatar": getUrlValue(buyer.Avatar),
"buyerAvatar": resolveAvatarURL(getStringValue(buyer.Avatar)),
"payTime": e.PayTime,
})
}

View File

@@ -5,14 +5,59 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/oss"
"github.com/gin-gonic/gin"
)
// avatarToPath 从头像 URL 提取路径(不含域名),用于保存到 DB
func avatarToPath(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
if idx := strings.Index(s, "/uploads/"); idx >= 0 {
return s[idx:]
}
if strings.HasPrefix(s, "/") {
return s
}
return s
}
// resolveAvatarURL 将路径解析为完整可访问 URL返回时使用
func resolveAvatarURL(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
// 已是完整 URL直接返回
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
return s
}
path := s
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
// OSS 存储:用 OSS 公网 URL
if oss.IsEnabled() {
if u := oss.PublicURL(path); u != "" {
return u
}
}
// 本地存储:用 BaseURL 拼接
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
return cfg.BaseURLJoin(path)
}
return path
}
// UserAddressesGet GET /api/user/addresses?userId=
func UserAddressesGet(c *gin.Context) {
userId := c.Query("userId")
@@ -243,8 +288,9 @@ func UserProfileGet(c *gin.Context) {
profileComplete := (user.Phone != nil && *user.Phone != "") || (user.WechatID != nil && *user.WechatID != "")
hasAvatar := user.Avatar != nil && *user.Avatar != "" && len(*user.Avatar) > 0
str := func(p *string) interface{} { if p != nil { return *p }; return "" }
avatarVal := resolveAvatarURL(str(user.Avatar).(string))
resp := gin.H{
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar),
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": avatarVal,
"phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode,
"hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings,
"referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar,
@@ -311,7 +357,7 @@ func UserProfilePost(c *gin.Context) {
updates["nickname"] = *body.Nickname
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
updates["avatar"] = avatarToPath(*body.Avatar)
}
if body.Phone != nil {
updates["phone"] = *body.Phone
@@ -343,8 +389,9 @@ func UserProfilePost(c *gin.Context) {
"story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro"}
if err := database.DB().Select(profileCols).Where("id = ?", user.ID).First(&user).Error; err == nil {
str := func(p *string) interface{} { if p != nil { return *p }; return "" }
avatarVal := resolveAvatarURL(str(user.Avatar).(string))
resp := gin.H{
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar),
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": avatarVal,
"phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode,
"createdAt": user.CreatedAt,
"mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry),
@@ -355,8 +402,12 @@ func UserProfilePost(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": resp})
} else {
avatarVal := ""
if body.Avatar != nil {
avatarVal = resolveAvatarURL(avatarToPath(*body.Avatar))
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": gin.H{
"id": user.ID, "nickname": body.Nickname, "avatar": body.Avatar, "phone": body.Phone, "wechatId": body.WechatID, "referralCode": user.ReferralCode,
"id": user.ID, "nickname": body.Nickname, "avatar": avatarVal, "phone": body.Phone, "wechatId": body.WechatID, "referralCode": user.ReferralCode,
}})
}
}
@@ -694,7 +745,7 @@ func UserUpdate(c *gin.Context) {
updates["nickname"] = *body.Nickname
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
updates["avatar"] = avatarToPath(*body.Avatar)
}
if body.Phone != nil {
updates["phone"] = *body.Phone

View File

@@ -126,7 +126,7 @@ func buildVipProfile(u *model.User) gin.H {
"vipName": getStr(u.VipName),
"vipProject": getStr(u.VipProject),
"vipContact": getStr(u.VipContact),
"vipAvatar": getStr(u.VipAvatar),
"vipAvatar": resolveAvatarURL(getStr(u.VipAvatar)),
"vipBio": getStr(u.VipBio),
}
}
@@ -195,7 +195,7 @@ func VipProfilePost(c *gin.Context) {
updates["vip_contact"] = req.VipContact
}
if req.VipAvatar != "" {
updates["vip_avatar"] = req.VipAvatar
updates["vip_avatar"] = avatarToPath(req.VipAvatar)
}
if req.VipBio != "" {
updates["vip_bio"] = req.VipBio
@@ -290,6 +290,7 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
if avatar == "" {
avatar = getUrlValue(u.VipAvatar)
}
avatar = resolveAvatarURL(avatar)
project := getStringValue(u.VipProject)
if project == "" {
project = getStringValue(u.ProjectIntro)

View File

@@ -112,7 +112,7 @@ func WechatPhoneLogin(c *gin.Context) {
"id": user.ID,
"openId": strVal(user.OpenID),
"nickname": strVal(user.Nickname),
"avatar": strVal(user.Avatar),
"avatar": resolveAvatarURL(strVal(user.Avatar)),
"phone": strVal(user.Phone),
"wechatId": strVal(user.WechatID),
"referralCode": strVal(user.ReferralCode),

View File

@@ -51,7 +51,7 @@ func generateWithdrawID() string {
}
// WithdrawPost POST /api/withdraw 创建提现申请(仅落库待审核,不调用微信打款接口)
// 可提现逻辑与小程序 referral 页一致;二次查库校验防止超额。打款由管理端审核后手动/后续接入官方接口再处理
// 余额不足时也允许落库,用户侧显示「申请已提交」而非「提现失败」;管理端批准时再校验可提现,不足则拒绝
func WithdrawPost(c *gin.Context) {
var req struct {
UserID string `json:"userId" binding:"required"`
@@ -69,14 +69,8 @@ func WithdrawPost(c *gin.Context) {
}
db := database.DB()
available, _, _, _, minWithdrawAmount := computeAvailableWithdraw(db, req.UserID)
if req.Amount > available {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": fmt.Sprintf("可提现金额不足(当前可提现:%.2f元)", available),
})
return
}
_, _, _, _, minWithdrawAmount := computeAvailableWithdraw(db, req.UserID)
// 不再在此处校验余额:余额不足也落库,由管理端批准时校验并拒绝,避免用户侧直接报「提现失败」
if req.Amount < minWithdrawAmount {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
@@ -119,6 +113,23 @@ func WithdrawPost(c *gin.Context) {
return
}
// 自动审批:若 referral_config.enableAutoWithdraw 为 true异步执行审批打款
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(refCfg.ConfigValue, &config); config != nil {
if enabled, ok := config["enableAutoWithdraw"].(bool); ok && enabled {
go func(id string) {
if _, e := doApproveWithdrawal(db, id); e != nil {
fmt.Printf("[WithdrawPost] 自动审批失败 id=%s: %v\n", id, e)
} else {
fmt.Printf("[WithdrawPost] 自动审批成功 id=%s\n", id)
}
}(withdrawal.ID)
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "提现申请已提交,审核通过后将打款至您的微信零钱",

View File

@@ -0,0 +1,149 @@
// Package handler - WebSocket 占位:用户在线检测
// 小程序连接 WSS 发心跳Redis 记录在线;管理端通过 HTTP 获取在线人数
// 后续可扩展:管理端 WSS 订阅、消息推送等
package handler
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/redis"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
const (
wsOnlinePrefix = "user:online:"
wsOfflineTimeout = 300 // 5 分钟无心跳视为离线(秒)
)
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// WsMiniprogram 处理小程序 WSS 连接:鉴权后记录心跳,维持在线状态
// 路径GET /ws/miniprogram?token=xxx
// 首条消息需包含 {"type":"auth","userId":"user_xxx"},占位阶段不校验 token
// 容错panic 时 recover 并关闭连接,不影响 HTTP API 及其他请求
func WsMiniprogram(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
log.Printf("[WS] WsMiniprogram panic recovered: %v", r)
}
}()
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
var userID string
authOK := false
// 读取首条消息auth
conn.SetReadDeadline(time.Now().Add(15 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
var authMsg struct {
Type string `json:"type"`
UserID string `json:"userId"`
}
if json.Unmarshal(msg, &authMsg) == nil && authMsg.Type == "auth" && authMsg.UserID != "" {
userID = strings.TrimSpace(authMsg.UserID)
// 占位:校验用户存在即可
db := database.DB()
var u model.User
if db.Where("id = ?", userID).First(&u).Error == nil {
authOK = true
}
}
if !authOK {
conn.WriteJSON(map[string]interface{}{"type": "error", "message": "auth failed"})
return
}
// 鉴权通过,开始处理心跳
conn.SetReadDeadline(time.Time{}) // 取消超时
client := redis.Client()
if client == nil {
log.Printf("[WS] Redis 未启用,在线状态不可用")
return
}
key := wsOnlinePrefix + userID
ctx := context.Background()
ttl := time.Duration(wsOfflineTimeout) * time.Second
// 立即写入一次在线
client.Set(ctx, key, "1", ttl)
// 心跳读取循环
for {
_, msg, err := conn.ReadMessage()
if err != nil {
break
}
var m struct {
Type string `json:"type"`
}
if json.Unmarshal(msg, &m) == nil && (m.Type == "ping" || m.Type == "heartbeat") {
client.Set(ctx, key, "1", ttl)
conn.WriteJSON(map[string]interface{}{"type": "pong"})
}
}
}
// AdminUsersOnlineStats GET /api/admin/users/online-stats 管理端在线人数统计
// 容错Redis 不可用时返回 success + onlineCount: 0不影响管理端其他功能
func AdminUsersOnlineStats(c *gin.Context) {
client := redis.Client()
if client == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "onlineCount": 0})
return
}
ctx := context.Background()
iter := client.Scan(ctx, 0, wsOnlinePrefix+"*", 0).Iterator()
count := 0
for iter.Next(ctx) {
count++
}
if err := iter.Err(); err != nil {
log.Printf("[WS] AdminUsersOnlineStats Redis scan err: %v降级返回 0", err)
c.JSON(http.StatusOK, gin.H{"success": true, "onlineCount": 0})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "onlineCount": count})
}
// GetWsURL 返回小程序可用的 WSS 地址(基于 API_BASE_URL 派生)
func GetWsURL() string {
cfg := config.Get()
if cfg == nil {
return ""
}
base := strings.TrimSuffix(cfg.BaseURL, "/")
if base == "" {
return ""
}
if strings.HasPrefix(base, "https://") {
return "wss" + strings.TrimPrefix(base, "https") + "/ws/miniprogram"
}
if strings.HasPrefix(base, "http://") {
return strings.Replace(base, "http", "ws", 1) + "/ws/miniprogram"
}
return ""
}

View File

@@ -14,8 +14,9 @@ type Withdrawal struct {
DetailNo *string `gorm:"column:detail_no;size:100" json:"detailNo,omitempty"` // 商家明细单号
BatchID *string `gorm:"column:batch_id;size:100" json:"batchId,omitempty"` // 微信批次单号
PackageInfo *string `gorm:"column:package_info;size:500" json:"packageInfo,omitempty"` // 微信返回的 package_info供小程序 wx.requestMerchantTransfer
Remark *string `gorm:"column:remark;size:200" json:"remark,omitempty"` // 提现备注
FailReason *string `gorm:"column:fail_reason;size:500" json:"failReason,omitempty"` // 失败原因
Remark *string `gorm:"column:remark;size:200" json:"remark,omitempty"` // 提现备注(用户填写)
FailReason *string `gorm:"column:fail_reason;size:500" json:"failReason,omitempty"` // 失败原因(打款失败/拒绝时记录)
ErrorMessage *string `gorm:"column:error_message;size:500" json:"errorMessage,omitempty"` // 错误信息(与 fail_reason 同步)
UserConfirmedAt *time.Time `gorm:"column:user_confirmed_at" json:"userConfirmedAt,omitempty"` // 用户点击「确认收款」时间
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`

View File

@@ -122,3 +122,20 @@ func IsOSSURL(rawURL string) bool {
prefix := "https://" + cfg.Bucket + "." + cfg.Endpoint + "/"
return strings.HasPrefix(rawURL, prefix)
}
// PublicURL 将路径转为 OSS 公网访问 URLpath 如 /uploads/avatars/xxx.jpg
// OSS 未配置时返回空字符串
func PublicURL(path string) string {
cfg := LoadConfig()
if cfg == nil {
return ""
}
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if path[0] == '/' {
path = path[1:]
}
return "https://" + cfg.Bucket + "." + cfg.Endpoint + "/" + path
}

View File

@@ -21,6 +21,8 @@ func Init(url string) error {
client = redis.NewClient(opt)
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
client = nil // 连接失败时清空避免后续使用超时cache 将自动降级到内存备用
log.Printf("redis: 连接失败,已降级到内存缓存(%v", err)
return err
}
log.Printf("redis: connected to %s", opt.Addr)

View File

@@ -40,6 +40,9 @@ func Setup(cfg *config.Config) *gin.Engine {
}
r.Static("/uploads", uploadDir)
// WebSocket小程序在线心跳占位后续可扩展管理端订阅、消息推送
r.GET("/ws/miniprogram", handler.WsMiniprogram)
api := r.Group("/api")
{
// ----- 管理端 -----
@@ -75,6 +78,8 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/withdrawals", handler.AdminWithdrawalsList)
admin.PUT("/withdrawals", handler.AdminWithdrawalsAction)
admin.POST("/withdrawals/sync", handler.AdminWithdrawalsSync)
admin.GET("/withdrawals/auto-approve", handler.AdminWithdrawalsAutoApproveGet)
admin.PUT("/withdrawals/auto-approve", handler.AdminWithdrawalsAutoApprovePut)
admin.GET("/withdraw-test", handler.AdminWithdrawTest)
admin.POST("/withdraw-test", handler.AdminWithdrawTest)
admin.GET("/settings", handler.AdminSettingsGet)
@@ -98,6 +103,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/users/:id/balance", handler.AdminUserBalanceGet)
admin.POST("/users/:id/balance/adjust", handler.AdminUserBalanceAdjust)
admin.GET("/balance/summary", handler.AdminBalanceSummary)
admin.GET("/users/online-stats", handler.AdminUsersOnlineStats)
admin.GET("/users", handler.AdminUsersList)
admin.POST("/users", handler.AdminUsersAction)
admin.PUT("/users", handler.AdminUsersAction)
@@ -291,7 +297,8 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/config", handler.GetPublicDBConfig)
miniprogram.POST("/login", handler.MiniprogramLogin)
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
miniprogram.POST("/dev/login-by-phone", handler.MiniprogramDevLoginByPhone) // 开发专用:按手机号登录(密码可空)
miniprogram.POST("/phone", handler.MiniprogramPhone)
miniprogram.GET("/pay", handler.MiniprogramPay)
miniprogram.POST("/pay", handler.MiniprogramPay)