package handler import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "soul-api/internal/database" "soul-api/internal/model" "soul-api/internal/wechat" "github.com/gin-gonic/gin" "gorm.io/gorm" ) var ( orderPollLogger *log.Logger orderPollLoggerOnce sync.Once ) // 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 { // 创建新用户 userID := openID // 直接使用 openid 作为用户 ID 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 } // 记录注册行为到 user_tracks trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000) db.Create(&model.UserTrack{ID: trackID, UserID: user.ID, Action: "register"}) // 新用户:异步调用神射手自动打标(手机号尚未绑定,phone 为空时暂不调用) AdminShensheShouAutoTag(userID, "") } else { // 更新 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": getUrlValue(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": getUrlValue(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, }, }) } // 辅助函数 func getStringValue(ptr *string) string { if ptr == nil { return "" } return *ptr } // getUrlValue 取字符串指针值并修复缺少冒号的 URL("https//..." → "https://...") func getUrlValue(ptr *string) string { s := getStringValue(ptr) if strings.HasPrefix(s, "https//") { return "https://" + s[7:] } if strings.HasPrefix(s, "http//") { return "http://" + s[6:] } return s } func getBoolValue(ptr *bool) bool { if ptr == nil { return false } 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"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少openId参数,请先登录"}) return } if req.Amount <= 0 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "支付金额无效"}) return } db := database.DB() var finalAmount float64 var orderSn string var referrerID *string 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 读取标准价 -------- standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID) 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(` 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, req.ProductID, 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() if clientIP == "" { clientIP = "127.0.0.1" } userID := req.UserID if userID == "" { userID = req.OpenID } productID := req.ProductID if productID == "" { switch req.ProductType { case "vip": productID = "vip_annual" case "match": productID = "match" default: productID = "fullbook" } } // 充值订单已存在,不重复创建 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) } } attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID) 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"` GiftPayRequestSn string `json:"giftPayRequestSn"` } if attachStr != "" { _ = json.Unmarshal([]byte(attachStr), &attach) } 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 // 权益归属与分佣:代付时归发起人(order.UserID),普通订单归 buyerUserID beneficiaryUserID := buyerUserID if attach.GiftPayRequestSn != "" && order.UserID != "" { beneficiaryUserID = order.UserID fmt.Printf("[PayNotify] 代付订单,权益归属发起人: %s\n", beneficiaryUserID) } if attach.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 = ?", attach.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")) } 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) } } 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) } 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) { type Binding struct { ID int `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 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) // 记录绑定手机号行为到 user_tracks trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000) db.Create(&model.UserTrack{ID: trackID, UserID: req.UserID, Action: "bind_phone"}) fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID) // 记录绑定手机行为 bindTrackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000) database.DB().Create(&model.UserTrack{ID: bindTrackID, UserID: req.UserID, Action: "bind_phone"}) // 绑定手机号后,异步调用神射手自动完善标签 AdminShensheShouAutoTag(req.UserID, phoneNumber) } 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 } // V4.1 修复:is_vip 同时校验过期时间(is_vip=1 且 vip_expire_date>NOW),而非仅凭订单数量 isVipActive, _ := isVipFromUsers(db, id) if !isVipActive { // 兜底:orders 表有有效 VIP 订单 isVipActive, _ = isVipFromOrders(db, id) } // 用户信息与会员资料(vip*)、P3 资料扩展,供会员详情页完整展示 item := gin.H{ "id": user.ID, "nickname": getStringValue(user.Nickname), "avatar": getUrlValue(user.Avatar), "phone": getStringValue(user.Phone), "wechatId": getStringValue(user.WechatID), "vipName": getStringValue(user.VipName), "vipAvatar": 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] // V4.1:is_vip 同时校验过期时间 uvip, _ := isVipFromUsers(db, u.ID) if !uvip { uvip, _ = isVipFromOrders(db, u.ID) } list = append(list, gin.H{ "id": u.ID, "nickname": getStringValue(u.Nickname), "avatar": getUrlValue(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) case "balance_recharge": ConfirmBalanceRechargeByOrder(db, order) } } // getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额 // productType: fullbook / vip / section / match // productId: 章节购买时为章节 ID func getStandardPrice(db *gorm.DB, productType, productID string) (float64, error) { switch productType { case "fullbook", "vip", "match": // 从 system_config 读取 configKey := "chapter_config" if productType == "vip" { configKey = "vip_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", } if v, ok := cfg[fieldMap[productType]].(float64); ok && v > 0 { return v, nil } } } // 兜底默认值 defaults := map[string]float64{"fullbook": 9.9, "vip": 1980, "match": 68} if p, ok := defaults[productType]; ok { return p, nil } return 0, fmt.Errorf("未知商品类型: %s", productType) case "section", "gift": 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 "balance_recharge": if productID == "" { return 0, fmt.Errorf("充值订单号缺失") } var order model.Order if err := db.Where("order_sn = ? AND product_type = ?", productID, "balance_recharge").First(&order).Error; err != nil { return 0, fmt.Errorf("充值订单不存在: %s", productID) } if order.Amount <= 0 { return 0, fmt.Errorf("充值金额无效") } return order.Amount, nil default: return 0, fmt.Errorf("未知商品类型: %s", productType) } }