feat: 同步本地三端改动并清理上传凭证风险
整合小程序、管理端与后端的最新本地改动,补齐用户管理与首页入口相关能力;提交前已完成敏感信息扫描,并移除本地 gitea 远程 URL 中的明文凭证,避免隐私信息进入远程仓库。 Made-with: Cursor
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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": "批量分配完成"})
|
||||
}
|
||||
|
||||
@@ -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(®7)
|
||||
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
|
||||
|
||||
@@ -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 != '')")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 读取
|
||||
|
||||
39
soul-api/internal/handler/miniprogram_super_stats.go
Normal file
39
soul-api/internal/handler/miniprogram_super_stats.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "行为"
|
||||
|
||||
@@ -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_click,target 格式「链接头像_userId」
|
||||
// 2. 首页超级个体横滑 card_click,target 格式「超级个体_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})
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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 Key:Authorization 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)
|
||||
}
|
||||
|
||||
// ----- 提现 -----
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,13 +10,15 @@ soulApi(soul-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 重启:优先网站型 Go(SiteStop/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)")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user