feat: 同步本地三端改动并清理上传凭证风险

整合小程序、管理端与后端的最新本地改动,补齐用户管理与首页入口相关能力;提交前已完成敏感信息扫描,并移除本地 gitea 远程 URL 中的明文凭证,避免隐私信息进入远程仓库。

Made-with: Cursor
This commit is contained in:
卡若
2026-04-06 15:59:34 +08:00
parent b1cc4be1c2
commit 6d11fb295d
126 changed files with 5789 additions and 2791 deletions

View File

@@ -232,6 +232,11 @@ func ensurePersonSchema(db *gorm.DB) {
log.Printf("database: persons schema ensure warning: %v; action=create idx_persons_is_pinned", err)
}
}
if !m.HasColumn(&model.Person{}, "home_entry_config") {
if err := db.Exec("ALTER TABLE persons ADD COLUMN home_entry_config TEXT NULL COMMENT '首页入口配置(打赏/上麦JSON'").Error; err != nil {
log.Printf("database: persons schema ensure warning: %v; action=add home_entry_config", err)
}
}
}
func ensureCkbLeadSchema(db *gorm.DB) {

View File

@@ -86,3 +86,60 @@ func AdminMbtiAvatarsPost(c *gin.Context) {
_mbtiAvatarCacheTs = 0
c.JSON(http.StatusOK, gin.H{"success": true, "message": "MBTI 头像映射已保存"})
}
// AdminMbtiAvatarsBatchAssign POST /api/admin/mbti-avatars/batch-assign
// 为无头像avatar IS NULL 或空)的用户批量分配 MBTI 默认头像。
// 有 MBTI 的按 MBTI 匹配;无 MBTI 的随机分配。
func AdminMbtiAvatarsBatchAssign(c *gin.Context) {
db := database.DB()
var row model.SystemConfig
if err := db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先配置 MBTI 头像映射"})
return
}
avatarMap := make(map[string]string)
if err := json.Unmarshal(row.ConfigValue, &avatarMap); err != nil || len(avatarMap) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "MBTI 头像映射为空"})
return
}
allTypes := make([]string, 0, len(avatarMap))
for k, v := range avatarMap {
if v != "" {
allTypes = append(allTypes, k)
}
}
if len(allTypes) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "无可用头像"})
return
}
var users []model.User
db.Where("(avatar IS NULL OR avatar = '')").Limit(500).Find(&users)
if len(users) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "assigned": 0, "message": "无需分配"})
return
}
assigned := 0
for i := range users {
mbti := ""
if users[i].Mbti != nil {
mbti = *users[i].Mbti
}
url := ""
if mbti != "" {
url = avatarMap[mbti]
}
if url == "" {
idx := (int(users[i].CreatedAt.UnixNano()) + i) % len(allTypes)
url = avatarMap[allTypes[idx]]
}
if url == "" {
continue
}
if err := db.Model(&model.User{}).Where("id = ?", users[i].ID).Update("avatar", url).Error; err == nil {
assigned++
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "assigned": assigned, "total": len(users), "message": "批量分配完成"})
}

View File

@@ -374,6 +374,35 @@ func DBUsersJourneyStats(c *gin.Context) {
db.Table("users").Where("referral_code IS NOT NULL AND referral_code != '' AND earnings > 0").Count(&dist)
stats["distribution"] = dist
// 打赏/上麦礼遇购买人数
var tipPay int64
db.Table("orders").Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "link_karuo_tip").Distinct("user_id").Count(&tipPay)
stats["tip_pay"] = tipPay
// 余额充值购买人数
var recharge int64
db.Table("orders").Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "balance_recharge").Distinct("user_id").Count(&recharge)
stats["balance_recharge"] = recharge
// 匹配次数购买人数
var matchPay int64
db.Table("orders").Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "match").Distinct("user_id").Count(&matchPay)
stats["match_pay"] = matchPay
// 流量池:时间窗内活跃(有任意轨迹的去重用户)
now := time.Now()
since7 := now.AddDate(0, 0, -7)
since30 := now.AddDate(0, 0, -30)
var active7, active30 int64
_ = db.Raw(`SELECT COUNT(DISTINCT user_id) FROM user_tracks WHERE created_at >= ?`, since7).Scan(&active7).Error
_ = db.Raw(`SELECT COUNT(DISTINCT user_id) FROM user_tracks WHERE created_at >= ?`, since30).Scan(&active30).Error
stats["active_7d"] = active7
stats["active_30d"] = active30
var reg7 int64
db.Table("users").Where("created_at >= ?", since7).Count(&reg7)
stats["register_7d"] = reg7
c.JSON(http.StatusOK, gin.H{"success": true, "stats": stats})
}
@@ -421,6 +450,18 @@ func DBUsersJourneyUsers(c *gin.Context) {
case "distribution":
db.Where("referral_code IS NOT NULL AND referral_code != ''").Where("COALESCE(earnings, 0) > ?", 0).
Order("created_at DESC").Limit(limit).Find(&users)
case "tip_pay":
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "link_karuo_tip")).
Order("created_at DESC").Limit(limit).Find(&users)
case "balance_recharge":
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "balance_recharge")).
Order("created_at DESC").Limit(limit).Find(&users)
case "match_pay":
db.Where("id IN (?)", db.Model(&model.Order{}).Select("user_id").
Where("status IN ? AND product_type = ?", []string{"paid", "completed"}, "match")).
Order("created_at DESC").Limit(limit).Find(&users)
default:
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的 stage 参数"})
return

View File

@@ -155,6 +155,23 @@ func defaultMpUi() gin.H {
"superSectionLinkPath": "/pages/match/match",
"pickSectionTitle": "精选推荐",
"latestSectionTitle": "最新新增",
"linkKaruoReward": gin.H{
"enabled": false, "entryButtonText": "支持一下", "modalTitle": "打赏支持",
"modalSubtitle": "感谢支持,任选礼物与数量", "payButtonText": "立即支付",
"leadLinkText": "留下联系方式", "afterPayToast": "支付成功,感谢支持!",
"gifts": []interface{}{
gin.H{"id": "gift_star", "name": "人气票", "price": 99, "emoji": "🎫", "icon": "", "micOrder": 0, "iconScale": 1, "micFeatured": false},
gin.H{"id": "gift_lite", "name": "小心心", "price": 9.9, "emoji": "❤️", "icon": "", "micOrder": 1, "iconScale": 1, "micFeatured": true},
gin.H{"id": "gift_super", "name": "嘉年华", "price": 1980, "emoji": "🎡", "icon": "", "micOrder": 2, "iconScale": 1, "micFeatured": false},
},
},
"liveMicSchedule": gin.H{
"enabled": false, "micStart": "18:00", "micEnd": "21:00", "micEmoji": "🎤",
"micButtonText": "申请上麦", "micModalTitle": "申请上麦",
"micModalContent": "当前为直播时段,提交后工作人员将按顺序处理连麦申请。",
"micAfterSubmitToast": "支付成功,感谢支持!", "paidWebhookUrl": "",
"micCenterEmojiScale": 1.55,
},
},
"myPage": gin.H{
"cardLabel": "名片", "vipLabelVip": "会员中心", "vipLabelGuest": "成为会员",
@@ -310,6 +327,19 @@ func buildMiniprogramConfig() gin.H {
}
}
}
// 全书解锁按钮显示阈值(购买多少小节后展示「解锁全书」),默认 2
var thresholdRow model.SystemConfig
if err := db.Where("config_key = ?", "fullbook_show_threshold").First(&thresholdRow).Error; err == nil && len(thresholdRow.ConfigValue) > 0 {
var tv interface{}
if json.Unmarshal(thresholdRow.ConfigValue, &tv) == nil {
if n, ok := tv.(float64); ok && n > 0 {
out["fullbookShowThreshold"] = int(n)
}
}
}
if _, exists := out["fullbookShowThreshold"]; !exists {
out["fullbookShowThreshold"] = 2
}
// 好友优惠与分润(用于 read 页展示优惠价、分享提示分润比例)
var refRow model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil {
@@ -1012,7 +1042,7 @@ func DBUsersList(c *gin.Context) {
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)
q = q.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR COALESCE(vip_name,'') LIKE ? OR COALESCE(wechat_id,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern, pattern, pattern)
}
if poolFilter == "complete" {
q = q.Where("(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')")
@@ -1027,7 +1057,7 @@ func DBUsersList(c *gin.Context) {
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)
query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR COALESCE(vip_name,'') LIKE ? OR COALESCE(wechat_id,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern, pattern, pattern)
}
if poolFilter == "complete" {
query = query.Where("(phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != '')")

View File

@@ -804,24 +804,45 @@ func CKBPinnedPerson(c *gin.Context) {
avatar = resolveAvatarURL(getStringValue(u.Avatar))
}
}
// 返回 per-person 首页入口配置(置顶超级个体独立配置),小程序优先读此字段
var homeEntryConfig interface{}
if p.HomeEntryConfig != nil && strings.TrimSpace(*p.HomeEntryConfig) != "" {
var hec interface{}
if json.Unmarshal([]byte(*p.HomeEntryConfig), &hec) == nil {
homeEntryConfig = hec
}
}
data := gin.H{
"name": personName,
"avatar": avatar,
"token": strings.TrimSpace(p.Token),
}
if homeEntryConfig != nil {
data["homeEntryConfig"] = homeEntryConfig
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"name": personName,
"avatar": avatar,
"token": strings.TrimSpace(p.Token),
},
"data": data,
})
}
// AdminCKBPlanCheck GET /api/admin/ckb/plan-check 管理端-检查存客宝计划在线状态
// 查询所有有 ckb_plan_id 的 Person对每个计划调用存客宝获取状态
// 查询所有有 ckb_plan_id 的 Person对每个计划调用存客宝获取状态(尝试启用以探测连通性)
// 可选 ?personId=xxx 仅检查单个人物(超级个体行内刷新)
func AdminCKBPlanCheck(c *gin.Context) {
db := database.DB()
var persons []model.Person
db.Where("ckb_plan_id > 0").Find(&persons)
q := db.Where("ckb_plan_id > 0")
if pid := strings.TrimSpace(c.Query("personId")); pid != "" {
q = q.Where("person_id = ?", pid)
}
q.Find(&persons)
if len(persons) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "plans": []interface{}{}, "message": "暂无配置了存客宝计划的人物"})
msg := "暂无配置了存客宝计划的人物"
if strings.TrimSpace(c.Query("personId")) != "" {
msg = "该人物未配置存客宝计划或不存在"
}
c.JSON(http.StatusOK, gin.H{"success": true, "plans": []interface{}{}, "message": msg})
return
}
token, err := ckbOpenGetToken()

View File

@@ -5,11 +5,14 @@ import (
"fmt"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
"unicode/utf8"
"regexp"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
@@ -21,6 +24,13 @@ import (
const giftPayExpireHours = 24
const wechatAttachMaxBytes = 128
var wxOpenIDRegex = regexp.MustCompile(`^o[A-Za-z0-9_-]{26,}$`)
func isLikelyWechatMiniOpenID(s string) bool {
s = strings.TrimSpace(s)
return wxOpenIDRegex.MatchString(s)
}
// truncateStr 截断字符串至最多 n 字节UTF-8 安全)
func truncateStr(s string, n int) string {
b := []byte(s)
@@ -207,6 +217,17 @@ func GiftPayInitiatorPay(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
return
}
req.OpenID = strings.TrimSpace(req.OpenID)
if req.OpenID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"})
return
}
if !isLikelyWechatMiniOpenID(req.OpenID) {
if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) != "development" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "支付标识无效,请重新进入小程序或重新登录"})
return
}
}
db := database.DB()
var gpr model.GiftPayRequest

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
@@ -26,6 +27,40 @@ var (
orderPollLoggerOnce sync.Once
)
// normalizePayProductType 清洗 productType避免不可见字符导致未命中 link_karuo_tip 分支
func normalizePayProductType(raw string) string {
s := strings.TrimSpace(raw)
s = strings.ReplaceAll(s, "\ufeff", "")
s = strings.ReplaceAll(s, "\u00a0", "")
s = strings.ReplaceAll(s, "\u200b", "")
s = strings.ReplaceAll(s, "\u200c", "")
s = strings.ReplaceAll(s, "\u200d", "")
s = strings.ReplaceAll(s, "\uff3f", "_")
s = strings.ReplaceAll(s, "-", "_")
s = strings.ToLower(s)
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
b.WriteRune(r)
}
}
out := b.String()
if strings.ReplaceAll(out, "_", "") == "linkkaruotip" {
return "link_karuo_tip"
}
return out
}
// isLinkKaruoTipProductType 打赏/上麦商品类型(兼容 normalize 漏网、同形连接符等)
func isLinkKaruoTipProductType(s string) bool {
s = strings.TrimSpace(strings.ToLower(normalizePayProductType(s)))
if s == "link_karuo_tip" {
return true
}
compact := strings.ReplaceAll(strings.ReplaceAll(s, "_", ""), "-", "")
return compact == "linkkaruotip"
}
// orderPollLogf 将订单轮询检测日志写入 log/order-poll.log不输出到控制台
func orderPollLogf(format string, args ...interface{}) {
orderPollLoggerOnce.Do(func() {
@@ -377,6 +412,7 @@ func miniprogramPayPost(c *gin.Context) {
Description string `json:"description"`
UserID string `json:"userId"`
ReferralCode string `json:"referralCode"`
TipSource string `json:"tipSource"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -384,6 +420,11 @@ func miniprogramPayPost(c *gin.Context) {
return
}
req.ProductType = normalizePayProductType(req.ProductType)
if isLinkKaruoTipProductType(req.ProductType) {
req.ProductType = "link_karuo_tip"
}
if req.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "支付金额无效"})
return
@@ -391,6 +432,11 @@ func miniprogramPayPost(c *gin.Context) {
db := database.DB()
productID := strings.TrimSpace(req.ProductID)
if req.ProductType == "link_karuo_tip" && strings.TrimSpace(req.TipSource) == "live_mic" && productID != "" && !strings.HasPrefix(productID, "live_mic|") {
productID = "live_mic|" + productID
}
var finalAmount float64
var orderSn string
var referrerID *string
@@ -410,44 +456,66 @@ func miniprogramPayPost(c *gin.Context) {
}
} else {
// -------- V1.1 后端价格:从 DB 读取标准价 --------
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
pricePID := productID
if pricePID == "" {
pricePID = strings.TrimSpace(req.ProductID)
}
standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID)
if priceErr != nil && req.ProductType == "link_karuo_tip" {
if fb, _, canonID, ferr := linkKaruoTipFallbackByAmount(db, pricePID, req.Amount); ferr == nil {
standardPrice = fb
priceErr = nil
_, qty, qe := parseLinkKaruoGiftQty(pricePID)
if qe != nil {
qty = 1
}
if strings.TrimSpace(req.TipSource) == "live_mic" {
productID = "live_mic|" + canonID + "|" + strconv.FormatInt(qty, 10)
} else {
productID = canonID + "|" + strconv.FormatInt(qty, 10)
}
}
}
if priceErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
return
}
finalAmount = standardPrice
if req.UserID != "" {
var binding struct {
ReferrerID string `gorm:"column:referrer_id"`
}
err := db.Raw(`
// 打赏不参与分销好友折扣(实付=标准价)
if req.ProductType != "link_karuo_tip" {
if req.UserID != "" {
var binding struct {
ReferrerID string `gorm:"column:referrer_id"`
}
err := db.Raw(`
SELECT referrer_id
FROM referral_bindings
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
ORDER BY binding_date DESC
LIMIT 1
`, req.UserID).Scan(&binding).Error
if err == nil && binding.ReferrerID != "" {
referrerID = &binding.ReferrerID
if err == nil && binding.ReferrerID != "" {
referrerID = &binding.ReferrerID
}
}
}
if referrerID == nil && req.ReferralCode != "" {
var refUser model.User
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
referrerID = &refUser.ID
if referrerID == nil && req.ReferralCode != "" {
var refUser model.User
if err := db.Where("referral_code = ?", req.ReferralCode).First(&refUser).Error; err == nil {
referrerID = &refUser.ID
}
}
}
if referrerID != nil {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
discountRate := userDiscount / 100
finalAmount = finalAmount * (1 - discountRate)
if finalAmount < 0.01 {
finalAmount = 0.01
if referrerID != nil {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}
if err := json.Unmarshal(cfg.ConfigValue, &config); err == nil {
if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 {
discountRate := userDiscount / 100
finalAmount = finalAmount * (1 - discountRate)
if finalAmount < 0.01 {
finalAmount = 0.01
}
}
}
}
@@ -455,26 +523,12 @@ func miniprogramPayPost(c *gin.Context) {
}
if req.Amount-finalAmount > 0.05 || finalAmount-req.Amount > 0.05 {
fmt.Printf("[PayCreate] 金额差异: 客户端=%.2f 后端=%.2f productType=%s productId=%s userId=%s\n",
req.Amount, finalAmount, req.ProductType, req.ProductID, req.UserID)
req.Amount, finalAmount, req.ProductType, pricePID, req.UserID)
}
orderSn = wechat.GenerateOrderSn()
}
totalFee := int(finalAmount * 100) // 转为分
description := req.Description
if description == "" {
if req.ProductType == "balance_recharge" {
description = fmt.Sprintf("余额充值 ¥%.2f", finalAmount)
} else if req.ProductType == "fullbook" {
description = "《一场Soul的创业实验》全书"
} else if req.ProductType == "vip" {
description = "卡若创业派对VIP年度会员365天"
} else if req.ProductType == "match" {
description = "购买匹配次数"
} else {
description = fmt.Sprintf("章节购买-%s", req.ProductID)
}
}
// 获取客户端 IP
clientIP := c.ClientIP()
@@ -489,21 +543,64 @@ func miniprogramPayPost(c *gin.Context) {
if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil {
userID = u.ID
} else {
// 查不到用户:可能是未登录或软删除后未重新登录,避免用 openid 导致订单归属到旧账号
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请先登录后再支付"})
return
}
}
productID := req.ProductID
if productID == "" {
if req.ProductType != "balance_recharge" {
if productID == "" {
switch req.ProductType {
case "vip":
productID = "vip_annual"
case "match":
productID = "match"
case "link_karuo_tip":
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "打赏缺少礼物标识"})
return
default:
productID = "fullbook"
}
}
}
description := strings.TrimSpace(req.Description)
if description == "" {
switch req.ProductType {
case "balance_recharge":
description = fmt.Sprintf("余额充值 ¥%.2f", finalAmount)
case "fullbook":
description = "《一场Soul的创业实验》全书"
case "vip":
productID = "vip_annual"
description = "卡若创业派对VIP年度会员365天"
case "match":
productID = "match"
description = "购买匹配次数"
case "link_karuo_tip":
root := loadMpConfigRoot(db)
giftID := productID
if strings.HasPrefix(giftID, "live_mic|") {
parts := strings.Split(giftID, "|")
if len(parts) >= 2 {
giftID = strings.TrimSpace(parts[1])
}
} else {
parts := strings.Split(giftID, "|")
giftID = strings.TrimSpace(parts[0])
}
g, _ := findLinkKaruoGift(root, giftID)
name := "打赏"
if g != nil {
if n, ok := g["name"].(string); ok && strings.TrimSpace(n) != "" {
name = strings.TrimSpace(n)
}
}
if strings.Contains(productID, "live_mic|") {
description = "上麦礼遇 · " + name
} else {
description = "首页打赏 · " + name
}
default:
productID = "fullbook"
description = fmt.Sprintf("章节购买-%s", productID)
}
}
@@ -530,7 +627,29 @@ func miniprogramPayPost(c *gin.Context) {
}
}
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID)
type payAttach struct {
ProductType string `json:"pt"`
ProductID string `json:"pid,omitempty"`
UserID string `json:"uid,omitempty"`
TipSource string `json:"ts,omitempty"`
}
attachBytes, _ := json.Marshal(payAttach{
ProductType: req.ProductType,
ProductID: productID,
UserID: userID,
TipSource: strings.TrimSpace(req.TipSource),
})
attach := string(attachBytes)
if len(attach) > 128 {
shortAttach, _ := json.Marshal(payAttach{
ProductType: req.ProductType,
UserID: userID,
})
attach = string(shortAttach)
if len(attach) > 128 {
attach = fmt.Sprintf(`{"pt":"%s"}`, req.ProductType)
}
}
ctx := c.Request.Context()
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, description, attach)
if err != nil {
@@ -623,11 +742,13 @@ func MiniprogramPayNotify(c *gin.Context) {
ProductType string `json:"productType"`
ProductID string `json:"productId"`
UserID string `json:"userId"`
TipSource string `json:"tipSource"`
GiftPayRequestSn string `json:"giftPayRequestSn"`
GiftPayInitiatorPay bool `json:"giftPayInitiatorPay"`
PT string `json:"pt"`
PID string `json:"pid"`
UID string `json:"uid"`
TS string `json:"ts"`
SN string `json:"sn"`
IP int `json:"ip"`
}
@@ -646,6 +767,9 @@ func MiniprogramPayNotify(c *gin.Context) {
if attach.UserID == "" {
attach.UserID = attach.UID
}
if attach.TipSource == "" {
attach.TipSource = attach.TS
}
if attach.GiftPayRequestSn == "" {
attach.GiftPayRequestSn = attach.SN
}
@@ -787,6 +911,8 @@ func MiniprogramPayNotify(c *gin.Context) {
} else {
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", beneficiaryUserID, attach.ProductID)
}
} else if attach.ProductType == "link_karuo_tip" {
fmt.Printf("[PayNotify] 打赏/上麦礼遇订单完成: user=%s order=%s\n", beneficiaryUserID, orderSn)
}
productID := attach.ProductID
if productID == "" {
@@ -824,6 +950,10 @@ func MiniprogramPayNotify(c *gin.Context) {
// 处理分销佣金(会员订单 20%/10%,内容订单 90%
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string, order *model.Order) {
if order != nil && order.ProductType == "link_karuo_tip" {
fmt.Printf("[PayNotify] 打赏订单跳过分销佣金: orderSn=%s\n", orderSn)
return
}
type Binding struct {
ID int `gorm:"column:id"`
ReferrerID string `gorm:"column:referrer_id"`
@@ -1203,13 +1333,241 @@ func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) {
}
case "balance_recharge":
ConfirmBalanceRechargeByOrder(db, order)
case "link_karuo_tip":
// 首页打赏 / 上麦礼遇:仅收款,无额外会员或章节权益
}
}
// loadMpConfigRoot 读取 system_config.mp_config 原始 JSON用于礼物价、上麦 Webhook 等)
func loadMpConfigRoot(db *gorm.DB) map[string]interface{} {
var row model.SystemConfig
if err := db.Where("config_key = ?", "mp_config").First(&row).Error; err != nil {
return nil
}
var m map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &m); err != nil {
return nil
}
return m
}
func parseMoneyAny(v interface{}) float64 {
if v == nil {
return 0
}
switch t := v.(type) {
case float64:
return t
case int:
return float64(t)
case int64:
return float64(t)
case json.Number:
f, _ := t.Float64()
return f
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
if err == nil {
return f
}
}
return 0
}
func defaultLinkKaruoGifts() []map[string]interface{} {
return []map[string]interface{}{
{"id": "gift_star", "name": "人气票", "price": 99, "emoji": "🎫", "icon": "", "micOrder": 0, "iconScale": 1, "micFeatured": false},
{"id": "gift_lite", "name": "小心心", "price": 9.9, "emoji": "❤️", "icon": "", "micOrder": 1, "iconScale": 1, "micFeatured": true},
{"id": "gift_super", "name": "嘉年华", "price": 1980, "emoji": "🎡", "icon": "", "micOrder": 2, "iconScale": 1, "micFeatured": false},
}
}
func linkKaruoRewardSection(root map[string]interface{}) map[string]interface{} {
if root == nil {
return nil
}
mpUi, _ := root["mpUi"].(map[string]interface{})
if mpUi == nil {
return nil
}
home, _ := mpUi["homePage"].(map[string]interface{})
if home == nil {
return nil
}
rw, _ := home["linkKaruoReward"].(map[string]interface{})
return rw
}
func linkKaruoGiftsList(root map[string]interface{}) []map[string]interface{} {
sec := linkKaruoRewardSection(root)
if sec == nil {
return defaultLinkKaruoGifts()
}
raw, ok := sec["gifts"]
if !ok || raw == nil {
return defaultLinkKaruoGifts()
}
arr, ok := raw.([]interface{})
if !ok || len(arr) == 0 {
return defaultLinkKaruoGifts()
}
out := make([]map[string]interface{}, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
out = append(out, m)
}
if len(out) == 0 {
return defaultLinkKaruoGifts()
}
return out
}
func findLinkKaruoGift(root map[string]interface{}, giftID string) (map[string]interface{}, float64) {
giftID = strings.TrimSpace(giftID)
if giftID == "" {
return nil, 0
}
for _, g := range linkKaruoGiftsList(root) {
id, _ := g["id"].(string)
id = strings.TrimSpace(id)
if id == giftID || strings.EqualFold(id, giftID) {
price := parseMoneyAny(g["price"])
if price > 0 {
return g, price
}
break
}
}
return nil, 0
}
const linkKaruoAmountEpsilon = 0.051
// parseLinkKaruoGiftQty 解析 giftId|qty 或 live_mic|giftId|qty
func parseLinkKaruoGiftQty(productID string) (giftID string, qty int64, err error) {
s := strings.TrimSpace(productID)
parts := strings.Split(s, "|")
if len(parts) >= 2 && strings.TrimSpace(parts[0]) == "live_mic" {
parts = parts[1:]
}
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return "", 0, fmt.Errorf("打赏商品格式错误")
}
giftID = strings.TrimSpace(parts[0])
qty = 1
if len(parts) >= 2 {
q, e := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if e != nil || q < 1 || q > 99 {
return "", 0, fmt.Errorf("打赏数量无效")
}
qty = q
}
return giftID, qty, nil
}
// linkKaruoTipFallbackByAmount 客户端礼物 id 与库不一致时,按金额唯一匹配后台档位并返回规范 id
func linkKaruoTipFallbackByAmount(db *gorm.DB, productID string, clientAmount float64) (total float64, giftName string, canonicalID string, err error) {
if clientAmount <= 0 {
return 0, "", "", fmt.Errorf("支付金额无效")
}
root := loadMpConfigRoot(db)
wantID, qty, err := parseLinkKaruoGiftQty(productID)
if err != nil {
return 0, "", "", err
}
type hit struct {
id string
unit float64
name string
}
var hits []hit
for _, g := range linkKaruoGiftsList(root) {
id, _ := g["id"].(string)
id = strings.TrimSpace(id)
if id == "" {
continue
}
unit := parseMoneyAny(g["price"])
if unit <= 0 {
continue
}
t := unit * float64(qty)
if math.Abs(t-clientAmount) < linkKaruoAmountEpsilon {
nm, _ := g["name"].(string)
hits = append(hits, hit{id, unit, strings.TrimSpace(nm)})
}
}
if len(hits) == 0 {
return 0, "", "", fmt.Errorf("打赏金额与后台档位不一致,请同步 mp_config 礼物后重试")
}
if len(hits) == 1 {
h := hits[0]
name := h.name
if name == "" {
name = "礼物"
}
return h.unit * float64(qty), name, h.id, nil
}
for _, h := range hits {
if strings.EqualFold(h.id, wantID) {
name := h.name
if name == "" {
name = "礼物"
}
return h.unit * float64(qty), name, h.id, nil
}
}
return 0, "", "", fmt.Errorf("后台存在同价多档礼物,请为每档设置不同价格或唯一 id")
}
// linkKaruoTipPriceTotal 打赏标准价:普通 giftId|qty上麦 live_mic|giftId|qty
func linkKaruoTipPriceTotal(db *gorm.DB, productID string) (float64, error) {
productID = strings.TrimSpace(productID)
if productID == "" {
return 0, fmt.Errorf("打赏缺少礼物标识")
}
root := loadMpConfigRoot(db)
var giftID string
var qty int64 = 1
if strings.HasPrefix(productID, "live_mic|") {
parts := strings.Split(productID, "|")
if len(parts) < 2 {
return 0, fmt.Errorf("上麦礼物参数无效")
}
giftID = strings.TrimSpace(parts[1])
if len(parts) >= 3 {
if q, err := strconv.ParseInt(strings.TrimSpace(parts[2]), 10, 64); err == nil && q > 0 {
qty = q
}
}
} else {
parts := strings.Split(productID, "|")
giftID = strings.TrimSpace(parts[0])
if len(parts) >= 2 {
if q, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); err == nil && q > 0 {
qty = q
}
}
}
_, price := findLinkKaruoGift(root, giftID)
if price <= 0 {
return 0, fmt.Errorf("礼物不存在或未定价: %s", giftID)
}
return price * float64(qty), nil
}
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
// productType: fullbook / vip / section / match
// productId: 章节购买时为章节 ID
// productType: fullbook / vip / section / match / link_karuo_tip
// productId: 章节购买时为章节 ID;打赏为 giftId|qty 或 live_mic|giftId|qty
func getStandardPrice(db *gorm.DB, productType, productID string) (float64, error) {
productType = normalizePayProductType(productType)
// 打赏/上麦:显式分支 + 模糊识别双保险,避免误入 default 报「未知商品类型」
if productType == "link_karuo_tip" || isLinkKaruoTipProductType(productType) {
return linkKaruoTipPriceTotal(db, productID)
}
switch productType {
case "fullbook", "vip", "match":
// 从 system_config 读取

View File

@@ -0,0 +1,39 @@
package handler
import (
"net/http"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
)
// MiniprogramMySuperStats GET /api/miniprogram/my/super-stats?userId=
// 返回当前 VIP 用户的超级个体获客数和头像点击数(小程序「我的」页展示用)。
func MiniprogramMySuperStats(c *gin.Context) {
userID := strings.TrimSpace(c.Query("userId"))
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
return
}
db := database.DB()
clicks := batchSuperIndividualClicks(db, []string{userID})
leads := batchSuperIndividualLeads(db, []string{userID})
var person model.Person
hasPerson := db.Where("user_id = ?", userID).First(&person).Error == nil
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"clickCount": clicks[userID],
"leadCount": leads[userID],
"hasPerson": hasPerson,
"isPinned": hasPerson && person.IsPinned,
"personName": person.Name,
},
})
}

View File

@@ -34,6 +34,41 @@ func loadOrderWebhookURL(db *gorm.DB) string {
return ""
}
func loadLiveMicTipPaidWebhookURL(db *gorm.DB, order *model.Order) string {
if order == nil || order.ProductType != "link_karuo_tip" {
return ""
}
pid := ""
if order.ProductID != nil {
pid = strings.TrimSpace(*order.ProductID)
}
if !strings.HasPrefix(pid, "live_mic|") {
return ""
}
root := loadMpConfigRoot(db)
if root == nil {
return ""
}
mpUi, _ := root["mpUi"].(map[string]interface{})
if mpUi == nil {
return ""
}
home, _ := mpUi["homePage"].(map[string]interface{})
if home == nil {
return ""
}
sch, _ := home["liveMicSchedule"].(map[string]interface{})
if sch == nil {
return ""
}
u, _ := sch["paidWebhookUrl"].(string)
u = strings.TrimSpace(u)
if u != "" && strings.HasPrefix(u, "http") {
return u
}
return ""
}
func pushPaidOrderWebhook(db *gorm.DB, order *model.Order) error {
if order == nil || order.OrderSN == "" {
return fmt.Errorf("empty order")
@@ -41,7 +76,10 @@ func pushPaidOrderWebhook(db *gorm.DB, order *model.Order) error {
if order.WebhookPushStatus == "sent" {
return nil
}
webhookURL := loadOrderWebhookURL(db)
webhookURL := loadLiveMicTipPaidWebhookURL(db, order)
if webhookURL == "" {
webhookURL = loadOrderWebhookURL(db)
}
if webhookURL == "" {
return nil
}

View File

@@ -18,6 +18,9 @@ func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model
if order == nil || order.ReferrerID == nil || *order.ReferrerID == "" {
return 0
}
if order.ProductType == "link_karuo_tip" {
return 0
}
// 读取推广配置
distributorShare := 0.9
userDiscount := 0.0

View File

@@ -243,12 +243,9 @@ func UserCheckPurchased(c *gin.Context) {
return
}
if type_ == "section" && productId != "" {
// 章节:需要区分普通版/增值版
var ch model.Chapter
// 不加载 content避免大字段
_ = db.Select("id", "is_free", "price", "edition_standard", "edition_premium").Where("id = ?", productId).First(&ch).Error
_ = db.Select("id", "is_free", "price").Where("id = ?", productId).First(&ch).Error
// 免费章节:直接可读
if ch.ID != "" {
if (ch.IsFree != nil && *ch.IsFree) || (ch.Price != nil && *ch.Price == 0) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "free_section"}})
@@ -256,16 +253,10 @@ func UserCheckPurchased(c *gin.Context) {
}
}
isPremium := ch.ID != "" && ch.EditionPremium != nil && *ch.EditionPremium
// 默认普通版:未明确标记增值版时,按普通版处理
isStandard := !isPremium
// 普通版:买断可读;增值版:买断不包含
if isStandard {
if user.HasFullBook != nil && *user.HasFullBook {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
return
}
// 全书买断覆盖所有章节(不再区分增值版/普通版)
if user.HasFullBook != nil && *user.HasFullBook {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"isPurchased": true, "reason": "has_full_book"}})
return
}
var count int64
@@ -725,6 +716,8 @@ func userTrackActionLabelCN(action string) string {
return "页面浏览"
case "search":
return "搜索"
case "live_mic_request":
return "申请上麦"
default:
if action == "" {
return "行为"

View File

@@ -39,8 +39,9 @@ func loadSuperIndividualWebhookMap(db *gorm.DB) map[string]string {
return out
}
// batchSuperIndividualClicks 统计「点击头像」行为
// user_tracks 中 action=avatar_click(兼容历史 btn_click)且 target 前缀「链接头像_」
// batchSuperIndividualClicks 统计超级个体被点击次数
// 1. member-detail 页 avatar_click / btn_clicktarget 格式「链接头像_userId
// 2. 首页超级个体横滑 card_clicktarget 格式「超级个体_userId」
func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64 {
out := make(map[string]int64)
if len(userIDs) == 0 {
@@ -52,15 +53,22 @@ func batchSuperIndividualClicks(db *gorm.DB, userIDs []string) map[string]int64
}
var rows []row
_ = db.Raw(`
SELECT
SUBSTRING(target, 6) AS user_id,
COUNT(*) AS clicks
FROM user_tracks
WHERE action IN ('avatar_click', 'btn_click')
AND target LIKE '链接头像\_%'
AND SUBSTRING(target, 6) IN ?
GROUP BY user_id
`, userIDs).Scan(&rows)
SELECT user_id, SUM(clicks) AS clicks FROM (
SELECT SUBSTRING(target, 6) AS user_id, COUNT(*) AS clicks
FROM user_tracks
WHERE action IN ('avatar_click', 'btn_click')
AND target LIKE '链接头像\_%'
AND SUBSTRING(target, 6) IN ?
GROUP BY user_id
UNION ALL
SELECT SUBSTRING(target, 6) AS user_id, COUNT(*) AS clicks
FROM user_tracks
WHERE action = 'card_click'
AND target LIKE '超级个体\_%'
AND SUBSTRING(target, 6) IN ?
GROUP BY user_id
) t GROUP BY user_id
`, userIDs, userIDs).Scan(&rows)
for _, r := range rows {
if r.UserID != "" {
out[r.UserID] = r.Clicks
@@ -138,6 +146,18 @@ func DBVipMembersList(c *gin.Context) {
leadByUser := batchSuperIndividualLeads(db, ids)
webhookMap := loadSuperIndividualWebhookMap(db)
// 批量查 persons 关联token, isPinned, homeEntryConfig
personByUserID := map[string]*model.Person{}
if len(ids) > 0 {
var persons []model.Person
db.Where("user_id IN ?", ids).Find(&persons)
for i := range persons {
if persons[i].UserID != nil && *persons[i].UserID != "" {
personByUserID[*persons[i].UserID] = &persons[i]
}
}
}
list := make([]gin.H, 0, len(users))
for i := range users {
item := formatVipMember(db, &users[i], true)
@@ -145,6 +165,21 @@ func DBVipMembersList(c *gin.Context) {
item["clickCount"] = clickByUser[uid]
item["leadCount"] = leadByUser[uid]
item["webhookUrl"] = strings.TrimSpace(webhookMap[uid])
if p, ok := personByUserID[uid]; ok {
item["personId"] = p.PersonID
item["personToken"] = p.Token
item["isPinned"] = p.IsPinned
item["personName"] = p.Name
item["personSource"] = p.PersonSource
item["ckbPlanId"] = p.CkbPlanID
item["ckbApiKey"] = p.CkbApiKey
if p.HomeEntryConfig != nil && strings.TrimSpace(*p.HomeEntryConfig) != "" {
var hec interface{}
if json.Unmarshal([]byte(*p.HomeEntryConfig), &hec) == nil {
item["homeEntryConfig"] = hec
}
}
}
list = append(list, item)
}
@@ -246,3 +281,38 @@ func DBVipMemberWebhookSet(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// DBPersonHomeEntryConfigSave PUT /api/db/persons/home-entry-config
// 保存超级个体的首页入口配置(打赏弹窗 + 上麦时段),存入 persons.home_entry_config。
func DBPersonHomeEntryConfigSave(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
HomeEntryConfig interface{} `json:"homeEntryConfig"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
return
}
userID := strings.TrimSpace(body.UserID)
if userID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "userId 不能为空"})
return
}
db := database.DB()
var person model.Person
if err := db.Where("user_id = ?", userID).First(&person).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该用户尚未关联超级个体人物"})
return
}
configJSON, err := json.Marshal(body.HomeEntryConfig)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置序列化失败"})
return
}
s := string(configJSON)
if err := db.Model(&model.Person{}).Where("id = ?", person.ID).Update("home_entry_config", s).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -80,18 +80,17 @@ func WsMiniprogram(c *gin.Context) {
// 鉴权通过,开始处理心跳
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)
if client != nil {
client.Set(ctx, key, "1", ttl)
} else {
log.Printf("[WS] Redis 未启用,在线统计不可用;仍维持读循环并回复 pong避免小程序端秒断")
}
// 心跳读取循环
// 心跳读取循环Redis 不可用时仅不回写在线 key
for {
_, msg, err := conn.ReadMessage()
if err != nil {
@@ -101,8 +100,10 @@ func WsMiniprogram(c *gin.Context) {
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"})
if client != nil {
client.Set(ctx, key, "1", ttl)
}
_ = conn.WriteJSON(map[string]interface{}{"type": "pong"})
}
}
}

View File

@@ -17,7 +17,7 @@ type Person struct {
UserID *string `gorm:"column:user_id;size:50;uniqueIndex" json:"userId"`
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token文章/小程序传此值
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token文章/小程序传此值
Name string `gorm:"column:name;size:100" json:"name"`
Aliases string `gorm:"column:aliases;size:255;default:''" json:"aliases"` // 逗号分隔别名:用于 @ 自动匹配
Label string `gorm:"column:label;size:200" json:"label"`
@@ -27,17 +27,20 @@ type Person struct {
CkbPlanID int64 `gorm:"column:ckb_plan_id;default:0" json:"ckbPlanId"`
// 存客宝 API 获客配置缓存(与 PersonAddEditModal 对应)
Greeting string `gorm:"column:greeting;size:255;default:''" json:"greeting"`
Tips string `gorm:"column:tips;type:text" json:"tips"`
RemarkType string `gorm:"column:remark_type;size:50;default:''" json:"remarkType"`
RemarkFormat string `gorm:"column:remark_format;size:200;default:''" json:"remarkFormat"`
Greeting string `gorm:"column:greeting;size:255;default:''" json:"greeting"`
Tips string `gorm:"column:tips;type:text" json:"tips"`
RemarkType string `gorm:"column:remark_type;size:50;default:''" json:"remarkType"`
RemarkFormat string `gorm:"column:remark_format;size:200;default:''" json:"remarkFormat"`
AddFriendInterval int `gorm:"column:add_friend_interval;default:1" json:"addFriendInterval"`
StartTime string `gorm:"column:start_time;size:10;default:'09:00'" json:"startTime"`
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
StartTime string `gorm:"column:start_time;size:10;default:'09:00'" json:"startTime"`
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
// 置顶到小程序首页
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"`
// 首页入口配置(打赏弹窗 + 上麦时段等JSON 存储,各超级个体独立配置
HomeEntryConfig *string `gorm:"column:home_entry_config;type:text" json:"homeEntryConfig"`
// PersonSource 来源:空=后台手工添加vip_sync=超级个体自动同步(共用统一计划)
PersonSource string `gorm:"column:person_source;size:32;default:''" json:"personSource"`

View File

@@ -40,6 +40,7 @@ func Setup(cfg *config.Config) *gin.Engine {
uploadDir = "./uploads"
}
r.Static("/uploads", uploadDir)
r.Static("/static", "./static")
// H5 落地页:朋友圈分享链接,文章预览 + 打开小程序按钮
r.GET("/read/:id", handler.H5ReadPage)
@@ -122,10 +123,12 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/ckb/plan-check", handler.AdminCKBPlanCheck)
admin.GET("/mbti-avatars", handler.AdminMbtiAvatarsGet)
admin.POST("/mbti-avatars", handler.AdminMbtiAvatarsPost)
admin.POST("/mbti-avatars/batch-assign", handler.AdminMbtiAvatarsBatchAssign)
admin.GET("/open-platform/keys", handler.AdminOpenPlatformKeysList)
admin.POST("/open-platform/keys", handler.AdminOpenPlatformKeysCreate)
admin.POST("/open-platform/keys/:id/revoke", handler.AdminOpenPlatformKeyRevoke)
admin.GET("/open-platform/logs", handler.AdminOpenPlatformLogsList)
admin.GET("/dashboard/leads", handler.AdminDashboardLeads)
}
// ----- 开放平台API KeyAuthorization Bearer op_sk_... 或 X-API-Key-----
@@ -210,6 +213,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.DELETE("/users", handler.DBUsersDelete)
db.GET("/users/referrals", handler.DBUsersReferrals)
db.GET("/users/rfm", handler.DBUsersRFM)
db.GET("/users/rfm-single", handler.DBUserRFMSingle)
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
db.GET("/users/journey-users", handler.DBUsersJourneyUsers)
db.GET("/users/tracks", handler.DBUserTracksList)
@@ -219,6 +223,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/vip-members", handler.DBVipMembersList)
db.PUT("/vip-members/webhook", handler.DBVipMemberWebhookSet)
db.PUT("/persons/home-entry-config", handler.DBPersonHomeEntryConfigSave)
db.GET("/match-records", handler.DBMatchRecordsList)
db.GET("/match-pool-counts", handler.DBMatchPoolCounts)
db.GET("/mentors", handler.DBMentorsList)
@@ -418,6 +423,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.POST("/gift-pay/redeem", handler.GiftPayRedeem)
miniprogram.POST("/gift-pay/cancel", handler.GiftPayCancel)
miniprogram.GET("/gift-pay/my-requests", handler.GiftPayMyRequests)
miniprogram.GET("/my/super-stats", handler.MiniprogramMySuperStats)
}
// ----- 提现 -----

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
@@ -310,6 +311,11 @@ func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int,
detail = fmt.Sprintf("res=%+v", res)
}
}
oidHint := openID
if len(oidHint) > 10 {
oidHint = oidHint[:6] + "…"
}
log.Printf("[WechatPay] JSAPI prepay_id 空 outTradeNo=%s openId=%s amountFen=%d %s", orderSn, oidHint, amountCents, detail)
return "", fmt.Errorf("微信返回 prepay_id 为空 (%s)", detail)
}
return res.PrepayID, nil

View File

@@ -10,13 +10,15 @@ soulApisoul-api 后端Go 项目一键部署到宝塔(正式环境)
宝塔 API 重启(可选):在环境变量或 .env 中设置
BT_PANEL_URL = https://你的面板地址:9988
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
BT_GO_PROJECT_NAME = soulApi (与宝塔 Go 项目列表里名称一致)
BT_GO_PROJECT_NAME = soulApi (与宝塔「网站」里 Go 站点名一致)
BT_GO_SITE_ID = 可选,网站 id不设则从 sites 表自动匹配 Go 站点)
并安装 requests: pip install requests
"""
from __future__ import print_function
import hashlib
import json
import os
import sys
import tempfile
@@ -121,7 +123,8 @@ def run_build(root):
# ==================== 打包 ====================
DEPLOY_PORT = 9090
# 正式环境 Nginx 一般反代 8080可用环境变量覆盖DEPLOY_API_PORT=9090
DEPLOY_PORT = int(os.environ.get("DEPLOY_API_PORT", "8080"))
def set_env_port(env_path, port=DEPLOY_PORT):
@@ -203,8 +206,109 @@ def pack_deploy(root, binary_path, include_env=True):
# ==================== 宝塔 API 重启 ====================
def _bt_signed_post(base_url, key, path, extra_data):
"""单次宝塔签名 POST每请求独立 request_time/token"""
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
data = {"request_time": req_time, "request_token": req_token}
data.update(extra_data or {})
return requests.post(base_url + path, data=data, timeout=20, verify=False)
def _bt_parse_json_response(r):
"""解析面板 JSON部分响应 Content-Type 不准)。"""
if r is None or r.status_code != 200:
return None
ct = (r.headers.get("content-type") or "").lower()
if "json" in ct:
try:
return r.json()
except Exception:
pass
t = (r.text or "").lstrip()
if t.startswith("{"):
try:
return json.loads(r.text)
except Exception:
pass
return None
def _bt_discover_go_site(base, key, want_name):
"""从「网站」列表匹配 project_type=Go 的站点(新版面板 go_project 插件表常为空)。"""
want = (want_name or "").strip()
want_l = want.lower()
if not want:
return None, None
def _scan_rows(rows):
for row in rows:
if not isinstance(row, dict):
continue
if row.get("project_type") != "Go":
continue
n = row.get("name") or row.get("ps") or ""
path = (row.get("path") or "").lower()
rid = row.get("id")
if rid is None:
continue
if n == want or (isinstance(n, str) and n.strip().lower() == want_l):
return str(rid), (n or want)
if "soul-api" in path and (want_l in path or want_l in (n or "").lower()):
return str(rid), (n or want)
return None, None
for search in (want, ""):
r = _bt_signed_post(
base,
key,
"/data?action=getData&table=sites",
{"p": "1", "limit": "500", "search": search, "type": "-1"},
)
j = _bt_parse_json_response(r)
rows = j.get("data") if isinstance(j, dict) else None
if isinstance(rows, list):
hit = _scan_rows(rows)
if hit[0]:
return hit
return None, None
def _bt_restart_go_via_site_api(base, key, site_id, site_name):
"""/site?action=SiteStop / SiteStart与面板「网站」一致"""
print(" [宝塔API] site SiteStop/SiteStart (id=%s) …" % site_id)
j = _bt_parse_json_response(
_bt_signed_post(
base,
key,
"/site?action=SiteStop",
{"id": str(site_id), "name": site_name},
)
)
if not isinstance(j, dict) or j.get("status") is not True:
if isinstance(j, dict) and j.get("msg"):
print(" [宝塔API] SiteStop: %s" % j.get("msg"))
return False
time.sleep(2)
j2 = _bt_parse_json_response(
_bt_signed_post(
base,
key,
"/site?action=SiteStart",
{"id": str(site_id), "name": site_name},
)
)
if isinstance(j2, dict) and j2.get("status") is True:
print(" [成功] 已通过宝塔 API 重启 Go 站点: %s" % site_name)
return True
if isinstance(j2, dict) and j2.get("msg"):
print(" [宝塔API] SiteStart: %s" % j2.get("msg"))
return False
def restart_via_bt_api(cfg):
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME"""
"""通过宝塔 API 重启:优先网站型 GoSiteStop/SiteStart失败再试 go_project 插件。"""
url = cfg.get("bt_panel_url") or ""
key = cfg.get("bt_api_key") or ""
name = cfg.get("bt_go_project_name", "soulApi")
@@ -214,42 +318,40 @@ def restart_via_bt_api(cfg):
print(" [提示] 未安装 requests无法使用宝塔 API将用 SSH 重启。pip install requests")
return False
try:
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
# 宝塔 Go 项目插件:先停止再启动,接口以实际面板版本为准
base = url.rstrip("/")
params = {"request_time": req_time, "request_token": req_token}
# 常见形式POST 带 action + 项目标识;部分版本只认 project_name部分只认 name故同时传
site_id_env = (os.environ.get("BT_GO_SITE_ID") or "").strip()
if site_id_env:
sid, snm = site_id_env, name
else:
sid, snm = _bt_discover_go_site(base, key, name)
if sid and _bt_restart_go_via_site_api(base, key, sid, snm or name):
return True
# 兜底go_project 插件(部分旧面板)
for action in ("stop_go_project", "start_go_project"):
data = dict(params)
data["action"] = action
data["project_name"] = name
data["name"] = name
r = requests.post(
base + "/plugin?name=go_project",
data=data,
timeout=15,
verify=False,
j = _bt_parse_json_response(
_bt_signed_post(
base,
key,
"/plugin?name=go_project",
{"action": action, "project_name": name, "name": name},
)
)
if r.status_code != 200:
continue
j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
if action == "stop_go_project":
time.sleep(2)
if j.get("status") is False and j.get("msg"):
if isinstance(j, dict) and j.get("status") is False and j.get("msg"):
print(" [宝塔API] %s: %s" % (action, j.get("msg", "")))
# 再调一次 start 确保启动
data = dict(params)
data["action"] = "start_go_project"
data["project_name"] = name
data["name"] = name
r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False)
if r.status_code == 200:
j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
if j.get("status") is True:
print(" [成功] 已通过宝塔 API 重启 Go 项目: %s" % name)
return True
j = _bt_parse_json_response(
_bt_signed_post(
base,
key,
"/plugin?name=go_project",
{"action": "start_go_project", "project_name": name, "name": name},
)
)
if isinstance(j, dict) and j.get("status") is True:
print(" [成功] 已通过宝塔 API 重启 Go 项目(插件): %s" % name)
return True
return False
except Exception as e:
print(" [宝塔API 失败] %s" % str(e))
@@ -374,6 +476,9 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
print(" [成功] soulApi 已通过 SSH 重启")
else:
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
if restart_method == "btapi" and not ok:
print(" [失败] 已指定 --restart-method btapi但宝塔 API 重启未成功(请核对 API 白名单含本机出口 IP、BT_GO_PROJECT_NAME/BT_GO_SITE_ID")
return False
else:
print("[4/4] 跳过重启 (--no-restart)")

View File

@@ -1,15 +1,31 @@
# soul-api 文档索引
> 相关文档已统一移至 **开发文档**
> 详细文档位于仓库 **[开发文档](../开发文档/)**。以下为常用入口(路径相对 `soul-api/`
- [8、部署/部署总览](../开发文档/8、部署/部署总览.md) — 部署入口
- [8、部署/DOCKER部署说明](../开发文档/8、部署/DOCKER部署说明.md)
- [8、部署/宝塔-Docker首次配置指南](../开发文档/8、部署/宝塔-Docker首次配置指南.md)
- [8、部署/宝塔反向代理说明](../开发文档/8、部署/宝塔反向代理说明.md)
- [8、部署/提现功能完整技术文档](../开发文档/8、部署/提现功能完整技术文档.md)
- [8、部署/订阅消息](../开发文档/8、部署/订阅消息.md)
- [8、部署/商家转账](../开发文档/8、部署/商家转账.md)
- [6、后端/管理端鉴权设计](../开发文档/6、后端/管理端鉴权设计.md)
- [6、后端/miniprogram接口补全说明](../开发文档/6、后端/miniprogram接口补全说明.md)
## 部署与运维
详见 [开发文档索引](../开发文档/索引.md)。
- [8、部署/部署总览.md](../开发文档/8、部署/部署总览.md) — 部署导航(推荐)
- [8、部署/运行与部署.md](../开发文档/8、部署/运行与部署.md)
- [8、部署/DOCKER部署说明.md](../开发文档/8、部署/DOCKER部署说明.md)
- [8、部署/宝塔-Docker首次配置指南.md](../开发文档/8、部署/宝塔-Docker首次配置指南.md)
- [8、部署/自动化与Webhook.md](../开发文档/8、部署/自动化与Webhook.md)
## 后端规范与架构
- [6、后端/后端开发规范.md](../开发文档/6、后端/后端开发规范.md)
- [6、后端/后端架构.md](../开发文档/6、后端/后端架构.md)
## 算法与业务规则(代码对齐)
- [6、后端/算法/README.md](../开发文档/6、后端/算法/README.md) — RFM、用户旅程、找伙伴匹配、存客宝留资
## 接口与数据库
- [5、接口/API接口完整文档.md](../开发文档/5、接口/API接口完整文档.md)
- [5、接口/接口与提现.md](../开发文档/5、接口/接口与提现.md)
- [7、数据库/数据库设计.md](../开发文档/7、数据库/数据库设计.md)
## 总索引
- [开发文档/README.md](../开发文档/README.md)
- [开发文档/索引.md](../开发文档/索引.md)