package handler import ( "encoding/json" "fmt" "io" "log" "math" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "soul-api/internal/database" "soul-api/internal/model" "soul-api/internal/wechat" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) var ( orderPollLogger *log.Logger 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() { _ = os.MkdirAll("log", 0755) f, err := os.OpenFile(filepath.Join("log", "order-poll.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { orderPollLogger = log.New(io.Discard, "", 0) return } orderPollLogger = log.New(f, "[OrderPoll] ", log.Ldate|log.Ltime) }) if orderPollLogger != nil { orderPollLogger.Printf(format, args...) } } // MiniprogramLogin POST /api/miniprogram/login func MiniprogramLogin(c *gin.Context) { var req struct { Code string `json:"code" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少登录code"}) return } // 调用微信接口获取 openid 和 session_key openID, sessionKey, _, err := wechat.Code2Session(req.Code) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)}) return } db := database.DB() // 查询用户是否存在 var user model.User result := db.Where("open_id = ?", openID).First(&user) isNewUser := result.Error != nil if isNewUser { // 创建新用户(含软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突) userID := "user_" + randomSuffix() referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:]) nickname := "微信用户" + openID[len(openID)-4:] avatar := "" hasFullBook := false earnings := 0.0 pendingEarnings := 0.0 referralCount := 0 purchasedSections := "[]" user = model.User{ ID: userID, OpenID: &openID, SessionKey: &sessionKey, Nickname: &nickname, Avatar: &avatar, ReferralCode: &referralCode, HasFullBook: &hasFullBook, PurchasedSections: &purchasedSections, Earnings: &earnings, PendingEarnings: &pendingEarnings, ReferralCount: &referralCount, } if err := db.Create(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"}) return } // 新用户:异步调用神射手自动打标(手机号尚未绑定,phone 为空时暂不调用) AdminShensheShouAutoTag(userID, "") } else { // 更新 session_key db.Model(&user).Update("session_key", sessionKey) } // 从 orders 表查询真实购买记录 var purchasedSections []string var orderRows []struct { ProductID string `gorm:"column:product_id"` } db.Raw(` SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section' `, user.ID).Scan(&orderRows) for _, row := range orderRows { if row.ProductID != "" { purchasedSections = append(purchasedSections, row.ProductID) } } if purchasedSections == nil { purchasedSections = []string{} } // 构建返回的用户对象 responseUser := map[string]interface{}{ "id": user.ID, "openId": getStringValue(user.OpenID), "nickname": getStringValue(user.Nickname), "avatar": resolveAvatarURLWithRequest(c, getStringValue(user.Avatar)), "phone": getStringValue(user.Phone), "wechatId": getStringValue(user.WechatID), "referralCode": getStringValue(user.ReferralCode), "hasFullBook": getBoolValue(user.HasFullBook), "purchasedSections": purchasedSections, "earnings": getFloatValue(user.Earnings), "pendingEarnings": getFloatValue(user.PendingEarnings), "referralCount": getIntValue(user.ReferralCount), "createdAt": user.CreatedAt, } // 生成 token token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix()) c.JSON(http.StatusOK, gin.H{ "success": true, "data": map[string]interface{}{ "openId": openID, "user": responseUser, "token": token, }, "isNewUser": isNewUser, }) } // MiniprogramDevLoginAs POST /api/miniprogram/dev/login-as 开发专用:按 userId 切换账号(仅 APP_ENV=development 可用) func MiniprogramDevLoginAs(c *gin.Context) { if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) != "development" { c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "仅开发环境可用"}) return } var req struct { UserID string `json:"userId" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"}) return } userID := strings.TrimSpace(req.UserID) if userID == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "userId 不能为空"}) return } db := database.DB() var user model.User if err := db.Where("id = ?", userID).First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "用户不存在"}) return } openID := getStringValue(user.OpenID) if openID == "" { openID = user.ID // 部分用户 id 即 openId } tokenSuffix := openID if len(openID) >= 8 { tokenSuffix = openID[len(openID)-8:] } token := fmt.Sprintf("tk_%s_%d", tokenSuffix, time.Now().Unix()) var purchasedSections []string var orderRows []struct { ProductID string `gorm:"column:product_id"` } db.Raw(`SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'`, user.ID).Scan(&orderRows) for _, row := range orderRows { if row.ProductID != "" { purchasedSections = append(purchasedSections, row.ProductID) } } if purchasedSections == nil { purchasedSections = []string{} } responseUser := map[string]interface{}{ "id": user.ID, "openId": openID, "nickname": getStringValue(user.Nickname), "avatar": resolveAvatarURLWithRequest(c, getStringValue(user.Avatar)), "phone": getStringValue(user.Phone), "wechatId": getStringValue(user.WechatID), "referralCode": getStringValue(user.ReferralCode), "hasFullBook": getBoolValue(user.HasFullBook), "purchasedSections": purchasedSections, "earnings": getFloatValue(user.Earnings), "pendingEarnings": getFloatValue(user.PendingEarnings), "referralCount": getIntValue(user.ReferralCount), "createdAt": user.CreatedAt, } if user.IsVip != nil { responseUser["isVip"] = *user.IsVip } if user.VipExpireDate != nil { responseUser["vipExpireDate"] = user.VipExpireDate.Format("2006-01-02") } c.JSON(http.StatusOK, gin.H{ "success": true, "data": map[string]interface{}{ "openId": openID, "user": responseUser, "token": token, }, }) } // saveH5UserPasswordHash 用原生 SQL 更新 users.password_hash,避免 GORM Model+Updates 在部分库/版本下更新失败。 func saveH5UserPasswordHash(db *gorm.DB, userID, bcryptHash string) error { userID = strings.TrimSpace(userID) if userID == "" { return fmt.Errorf("empty user id") } at := time.Now() r := db.Exec("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", bcryptHash, at, userID) if r.Error != nil { return r.Error } if r.RowsAffected < 1 { return fmt.Errorf("no rows updated for id=%s", userID) } return nil } // miniprogramPhoneLoginAllowed 开发环境 或 显式开启 H5 手机号登录 时允许(按库内已绑定手机匹配用户,与小程序微信登录为同一套 users 表) func miniprogramPhoneLoginAllowed() bool { if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) == "development" { return true } // 本地常未设 APP_ENV;Gin 默认 GIN_MODE=debug,与 release 生产区分,避免联调 H5 误 403 if strings.ToLower(strings.TrimSpace(os.Getenv("GIN_MODE"))) == "debug" { return true } v := strings.TrimSpace(os.Getenv("H5_PHONE_LOGIN_ENABLED")) if v == "" { return false } v = strings.ToLower(v) return v == "1" || v == "true" || v == "yes" || v == "on" } // MiniprogramDevLoginByPhone POST /api/miniprogram/dev/login-by-phone 与 POST /api/miniprogram/h5/login-by-phone // 使用手机号在库中查找已注册用户;须至少 6 位密码:password_hash 为空时写入 bcrypt,否则校验。 // 放行条件:APP_ENV=development,或 GIN_MODE=debug(本地默认),或 H5_PHONE_LOGIN_ENABLED=1;生产须 release + 显式开关。 func MiniprogramDevLoginByPhone(c *gin.Context) { if !miniprogramPhoneLoginAllowed() { c.JSON(http.StatusForbidden, gin.H{ "success": false, "error": "H5 手机号登录未开启:本机 soul-api 请使用 APP_ENV=development 或 GIN_MODE=debug;reactH5 请将 Vite 代理指向本机(默认 127.0.0.1:9100,见 vite.config)。若必须连线上 API,请在服务端设置 H5_PHONE_LOGIN_ENABLED=1", }) return } var req struct { Phone string `json:"phone" binding:"required"` Password string `json:"password"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少手机号"}) return } phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", "")) if phone == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "手机号不能为空"}) return } db := database.DB() var user model.User // 支持纯数字或带 +86 前缀 if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "该手机号未注册"}) return } plain := strings.TrimSpace(req.Password) storedHash := "" if user.PasswordHash != nil { storedHash = strings.TrimSpace(*user.PasswordHash) } if storedHash == "" { if len(plain) < 6 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "首次登录请设置至少6位密码"}) return } hp, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"}) return } hs := string(hp) if err := saveH5UserPasswordHash(db, user.ID, hs); err != nil { log.Printf("login-by-phone: save password_hash user=%s err=%v", user.ID, err) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存密码失败"}) return } } else { if plain == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入密码"}) return } if bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) != nil { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "手机号或密码错误"}) return } } openID := getStringValue(user.OpenID) if openID == "" { openID = user.ID } tokenSuffix := openID if len(openID) >= 8 { tokenSuffix = openID[len(openID)-8:] } token := fmt.Sprintf("tk_%s_%d", tokenSuffix, time.Now().Unix()) var purchasedSections []string var orderRows []struct { ProductID string `gorm:"column:product_id"` } db.Raw(`SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'`, user.ID).Scan(&orderRows) for _, row := range orderRows { if row.ProductID != "" { purchasedSections = append(purchasedSections, row.ProductID) } } if purchasedSections == nil { purchasedSections = []string{} } responseUser := map[string]interface{}{ "id": user.ID, "openId": openID, "nickname": getStringValue(user.Nickname), "avatar": resolveAvatarURLWithRequest(c, getStringValue(user.Avatar)), "phone": getStringValue(user.Phone), "wechatId": getStringValue(user.WechatID), "referralCode": getStringValue(user.ReferralCode), "hasFullBook": getBoolValue(user.HasFullBook), "purchasedSections": purchasedSections, "earnings": getFloatValue(user.Earnings), "pendingEarnings": getFloatValue(user.PendingEarnings), "referralCount": getIntValue(user.ReferralCount), "createdAt": user.CreatedAt, } if user.IsVip != nil { responseUser["isVip"] = *user.IsVip } if user.VipExpireDate != nil { responseUser["vipExpireDate"] = user.VipExpireDate.Format("2006-01-02") } c.JSON(http.StatusOK, gin.H{ "success": true, "data": map[string]interface{}{ "openId": openID, "user": responseUser, "token": token, }, }) } var h5PhoneRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`) // MiniprogramH5RegisterByPhone POST /api/miniprogram/h5/register-by-phone // 与 H5 登录同开关:仅允许未占用手机号的纯 H5 账号(无微信 open_id);注册成功后返回与 login-by-phone 一致的 data。 func MiniprogramH5RegisterByPhone(c *gin.Context) { if !miniprogramPhoneLoginAllowed() { c.JSON(http.StatusForbidden, gin.H{ "success": false, "error": "H5 手机号注册未开启:与本机 h5/login-by-phone 403 说明相同,请开启 development / GIN_MODE=debug / H5_PHONE_LOGIN_ENABLED", }) return } var req struct { Phone string `json:"phone" binding:"required"` Password string `json:"password" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"}) return } phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", "")) if !h5PhoneRegexp.MatchString(phone) { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入有效的大陆11位手机号"}) return } pwd := strings.TrimSpace(req.Password) if len(pwd) < 6 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码至少6位"}) return } db := database.DB() var existed model.User if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone). First(&existed).Error; err == nil { c.JSON(http.StatusConflict, gin.H{"success": false, "error": "该手机号已注册,请直接登录"}) return } hp, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"}) return } hs := string(hp) nickname := fmt.Sprintf("用户%s", phone[len(phone)-4:]) avatar := "" hasFullBook := false earnings := 0.0 pendingEarnings := 0.0 referralCount := 0 purchasedJSON := "[]" var createdUser model.User created := false for attempt := 0; attempt < 12; attempt++ { userID := "user_" + randomSuffix() refBase := strings.ToUpper(randomSuffix()) if len(refBase) < 6 { refBase = refBase + strings.ToUpper(randomSuffix()) } rc := "SOUL" + refBase rc = strings.TrimSpace(rc) if len(rc) > 20 { rc = rc[:20] } var dupRC model.User if db.Where("referral_code = ?", rc).First(&dupRC).Error == nil { continue } ph := phone u := model.User{ ID: userID, Phone: &ph, PasswordHash: &hs, Nickname: &nickname, Avatar: &avatar, ReferralCode: &rc, HasFullBook: &hasFullBook, PurchasedSections: &purchasedJSON, Earnings: &earnings, PendingEarnings: &pendingEarnings, ReferralCount: &referralCount, } if err := db.Create(&u).Error; err != nil { if strings.Contains(strings.ToLower(err.Error()), "duplicate") { continue } c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "注册失败,请稍后重试"}) return } createdUser = u created = true break } if !created { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "注册繁忙,请稍后重试"}) return } AdminShensheShouAutoTag(createdUser.ID, phone) // 对齐 login-by-phone 的 token 与用户 payload openID := getStringValue(createdUser.OpenID) if openID == "" { openID = createdUser.ID } tokenSuffix := openID if len(openID) >= 8 { tokenSuffix = openID[len(openID)-8:] } token := fmt.Sprintf("tk_%s_%d", tokenSuffix, time.Now().Unix()) var purchasedSections []string var orderRows []struct { ProductID string `gorm:"column:product_id"` } db.Raw(`SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'`, createdUser.ID).Scan(&orderRows) for _, row := range orderRows { if row.ProductID != "" { purchasedSections = append(purchasedSections, row.ProductID) } } if purchasedSections == nil { purchasedSections = []string{} } responseUser := map[string]interface{}{ "id": createdUser.ID, "openId": openID, "nickname": nickname, "avatar": resolveAvatarURLWithRequest(c, avatar), "phone": phone, "wechatId": "", "referralCode": getStringValue(createdUser.ReferralCode), "hasFullBook": false, "purchasedSections": purchasedSections, "earnings": 0.0, "pendingEarnings": 0.0, "referralCount": 0, "createdAt": createdUser.CreatedAt, } c.JSON(http.StatusOK, gin.H{ "success": true, "data": map[string]interface{}{ "openId": openID, "user": responseUser, "token": token, }, }) } // MiniprogramH5ResetPassword POST /api/miniprogram/h5/reset-password // 与 H5 手机号登录同开关;无短信/邮箱验码,依赖网关限流与 H5_PHONE_LOGIN_ENABLED 管控。 func MiniprogramH5ResetPassword(c *gin.Context) { if !miniprogramPhoneLoginAllowed() { c.JSON(http.StatusForbidden, gin.H{ "success": false, "error": "H5 密码重置与登录共用开关,请见 h5/login-by-phone 403 说明或设置 H5_PHONE_LOGIN_ENABLED=1", }) return } var req struct { Phone string `json:"phone" binding:"required"` NewPassword string `json:"newPassword" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"}) return } phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", "")) pwd := strings.TrimSpace(req.NewPassword) if phone == "" || len(pwd) < 6 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入手机号及至少6位新密码"}) return } db := database.DB() var user model.User if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).First(&user).Error; err == nil { hp, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"}) return } hashStr := string(hp) if err := saveH5UserPasswordHash(db, user.ID, hashStr); err != nil { log.Printf("MiniprogramH5ResetPassword: update password_hash user=%s err=%v", user.ID, err) h := gin.H{"success": false, "error": "更新失败"} if gin.Mode() == gin.DebugMode { h["detail"] = err.Error() } c.JSON(http.StatusInternalServerError, h) return } } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "若该手机号已注册,已更新登录密码,请使用新密码登录", }) } // 辅助函数 func getStringValue(ptr *string) string { if ptr == nil { return "" } return *ptr } func getUrlValue(ptr *string) string { if ptr == nil { return "" } return *ptr } func getBoolValue(ptr *bool) bool { if ptr == nil { return false } return *ptr } func getFloatValue(ptr *float64) float64 { if ptr == nil { return 0.0 } return *ptr } func getIntValue(ptr *int) int { if ptr == nil { return 0 } return *ptr } // MiniprogramPay GET/POST /api/miniprogram/pay func MiniprogramPay(c *gin.Context) { if c.Request.Method == "POST" { miniprogramPayPost(c) } else { miniprogramPayGet(c) } } // POST - 创建小程序支付订单 func miniprogramPayPost(c *gin.Context) { var req struct { OpenID string `json:"openId" binding:"required"` ProductType string `json:"productType" binding:"required"` ProductID string `json:"productId"` Amount float64 `json:"amount" binding:"required"` Description string `json:"description"` UserID string `json:"userId"` ReferralCode string `json:"referralCode"` TipSource string `json:"tipSource"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少openId参数,请先登录"}) 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 } db := database.DB() // 尽早解析 userId,便于 mentor_consultation 等按用户校验预约单与标准价 userIDForPrice := strings.TrimSpace(req.UserID) if userIDForPrice == "" && strings.TrimSpace(req.OpenID) != "" { var u model.User if err := db.Where("open_id = ?", strings.TrimSpace(req.OpenID)).First(&u).Error; err == nil { userIDForPrice = u.ID } } 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 // 非充值单记录标准价:若最终发现自推,需撤销误用的好友折扣 var standardPriceSnapshot float64 if req.ProductType == "balance_recharge" { // 充值:从已创建的订单取金额,productId=orderSn var existOrder model.Order if err := db.Where("order_sn = ? AND product_type = ? AND status = ?", req.ProductID, "balance_recharge", "created").First(&existOrder).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "充值订单不存在或已支付"}) return } orderSn = existOrder.OrderSN finalAmount = existOrder.Amount if req.UserID != "" && existOrder.UserID != req.UserID { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "订单用户不匹配"}) return } } else { // -------- V1.1 后端价格:从 DB 读取标准价 -------- pricePID := productID if pricePID == "" { pricePID = strings.TrimSpace(req.ProductID) } standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID, userIDForPrice, req.TipSource) if priceErr != nil && req.ProductType == "link_karuo_tip" { if fb, _, canonID, ferr := linkKaruoTipFallbackByAmount(db, pricePID, req.Amount, req.TipSource); 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 standardPriceSnapshot = standardPrice // 打赏不参与分销好友折扣(实付=标准价) 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 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 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, pricePID, req.UserID) } orderSn = wechat.GenerateOrderSn() } // 获取客户端 IP clientIP := c.ClientIP() if clientIP == "" { clientIP = "127.0.0.1" } // userID:优先用客户端传入;为空时按 openid 查用户(排除软删除,避免订单归属到旧账号) userID := strings.TrimSpace(req.UserID) if userID == "" && userIDForPrice != "" { userID = userIDForPrice } if userID == "" && req.OpenID != "" { var u model.User if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil { userID = u.ID } else { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请先登录后再支付"}) return } } // 自购自推:不写 referrer、不按好友价(兼容脏绑定或误传自己的推荐码;userID 以最终解析为准) if req.ProductType != "balance_recharge" && req.ProductType != "link_karuo_tip" && referrerID != nil && userID != "" && isSelfReferralBuyer(userID, *referrerID) { fmt.Printf("[MiniprogramPay] 自推无效: userId=%s 忽略 referrer\n", userID) referrerID = nil if standardPriceSnapshot > 0 { finalAmount = standardPriceSnapshot } } totalFee := int(finalAmount * 100) // 转为分(须在自推纠正 finalAmount 之后) 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 case "mentor_consultation": c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少预约单 ID(productId)"}) 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 = "加入读书会" case "vip": description = "卡若创业派对VIP年度会员(365天)" case "match": description = "购买匹配次数" case "mentor_consultation": description = "导师咨询服务" case "link_karuo_tip": 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, _ := findLinkKaruoGiftInList(mergedLinkKaruoGiftsForPay(db, req.TipSource), 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: description = fmt.Sprintf("章节购买-%s", productID) } } // 充值订单已存在,不重复创建 if req.ProductType != "balance_recharge" { status := "created" pm := "wechat" order := model.Order{ ID: orderSn, OrderSN: orderSn, UserID: userID, OpenID: req.OpenID, ProductType: req.ProductType, ProductID: &productID, Amount: finalAmount, Description: &description, Status: &status, ReferrerID: referrerID, ReferralCode: &req.ReferralCode, PaymentMethod: &pm, } if err := db.Create(&order).Error; err != nil { fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err) } } 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 { c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("微信支付请求失败: %v", err)}) return } payParams, err := wechat.GetJSAPIPayParams(prepayID) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("生成支付参数失败: %v", err)}) return } c.JSON(http.StatusOK, gin.H{ "success": true, "data": map[string]interface{}{ "orderSn": orderSn, "prepayId": prepayID, "payParams": payParams, }, }) } // GET - 查询订单状态(并主动同步:若微信已支付但本地未标记,则更新本地订单,便于配额即时生效) func miniprogramPayGet(c *gin.Context) { orderSn := c.Query("orderSn") if orderSn == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少订单号"}) return } ctx := c.Request.Context() tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, orderSn) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": true, "data": map[string]interface{}{ "status": "unknown", "orderSn": orderSn, }, }) return } status := "paying" switch tradeState { case "SUCCESS": status = "paid" // V1.3 修复:主动同步到本地 orders,并激活对应权益(VIP/全书),避免等待 PayNotify 延迟 db := database.DB() var order model.Order if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err == nil && order.Status != nil && *order.Status != "paid" { now := time.Now() db.Model(&order).Updates(map[string]interface{}{ "status": "paid", "transaction_id": transactionID, "pay_time": now, }) order.Status = strToPtr("paid") order.PayTime = &now orderPollLogf("主动同步订单已支付: %s", orderSn) // 激活权益 if order.UserID != "" { activateOrderBenefits(db, &order, now) } } case "CLOSED", "REVOKED", "PAYERROR": status = "failed" case "REFUND": status = "refunded" } c.JSON(http.StatusOK, gin.H{ "success": true, "data": map[string]interface{}{ "status": status, "orderSn": orderSn, "transactionId": transactionID, "totalFee": totalFee, }, }) } // MiniprogramPayNotify POST /api/miniprogram/pay/notify(v3 支付回调,PowerWeChat 验签解密) func MiniprogramPayNotify(c *gin.Context) { resp, err := wechat.HandlePayNotify(c.Request, func(orderSn, transactionID string, totalFee int, attachStr, openID string) error { totalAmount := float64(totalFee) / 100 fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount) var attach struct { 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"` } if attachStr != "" { _ = json.Unmarshal([]byte(attachStr), &attach) if attach.ProductType == "" { if attach.PT == "gpb" { attach.ProductType = "gift_pay_batch" } else { attach.ProductType = attach.PT } } if attach.ProductID == "" { attach.ProductID = attach.PID } if attach.UserID == "" { attach.UserID = attach.UID } if attach.TipSource == "" { attach.TipSource = attach.TS } if attach.GiftPayRequestSn == "" { attach.GiftPayRequestSn = attach.SN } if attach.IP != 0 { attach.GiftPayInitiatorPay = true } } db := database.DB() buyerUserID := attach.UserID if openID != "" { var user model.User if err := db.Where("open_id = ?", openID).First(&user).Error; err == nil { if attach.UserID != "" && user.ID != attach.UserID { fmt.Printf("[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准\n") } buyerUserID = user.ID } } if buyerUserID == "" && attach.UserID != "" { buyerUserID = attach.UserID } var order model.Order result := db.Where("order_sn = ?", orderSn).First(&order) if result.Error != nil { fmt.Printf("[PayNotify] 订单不存在,补记订单: %s\n", orderSn) productID := attach.ProductID if productID == "" { productID = "fullbook" } productType := attach.ProductType if productType == "" { productType = "unknown" } desc := "支付回调补记订单" status := "paid" now := time.Now() order = model.Order{ ID: orderSn, OrderSN: orderSn, UserID: buyerUserID, OpenID: openID, ProductType: productType, ProductID: &productID, Amount: totalAmount, Description: &desc, Status: &status, TransactionID: &transactionID, PayTime: &now, } if err := db.Create(&order).Error; err != nil { fmt.Printf("[PayNotify] 补记订单失败: %s, err=%v\n", orderSn, err) return fmt.Errorf("create order: %w", err) } } else if *order.Status != "paid" { status := "paid" now := time.Now() updates := map[string]interface{}{ "status": status, "transaction_id": transactionID, "pay_time": now, } if err := db.Model(&order).Updates(updates).Error; err != nil { fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err) return fmt.Errorf("update order: %w", err) } fmt.Printf("[PayNotify] 订单状态已更新为已支付: %s\n", orderSn) } else { fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn) } // 代付订单:更新 gift_pay_request、订单 payer_user_id // 权益归属与分佣:旧版好友付归发起人;新版发起人付不发放权益(好友领取时再发) giftPayRequestSn := attach.GiftPayRequestSn if giftPayRequestSn == "" && order.GiftPayRequestID != nil && *order.GiftPayRequestID != "" { var gpr model.GiftPayRequest if err := db.Where("id = ?", *order.GiftPayRequestID).Select("request_sn").First(&gpr).Error; err == nil { giftPayRequestSn = gpr.RequestSN } } beneficiaryUserID := buyerUserID if giftPayRequestSn != "" && order.UserID != "" && !attach.GiftPayInitiatorPay { beneficiaryUserID = order.UserID fmt.Printf("[PayNotify] 代付订单(好友付),权益归属发起人: %s\n", beneficiaryUserID) } if attach.GiftPayInitiatorPay { fmt.Printf("[PayNotify] 代付订单(发起人付),不发放权益,好友领取时再发\n") } if giftPayRequestSn != "" { var payerUserID string if openID != "" { var payer model.User if err := db.Where("open_id = ?", openID).First(&payer).Error; err == nil { payerUserID = payer.ID db.Model(&order).Update("payer_user_id", payerUserID) } } db.Model(&model.GiftPayRequest{}).Where("request_sn = ?", giftPayRequestSn). Updates(map[string]interface{}{ "status": "paid", "payer_user_id": payerUserID, "order_id": orderSn, "updated_at": time.Now(), }) } if beneficiaryUserID != "" && attach.ProductType != "" { if attach.ProductType == "fullbook" { db.Model(&model.User{}).Where("id = ?", beneficiaryUserID).Update("has_full_book", true) fmt.Printf("[PayNotify] 用户已购全书: %s\n", beneficiaryUserID) } else if attach.ProductType == "vip" { vipActivatedAt := time.Now() if order.PayTime != nil { vipActivatedAt = *order.PayTime } expireDate := activateVIP(db, beneficiaryUserID, 365, vipActivatedAt) fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", beneficiaryUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05")) // 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力) if err := ensurePersonForUser(db, beneficiaryUserID); err != nil { fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, orderSn=%s, err=%v\n", beneficiaryUserID, orderSn, err) } } else if attach.ProductType == "match" { fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", beneficiaryUserID, orderSn) } else if attach.ProductType == "balance_recharge" { if err := ConfirmBalanceRechargeByOrder(db, &order); err != nil { fmt.Printf("[PayNotify] 余额充值确认失败: %s, err=%v\n", orderSn, err) } else { fmt.Printf("[PayNotify] 余额充值成功: %s, 金额 %.2f\n", beneficiaryUserID, totalAmount) } } else if attach.ProductType == "section" && attach.ProductID != "" { var count int64 db.Model(&model.Order{}).Where( "user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid' AND order_sn != ?", beneficiaryUserID, attach.ProductID, orderSn, ).Count(&count) if count == 0 { fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", beneficiaryUserID, attach.ProductID) } 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) } else if attach.ProductType == "mentor_consultation" && attach.ProductID != "" { if cid, err := strconv.Atoi(strings.TrimSpace(attach.ProductID)); err == nil && cid > 0 { _ = db.Model(&model.MentorConsultation{}). Where("id = ? AND user_id = ?", cid, beneficiaryUserID). Updates(map[string]interface{}{"status": "paid", "updated_at": time.Now()}) fmt.Printf("[PayNotify] 导师预约已支付: user=%s consultId=%d order=%s\n", beneficiaryUserID, cid, orderSn) } } productID := attach.ProductID if productID == "" { productID = "fullbook" } db.Where( "user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?", beneficiaryUserID, attach.ProductType, productID, orderSn, ).Delete(&model.Order{}) processReferralCommission(db, beneficiaryUserID, totalAmount, orderSn, &order) } // 支付成功后实时推送到 webhook;失败记录,交给定时补偿任务统一重推 if pushErr := pushPaidOrderWebhook(db, &order); pushErr != nil { fmt.Printf("[PayNotify] webhook 推送失败: orderSn=%s, err=%v\n", orderSn, pushErr) markOrderWebhookResult(db, orderSn, false, pushErr) } else { markOrderWebhookResult(db, orderSn, true, nil) } return nil }) if err != nil { fmt.Printf("[PayNotify] 处理回调失败: %v\n", err) c.String(http.StatusOK, failResponse()) return } defer resp.Body.Close() for k, v := range resp.Header { if len(v) > 0 { c.Header(k, v[0]) } } c.Status(resp.StatusCode) io.Copy(c.Writer, resp.Body) } // 处理分销佣金(会员订单 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 } if order != nil && order.ProductType == "mentor_consultation" { fmt.Printf("[PayNotify] 导师咨询订单跳过分销佣金: orderSn=%s\n", orderSn) return } type Binding struct { ID string `gorm:"column:id"` ReferrerID string `gorm:"column:referrer_id"` ExpiryDate time.Time `gorm:"column:expiry_date"` PurchaseCount int `gorm:"column:purchase_count"` TotalCommission float64 `gorm:"column:total_commission"` } var binding Binding err := db.Raw(` SELECT id, referrer_id, expiry_date, purchase_count, total_commission FROM referral_bindings WHERE referee_id = ? AND status = 'active' ORDER BY binding_date DESC LIMIT 1 `, buyerUserID).Scan(&binding).Error if err != nil { fmt.Printf("[PayNotify] 用户无有效推广绑定,跳过分佣: %s\n", buyerUserID) return } if isSelfReferralBuyer(buyerUserID, binding.ReferrerID) { fmt.Printf("[PayNotify] 自购自推,跳过分佣: buyer=%s\n", buyerUserID) return } if time.Now().After(binding.ExpiryDate) { fmt.Printf("[PayNotify] 绑定已过期,跳过分佣: %s\n", buyerUserID) return } // 确保 order 有 referrer_id(补记订单可能缺失) if order != nil && (order.ReferrerID == nil || *order.ReferrerID == "") { order.ReferrerID = &binding.ReferrerID db.Model(order).Update("referrer_id", binding.ReferrerID) } // 构建用于计算的 order(若为 nil 则用 binding 信息) calcOrder := order if calcOrder == nil { calcOrder = &model.Order{Amount: amount, ProductType: "unknown", ReferrerID: &binding.ReferrerID} } commission := computeOrderCommission(db, calcOrder, nil) if commission <= 0 { fmt.Printf("[PayNotify] 佣金为 0,跳过分佣: orderSn=%s\n", orderSn) return } newPurchaseCount := binding.PurchaseCount + 1 newTotalCommission := binding.TotalCommission + commission fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f\n", binding.ReferrerID, amount, commission) db.Model(&model.User{}).Where("id = ?", binding.ReferrerID). Update("pending_earnings", db.Raw("pending_earnings + ?", commission)) db.Exec(` UPDATE referral_bindings SET last_purchase_date = NOW(), purchase_count = COALESCE(purchase_count, 0) + 1, total_commission = COALESCE(total_commission, 0) + ? WHERE id = ? `, commission, binding.ID) fmt.Printf("[PayNotify] 分佣完成: 推广者 %s 获得 %.2f 元(第 %d 次购买,累计 %.2f 元)\n", binding.ReferrerID, commission, newPurchaseCount, newTotalCommission) } // 微信支付回调响应 func successResponse() string { return `` } func failResponse() string { return `` } // MiniprogramPhone POST /api/miniprogram/phone func MiniprogramPhone(c *gin.Context) { var req struct { Code string `json:"code" binding:"required"` UserID string `json:"userId"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少code参数"}) return } // 获取手机号 phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.Code) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "message": "获取手机号失败", "error": err.Error(), }) return } // 如果提供了 userId,更新到数据库 if req.UserID != "" { db := database.DB() db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber) fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID) // 绑定手机号后,异步调用神射手自动完善标签 AdminShensheShouAutoTag(req.UserID, phoneNumber) } c.JSON(http.StatusOK, gin.H{ "success": true, "phoneNumber": phoneNumber, "countryCode": countryCode, }) } // MiniprogramQrcode POST /api/miniprogram/qrcode func MiniprogramQrcode(c *gin.Context) { var req struct { Scene string `json:"scene"` Page string `json:"page"` Width int `json:"width"` ChapterID string `json:"chapterId"` UserID string `json:"userId"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"}) return } // 构建 scene 参数 scene := req.Scene if scene == "" { var parts []string if req.UserID != "" { userId := req.UserID if len(userId) > 15 { userId = userId[:15] } parts = append(parts, fmt.Sprintf("ref=%s", userId)) } if req.ChapterID != "" { parts = append(parts, fmt.Sprintf("ch=%s", req.ChapterID)) } if len(parts) == 0 { scene = "soul" } else { scene = strings.Join(parts, "&") } } page := req.Page if page == "" { page = "pages/index/index" } width := req.Width if width == 0 { width = 280 } fmt.Printf("[MiniprogramQrcode] 生成小程序码, scene=%s\n", scene) // 生成小程序码 imageData, err := wechat.GenerateMiniProgramCode(scene, page, width) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, "error": fmt.Sprintf("生成小程序码失败: %v", err), }) return } // 转换为 base64 base64Image := fmt.Sprintf("data:image/png;base64,%s", base64Encode(imageData)) c.JSON(http.StatusOK, gin.H{ "success": true, "image": base64Image, "scene": scene, }) } // MiniprogramQrcodeImage GET /api/miniprogram/qrcode/image?scene=xxx&page=xxx&width=280 // 直接返回 image/png,供小程序 wx.downloadFile 使用,便于开发工具与真机统一用 tempFilePath 绘制 func MiniprogramQrcodeImage(c *gin.Context) { scene := c.Query("scene") if scene == "" { scene = "soul" } page := c.DefaultQuery("page", "pages/read/read") width, _ := strconv.Atoi(c.DefaultQuery("width", "280")) if width <= 0 { width = 280 } imageData, err := wechat.GenerateMiniProgramCode(scene, page, width) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "error": fmt.Sprintf("生成小程序码失败: %v", err), }) return } c.Header("Content-Type", "image/png") c.Data(http.StatusOK, "image/png", imageData) } // GiftLinkGet GET /api/miniprogram/gift/link 代付链接(需登录,传 userId) // 返回 path、ref、scene,供 gift-link 页展示与复制;qrcodeImageUrl 供生成小程序码 func GiftLinkGet(c *gin.Context) { userID := c.Query("userId") if userID == "" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId,请先登录"}) return } db := database.DB() var user model.User if err := db.Where("id = ?", userID).First(&user).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"}) return } c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) return } ref := getStringValue(user.ReferralCode) if ref == "" { suffix := userID if len(userID) >= 6 { suffix = userID[len(userID)-6:] } ref = "SOUL" + strings.ToUpper(suffix) } path := fmt.Sprintf("pages/gift-link/gift-link?ref=%s&gift=1", ref) scene := fmt.Sprintf("ref_%s_gift_1", strings.ReplaceAll(ref, "&", "_")) if len(scene) > 32 { scene = scene[:32] } c.JSON(http.StatusOK, gin.H{ "success": true, "path": path, "ref": ref, "scene": scene, }) } // base64 编码 func base64Encode(data []byte) string { const base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" var result strings.Builder for i := 0; i < len(data); i += 3 { b1, b2, b3 := data[i], byte(0), byte(0) if i+1 < len(data) { b2 = data[i+1] } if i+2 < len(data) { b3 = data[i+2] } result.WriteByte(base64Table[b1>>2]) result.WriteByte(base64Table[((b1&0x03)<<4)|(b2>>4)]) if i+1 < len(data) { result.WriteByte(base64Table[((b2&0x0F)<<2)|(b3>>6)]) } else { result.WriteByte('=') } if i+2 < len(data) { result.WriteByte(base64Table[b3&0x3F]) } else { result.WriteByte('=') } } return result.String() } // MiniprogramUsers GET /api/miniprogram/users 小程序-用户列表/单个(首页超级个体补充、会员详情回退) // 支持 ?limit=20 返回列表;?id=xxx 返回单个。返回 { success, data } 格式 func MiniprogramUsers(c *gin.Context) { id := c.Query("id") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) if limit < 1 || limit > 50 { limit = 20 } db := database.DB() if id != "" { var user model.User if err := db.Where("id = ?", id).First(&user).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": true, "data": nil}) return } // 与 /vip/status 一致:显式 is_vip=false 时不认订单兜底 isVipActive, _ := isVipEffective(db, id) // 用户信息与会员资料(vip*)、P3 资料扩展,供会员详情页完整展示 item := gin.H{ "id": user.ID, "ckbLeadToken": personLeadTokenByUserID(db, user.ID), "nickname": getStringValue(user.Nickname), "avatar": resolveAvatarURLWithRequest(c, getStringValue(user.Avatar)), "phone": getStringValue(user.Phone), "wechatId": getStringValue(user.WechatID), "vipName": getStringValue(user.VipName), "vipAvatar": resolveAvatarURLWithRequest(c, getStringValue(user.VipAvatar)), "vipContact": getStringValue(user.VipContact), "vipProject": getStringValue(user.VipProject), "vipBio": getStringValue(user.VipBio), "mbti": getStringValue(user.Mbti), "region": getStringValue(user.Region), "industry": getStringValue(user.Industry), "position": getStringValue(user.Position), "businessScale": getStringValue(user.BusinessScale), "skills": getStringValue(user.Skills), "storyBestMonth": getStringValue(user.StoryBestMonth), "storyAchievement": getStringValue(user.StoryAchievement), "storyTurning": getStringValue(user.StoryTurning), "helpOffer": getStringValue(user.HelpOffer), "helpNeed": getStringValue(user.HelpNeed), "projectIntro": getStringValue(user.ProjectIntro), "is_vip": isVipActive, } c.JSON(http.StatusOK, gin.H{"success": true, "data": item}) return } var users []model.User db.Order("created_at DESC").Limit(limit).Find(&users) list := make([]gin.H, 0, len(users)) for i := range users { u := &users[i] uvip, _ := isVipEffective(db, u.ID) list = append(list, gin.H{ "id": u.ID, "nickname": getStringValue(u.Nickname), "avatar": resolveAvatarURLWithRequest(c, getStringValue(u.Avatar)), "is_vip": uvip, }) } c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) } // strToPtr 返回字符串指针(辅助函数) func strToPtr(s string) *string { return &s } // activateVIP 为用户激活 VIP:续费时从 max(now, vip_expire_date) 累加 days 天 // 返回最终过期时间 func activateVIP(db *gorm.DB, userID string, days int, activatedAt time.Time) time.Time { var u model.User db.Select("id", "is_vip", "vip_expire_date").Where("id = ?", userID).First(&u) base := activatedAt if u.VipExpireDate != nil && u.VipExpireDate.After(base) { base = *u.VipExpireDate // 续费累加 } expireDate := base.AddDate(0, 0, days) db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ "is_vip": true, "vip_expire_date": expireDate, "vip_activated_at": activatedAt, }) return expireDate } // activateOrderBenefits 订单支付成功后激活对应权益(VIP / 全书 / 余额充值) func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) { if order == nil { return } userID := order.UserID productType := order.ProductType switch productType { case "fullbook": db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true) case "vip": activateVIP(db, userID, 365, payTime) // 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力,不阻断权益) if err := ensurePersonForUser(db, userID); err != nil { fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, err=%v\n", userID, err) } case "balance_recharge": ConfirmBalanceRechargeByOrder(db, order) case "link_karuo_tip": // 首页打赏 / 上麦礼遇:仅收款,无额外会员或章节权益 case "mentor_consultation": if order.ProductID == nil || strings.TrimSpace(*order.ProductID) == "" { return } cid, err := strconv.Atoi(strings.TrimSpace(*order.ProductID)) if err != nil || cid <= 0 { return } _ = db.Model(&model.MentorConsultation{}). Where("id = ? AND user_id = ?", cid, userID). Updates(map[string]interface{}{"status": "paid", "updated_at": payTime}) } } // 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 cloneGiftMap(m map[string]interface{}) map[string]interface{} { if m == nil { return nil } out := make(map[string]interface{}, len(m)) for k, v := range m { out[k] = v } return out } // mergeGiftOverlayOrdered 顺序以置顶人物 overlay 为准,同 id 用 overlay 字段覆盖全局 base;仅存在于 base 的 id 按原顺序接在末尾 func mergeGiftOverlayOrdered(base []map[string]interface{}, overlay []map[string]interface{}) []map[string]interface{} { if len(overlay) == 0 { return base } baseByID := make(map[string]map[string]interface{}) for _, g := range base { id, _ := g["id"].(string) id = strings.TrimSpace(id) if id == "" { continue } baseByID[id] = g } seen := make(map[string]bool) out := make([]map[string]interface{}, 0, len(overlay)+len(base)) for _, og := range overlay { id, _ := og["id"].(string) id = strings.TrimSpace(id) if id == "" { continue } var merged map[string]interface{} if b, ok := baseByID[id]; ok { merged = cloneGiftMap(b) } else { merged = make(map[string]interface{}) } for k, v := range og { merged[k] = v } if _, ok := merged["id"]; !ok { merged["id"] = id } out = append(out, merged) seen[id] = true } for _, g := range base { id, _ := g["id"].(string) id = strings.TrimSpace(id) if id == "" || seen[id] { continue } cm := cloneGiftMap(g) if cm != nil { out = append(out, cm) } } return out } func loadPinnedPersonHomeEntryGifts(db *gorm.DB) []map[string]interface{} { if db == nil { return nil } var p model.Person if err := db.Where("is_pinned = ?", true).Order("updated_at DESC").First(&p).Error; err != nil { return nil } if p.HomeEntryConfig == nil || strings.TrimSpace(*p.HomeEntryConfig) == "" { return nil } var m map[string]interface{} if json.Unmarshal([]byte(*p.HomeEntryConfig), &m) != nil { return nil } rw, _ := m["linkKaruoReward"].(map[string]interface{}) if rw == nil { return nil } raw, ok := rw["gifts"] if !ok || raw == nil { return nil } arr, ok := raw.([]interface{}) if !ok || len(arr) == 0 { return nil } out := make([]map[string]interface{}, 0, len(arr)) for _, it := range arr { g, ok := it.(map[string]interface{}) if ok { out = append(out, g) } } if len(out) == 0 { return nil } return out } // mergedLinkKaruoGiftsForPay 首页横排礼物/上麦:全局 mp_config 与置顶人物 home_entry_config 按 id 合并,计价与小程序展示一致 func mergedLinkKaruoGiftsForPay(db *gorm.DB, tipSource string) []map[string]interface{} { root := loadMpConfigRoot(db) base := linkKaruoGiftsList(root) ts := strings.TrimSpace(tipSource) if ts != "live_mic" && ts != "home_reward" { return base } ov := loadPinnedPersonHomeEntryGifts(db) if len(ov) == 0 { return base } return mergeGiftOverlayOrdered(base, ov) } func findLinkKaruoGiftInList(gifts []map[string]interface{}, giftID string) (map[string]interface{}, float64) { giftID = strings.TrimSpace(giftID) if giftID == "" { return nil, 0 } for _, g := range gifts { 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 } func findLinkKaruoGift(root map[string]interface{}, giftID string) (map[string]interface{}, float64) { return findLinkKaruoGiftInList(linkKaruoGiftsList(root), giftID) } 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, tipSource string) (total float64, giftName string, canonicalID string, err error) { if clientAmount <= 0 { return 0, "", "", fmt.Errorf("支付金额无效") } 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 mergedLinkKaruoGiftsForPay(db, tipSource) { 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("打赏金额与后台档位不一致,请检查全局礼物与置顶人物礼物配置后重试") } 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(tipSource 为 live_mic/home_reward 时合并置顶人物礼物价) func linkKaruoTipPriceTotal(db *gorm.DB, productID string, tipSource string) (float64, error) { productID = strings.TrimSpace(productID) if productID == "" { return 0, fmt.Errorf("打赏缺少礼物标识") } 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 } } } gifts := mergedLinkKaruoGiftsForPay(db, tipSource) _, price := findLinkKaruoGiftInList(gifts, giftID) if price <= 0 { return 0, fmt.Errorf("礼物不存在或未定价: %s", giftID) } return price * float64(qty), nil } // getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额 // productType: fullbook / vip / section / match / link_karuo_tip / mentor_consultation // productId: 章节购买时为章节 ID;打赏为 giftId|qty 或 live_mic|giftId|qty;导师咨询为 mentor_consultations.id // buyerUserID: mentor_consultation 时校验预约归属;其他类型可传空字符串 // tipSource 可选:live_mic / home_reward 时 link_karuo_tip 计价与置顶人物礼物配置合并 func getStandardPrice(db *gorm.DB, productType, productID, buyerUserID string, tipSource ...string) (float64, error) { productType = normalizePayProductType(productType) ts := "" if len(tipSource) > 0 { ts = strings.TrimSpace(tipSource[0]) } // 打赏/上麦:显式分支 + 模糊识别双保险,避免误入 default 报「未知商品类型」 if productType == "link_karuo_tip" || isLinkKaruoTipProductType(productType) { return linkKaruoTipPriceTotal(db, productID, ts) } switch productType { case "fullbook", "vip", "match": // 价格来源要与小程序 buildMiniprogramConfig 一致:fullbook 优先 site_settings.baseBookPrice(管理端「站点与作者」) if productType == "fullbook" { var siteRow model.SystemConfig if err := db.Where("config_key = ?", "site_settings").First(&siteRow).Error; err == nil && len(siteRow.ConfigValue) > 0 { var siteVal map[string]interface{} if json.Unmarshal(siteRow.ConfigValue, &siteVal) == nil { if v, ok := siteVal["baseBookPrice"].(float64); ok && v > 0 { return v, nil } } } } // 从 system_config 读取(chapter_config / vip_config / match_config) configKey := "chapter_config" if productType == "vip" { configKey = "vip_config" } else if productType == "match" { // 找伙伴匹配次数价格应使用 match_config configKey = "match_config" } var row model.SystemConfig if err := db.Where("config_key = ?", configKey).First(&row).Error; err == nil { var cfg map[string]interface{} if json.Unmarshal(row.ConfigValue, &cfg) == nil { fieldMap := map[string]string{ "fullbook": "fullbookPrice", "vip": "price", "match": "matchPrice", } raw := cfg[fieldMap[productType]] if raw != nil { switch v := raw.(type) { case float64: if v > 0 { return v, nil } case int: if v > 0 { return float64(v), nil } case int64: if v > 0 { return float64(v), nil } case json.Number: f, err := v.Float64() if err == nil && f > 0 { return f, nil } case string: f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) if err == nil && f > 0 { return f, nil } } } } } // 兜底默认值 defaults := map[string]float64{"fullbook": 365, "vip": 1980, "match": 1} if p, ok := defaults[productType]; ok { return p, nil } return 0, fmt.Errorf("未知商品类型: %s", productType) case "section": if productID == "" { return 0, fmt.Errorf("单章购买缺少 productId") } var ch model.Chapter if err := db.Select("id", "price", "is_free").Where("id = ?", productID).First(&ch).Error; err != nil { return 0, fmt.Errorf("章节不存在: %s", productID) } if ch.IsFree != nil && *ch.IsFree { return 0, fmt.Errorf("该章节为免费章节,无需支付") } if ch.Price == nil || *ch.Price <= 0 { return 0, fmt.Errorf("章节价格未配置: %s", productID) } return *ch.Price, nil case "mentor_consultation": if productID == "" { return 0, fmt.Errorf("导师咨询缺少预约单 ID") } cid, err := strconv.Atoi(strings.TrimSpace(productID)) if err != nil || cid <= 0 { return 0, fmt.Errorf("无效的预约单 ID") } var mc model.MentorConsultation if err := db.Where("id = ?", cid).First(&mc).Error; err != nil { return 0, fmt.Errorf("预约单不存在") } if mc.Status != "" && mc.Status != "created" { return 0, fmt.Errorf("预约单状态不可支付") } if strings.TrimSpace(buyerUserID) != "" && mc.UserID != buyerUserID { return 0, fmt.Errorf("预约单与当前用户不匹配") } if mc.Amount <= 0 { return 0, fmt.Errorf("预约金额无效") } return mc.Amount, nil default: return 0, fmt.Errorf("未知商品类型: %s", productType) } }