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 } } 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": 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, }) } // 辅助函数 func getStringValue(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"` } 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() // 查询用户的有效推荐人(先查 binding,再查 referralCode) var referrerID *string 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 } } // 有推荐人时应用好友优惠(无论是 binding 还是 referralCode) finalAmount := req.Amount 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 = req.Amount * (1 - discountRate) if finalAmount < 0.01 { finalAmount = 0.01 } } } } } // 生成订单号 orderSn := wechat.GenerateOrderSn() totalFee := int(finalAmount * 100) // 转为分 description := req.Description if description == "" { 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" } } status := "created" 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, } 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" // 若微信已支付,主动同步到本地 orders(不等 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, }) orderPollLogf("主动同步订单已支付: %s", orderSn) } 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"` } 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() if err := db.Model(&order).Updates(map[string]interface{}{ "status": status, "transaction_id": transactionID, "pay_time": now, }).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) } if buyerUserID != "" && attach.ProductType != "" { if attach.ProductType == "fullbook" { db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true) fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID) } else if attach.ProductType == "vip" { // VIP 支付成功:更新 users.is_vip、vip_expire_date、vip_activated_at(排序:后付款在前) expireDate := time.Now().AddDate(0, 0, 365) vipActivatedAt := time.Now() if order.PayTime != nil { vipActivatedAt = *order.PayTime } db.Model(&model.User{}).Where("id = ?", buyerUserID).Updates(map[string]interface{}{ "is_vip": true, "vip_expire_date": expireDate, "vip_activated_at": vipActivatedAt, }) fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, 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", buyerUserID, orderSn) } 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 != ?", buyerUserID, attach.ProductID, orderSn, ).Count(&count) if count == 0 { fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", buyerUserID, attach.ProductID) } else { fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", buyerUserID, 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 != ?", buyerUserID, attach.ProductType, productID, orderSn, ).Delete(&model.Order{}) processReferralCommission(db, buyerUserID, 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) fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID) } 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) } // 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 } var cnt int64 db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)", id, "paid", "fullbook", "vip").Count(&cnt) // 用户信息与会员资料(vip*)、P3 资料扩展,供会员详情页完整展示 item := gin.H{ "id": user.ID, "nickname": getStringValue(user.Nickname), "avatar": getStringValue(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": cnt > 0, } 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] var cnt int64 db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND (product_type = ? OR product_type = ?)", u.ID, "paid", "fullbook", "vip").Count(&cnt) list = append(list, gin.H{ "id": u.ID, "nickname": getStringValue(u.Nickname), "avatar": getStringValue(u.Avatar), "is_vip": cnt > 0, }) } c.JSON(http.StatusOK, gin.H{"success": true, "data": list}) }