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

@@ -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 读取