package handler import ( "encoding/json" "fmt" "math" "net/http" "strconv" "strings" "time" "unicode/utf8" "soul-api/internal/database" "soul-api/internal/model" "soul-api/internal/wechat" "github.com/gin-gonic/gin" "gorm.io/gorm" ) const giftPayExpireHours = 24 const wechatAttachMaxBytes = 128 // truncateStr 截断字符串至最多 n 字节(UTF-8 安全) func truncateStr(s string, n int) string { b := []byte(s) if len(b) <= n { return s } b = b[:n] for len(b) > 0 && b[len(b)-1] >= 0x80 { b = b[:len(b)-1] } return string(b) } // giftPayPreviewContent 取内容前 20%,用于代付页营销展示 func giftPayPreviewContent(content string) string { n := utf8.RuneCountInString(content) if n == 0 { return "" } limit := n * 20 / 100 if limit < 50 { limit = 50 } if limit > n { limit = n } runes := []rune(content) if limit >= n { return string(runes) } return string(runes[:limit]) + "……" } // GiftPayCreate POST /api/miniprogram/gift-pay/create 创建代付请求(改造后:发起人支付,好友领取) func GiftPayCreate(c *gin.Context) { var req struct { UserID string `json:"userId" binding:"required"` ProductType string `json:"productType" binding:"required"` ProductID string `json:"productId"` Quantity int `json:"quantity"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"}) return } quantity := req.Quantity if quantity < 1 { c.JSON(http.StatusOK, gin.H{"success": false, "error": "发放份数须为正整数"}) return } db := database.DB() // 校验发起人 var initiator model.User if err := db.Where("id = ?", req.UserID).First(&initiator).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户不存在"}) return } // 价格与商品校验 productID := req.ProductID if productID == "" { switch req.ProductType { case "vip": productID = "vip_annual" case "match": productID = "match" case "fullbook": productID = "fullbook" } } unitPrice, priceErr := getStandardPrice(db, req.ProductType, productID) if priceErr != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()}) return } amount := unitPrice * float64(quantity) if amount < 0.01 { amount = 0.01 } // 发起人若有推荐人绑定,享受好友优惠 var referrerID *string var binding struct { ReferrerID string `gorm:"column:referrer_id"` } if 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; err == nil && binding.ReferrerID != "" { referrerID = &binding.ReferrerID var cfg model.SystemConfig if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil { var config map[string]interface{} if json.Unmarshal(cfg.ConfigValue, &config) == nil { if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 { unitPrice = unitPrice * (1 - userDiscount/100) if unitPrice < 0.01 { unitPrice = 0.01 } amount = unitPrice * float64(quantity) if amount < 0.01 { amount = 0.01 } } } } } _ = referrerID // 分佣在 PayNotify 时按发起人计算 // 改造后:发起人帮别人买,发起人自己可已拥有,不再校验 // 描述 desc := "" switch req.ProductType { case "fullbook": desc = "《一场Soul的创业实验》全书" case "vip": desc = "卡若创业派对VIP年度会员(365天)" case "match": desc = "购买匹配次数" case "section": var ch model.Chapter if err := db.Select("section_title").Where("id = ?", productID).First(&ch).Error; err == nil && ch.SectionTitle != "" { desc = ch.SectionTitle } else { desc = fmt.Sprintf("章节-%s", productID) } default: desc = fmt.Sprintf("%s-%s", req.ProductType, productID) } expireAt := time.Now().Add(giftPayExpireHours * time.Hour) requestSN := "GPR" + wechat.GenerateOrderSn() id := "gpr_" + fmt.Sprintf("%d", time.Now().UnixNano()%100000000000) gpr := model.GiftPayRequest{ ID: id, RequestSN: requestSN, InitiatorUserID: req.UserID, ProductType: req.ProductType, ProductID: productID, Amount: amount, Description: desc, Status: "pending_pay", Quantity: quantity, RedeemedCount: 0, ExpireAt: expireAt, } if err := db.Create(&gpr).Error; err != nil { fmt.Printf("[GiftPayCreate] 创建失败: %v\n", err) // 若报 unknown column 'quantity' 等,需执行 soul-api/scripts/add-gift-pay-quantity.sql c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建失败"}) return } sectionTitle := desc if req.ProductType == "section" && productID != "" { var ch model.Chapter if err := db.Select("section_title").Where("id = ?", productID).First(&ch).Error; err == nil && ch.SectionTitle != "" { sectionTitle = ch.SectionTitle } } path := fmt.Sprintf("pages/gift-pay/detail?requestSn=%s", requestSN) c.JSON(http.StatusOK, gin.H{ "success": true, "requestSn": requestSN, "path": path, "amount": amount, "quantity": quantity, "sectionTitle": sectionTitle, "expireAt": expireAt.Format(time.RFC3339), }) } // GiftPayInitiatorPay POST /api/miniprogram/gift-pay/initiator-pay 发起人支付(改造后:我帮别人付款) func GiftPayInitiatorPay(c *gin.Context) { var req struct { RequestSn string `json:"requestSn" binding:"required"` OpenID string `json:"openId" binding:"required"` UserID string `json:"userId" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"}) return } db := database.DB() var gpr model.GiftPayRequest if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "pending_pay").First(&gpr).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或已支付"}) return } if time.Now().After(gpr.ExpireAt) { db.Model(&gpr).Update("status", "expired") c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"}) return } if req.UserID != gpr.InitiatorUserID { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅发起人可支付"}) return } var initiator model.User if err := db.Where("open_id = ?", req.OpenID).First(&initiator).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"}) return } if initiator.ID != gpr.InitiatorUserID { c.JSON(http.StatusOK, gin.H{"success": false, "error": "登录用户与发起人不一致"}) return } orderSn := wechat.GenerateOrderSn() status := "created" pm := "wechat" productType := "gift_pay_batch" productID := gpr.ProductID desc := fmt.Sprintf("代付分享 - %s × %d 份", gpr.Description, gpr.Quantity) gprID := gpr.ID order := model.Order{ ID: orderSn, OrderSN: orderSn, UserID: gpr.InitiatorUserID, OpenID: req.OpenID, ProductType: productType, ProductID: &productID, Amount: gpr.Amount, Description: &desc, Status: &status, PaymentMethod: &pm, GiftPayRequestID: &gprID, PayerUserID: &gpr.InitiatorUserID, } if err := db.Create(&order).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建订单失败"}) return } // 微信 attach 最大 128 字节;发起人付订单已存在,PayNotify 从 order 取 giftPayRequestSn attach := `{"ip":1}` totalFee := int(math.Round(gpr.Amount * 100)) // 与正常章节支付一致,避免浮点精度导致分额错误 if totalFee < 1 { c.JSON(http.StatusOK, gin.H{"success": false, "error": "金额异常,无法发起支付"}) return } ctx := c.Request.Context() prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, "代付-"+gpr.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": "生成支付参数失败"}) return } c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ "orderSn": orderSn, "prepayId": prepayID, "payParams": payParams, }, }) } // GiftPayDetail GET /api/miniprogram/gift-pay/detail?requestSn=xxx&userId= 或 ?sectionId=xxx&userId= 预览态 func GiftPayDetail(c *gin.Context) { requestSn := strings.TrimSpace(c.Query("requestSn")) sectionId := strings.TrimSpace(c.Query("sectionId")) callerUserID := strings.TrimSpace(c.Query("userId")) db := database.DB() // 预览态:无 requestSn 有 sectionId,返回文章信息供创建代付 if requestSn == "" && sectionId != "" { if callerUserID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"}) return } unitPrice, priceErr := getStandardPrice(db, "section", sectionId) if priceErr != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()}) return } // 发起人若有推荐人,享受折扣(与 create 一致) var binding struct { ReferrerID string `gorm:"column:referrer_id"` } if 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 `, callerUserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" { var cfg model.SystemConfig if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil { var config map[string]interface{} if json.Unmarshal(cfg.ConfigValue, &config) == nil { if userDiscount, ok := config["userDiscount"].(float64); ok && userDiscount > 0 { unitPrice = unitPrice * (1 - userDiscount/100) if unitPrice < 0.01 { unitPrice = 0.01 } } } } } var ch model.Chapter sectionTitle := "" productMid := 0 if err := db.Select("section_title", "mid").Where("id = ?", sectionId).First(&ch).Error; err == nil { sectionTitle = ch.SectionTitle productMid = ch.MID } c.JSON(http.StatusOK, gin.H{ "success": true, "mode": "create", "sectionId": sectionId, "sectionTitle": sectionTitle, "productMid": productMid, "unitPrice": unitPrice, "isInitiator": true, "action": "create", }) return } if requestSn == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少代付请求号"}) return } var gpr model.GiftPayRequest if err := db.Where("request_sn = ?", requestSn).First(&gpr).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"}) return } if gpr.Status != "pending" && gpr.Status != "pending_pay" && gpr.Status != "paid" && gpr.Status != "refunded" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"}) return } if time.Now().After(gpr.ExpireAt) { db.Model(&gpr).Update("status", "expired") c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付已过期"}) return } isInitiator := callerUserID != "" && callerUserID == gpr.InitiatorUserID // 发起人昵称与头像(完整展示) var initiator model.User nickname := "好友" initiatorAvatar := "" if err := db.Where("id = ?", gpr.InitiatorUserID).Select("nickname", "avatar").First(&initiator).Error; err == nil { if initiator.Nickname != nil && *initiator.Nickname != "" { nickname = *initiator.Nickname } if initiator.Avatar != nil && *initiator.Avatar != "" { initiatorAvatar = resolveAvatarURL(*initiator.Avatar) } } // 营销:章节类型时返回标题和内容预览 sectionTitle := gpr.Description contentPreview := "" productMid := 0 if gpr.ProductType == "section" && gpr.ProductID != "" { var ch model.Chapter if err := db.Select("section_title", "content", "mid").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil { if ch.SectionTitle != "" { sectionTitle = ch.SectionTitle } contentPreview = giftPayPreviewContent(ch.Content) productMid = ch.MID } } // 领取记录(发起人查看) var redeemList []gin.H if isInitiator { var orders []model.Order db.Where("gift_pay_request_id = ? AND product_type = ? AND status = ?", gpr.ID, "section", "paid").Order("created_at ASC").Find(&orders) for _, o := range orders { if o.UserID == "" { continue } var u model.User nickname := "用户" avatar := "" if err := db.Where("id = ?", o.UserID).Select("nickname", "avatar").First(&u).Error; err == nil { if u.Nickname != nil && *u.Nickname != "" { nickname = *u.Nickname } if u.Avatar != nil && *u.Avatar != "" { avatar = resolveAvatarURL(*u.Avatar) } } redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": o.CreatedAt.Format("2006-01-02 15:04")}) } } // action: pay=发起人待支付 | share=发起人已支付可分享 | redeem=好友可领取 | wait=好友待发起人支付 action := "" if isInitiator { if gpr.Status == "pending_pay" { action = "pay" } else if gpr.Status == "paid" { action = "share" } else if gpr.Status == "refunded" { action = "refunded" } else if gpr.Status == "pending" { action = "share" // 旧版:待好友付 } } else { if gpr.Status == "pending_pay" || gpr.Status == "pending" { action = "wait" } else if gpr.Status == "paid" { // 好友已领取过:返回 alreadyRedeemed,供前端直接跳转 read var existCnt int64 db.Model(&model.Order{}).Where( "user_id = ? AND gift_pay_request_id = ? AND product_type = ? AND status = ?", callerUserID, gpr.ID, "section", "paid", ).Count(&existCnt) if existCnt > 0 { action = "alreadyRedeemed" } else { action = "redeem" } } else if gpr.Status == "refunded" { action = "refunded" } } resp := gin.H{ "success": true, "requestSn": gpr.RequestSN, "productType": gpr.ProductType, "productId": gpr.ProductID, "productMid": productMid, "amount": gpr.Amount, "quantity": gpr.Quantity, "redeemedCount": gpr.RedeemedCount, "redeemList": redeemList, "description": gpr.Description, "sectionTitle": sectionTitle, "contentPreview": contentPreview, "initiatorNickname": nickname, "initiatorAvatar": initiatorAvatar, "initiatorUserId": gpr.InitiatorUserID, "isInitiator": isInitiator, "action": action, "status": gpr.Status, "expireAt": gpr.ExpireAt.Format(time.RFC3339), } c.JSON(http.StatusOK, resp) } // GiftPayRedeem POST /api/miniprogram/gift-pay/redeem 好友领取(改造后:免费获得章节) func GiftPayRedeem(c *gin.Context) { var req struct { RequestSn string `json:"requestSn" binding:"required"` UserID string `json:"userId" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"}) return } db := database.DB() var gpr model.GiftPayRequest if err := db.Where("request_sn = ? AND status = ?", req.RequestSn, "paid").First(&gpr).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在或未支付"}) return } if req.UserID == gpr.InitiatorUserID { c.JSON(http.StatusOK, gin.H{"success": false, "error": "发起人无需领取"}) return } if gpr.RedeemedCount >= gpr.Quantity { c.JSON(http.StatusOK, gin.H{"success": false, "error": "已领完"}) return } // 同一用户同一 requestSn 只能领一次 var existCnt int64 db.Model(&model.Order{}).Where( "user_id = ? AND gift_pay_request_id = ? AND product_type = ? AND status = ?", req.UserID, gpr.ID, "section", "paid", ).Count(&existCnt) if existCnt > 0 { c.JSON(http.StatusOK, gin.H{"success": false, "error": "您已领取过"}) return } // 创建好友订单:productType=section, status=paid, paymentMethod=gift_pay orderSn := wechat.GenerateOrderSn() status := "paid" pm := "gift_pay" productID := gpr.ProductID desc := fmt.Sprintf("代付领取 - %s", gpr.Description) gprID := gpr.ID amount := 0.0 order := model.Order{ ID: orderSn, OrderSN: orderSn, UserID: req.UserID, ProductType: "section", ProductID: &productID, Amount: amount, Description: &desc, Status: &status, PaymentMethod: &pm, GiftPayRequestID: &gprID, PayerUserID: &gpr.InitiatorUserID, } if err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Create(&order).Error; err != nil { return err } return tx.Model(&model.GiftPayRequest{}).Where("id = ?", gpr.ID). Update("redeemed_count", gorm.Expr("redeemed_count + 1")).Error }); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "领取失败"}) return } _ = amount productMid := 0 if gpr.ProductType == "section" && gpr.ProductID != "" { var ch model.Chapter if err := db.Select("mid").Where("id = ?", gpr.ProductID).First(&ch).Error; err == nil { productMid = ch.MID } } c.JSON(http.StatusOK, gin.H{ "success": true, "sectionId": gpr.ProductID, "sectionMid": productMid, }) } // GiftPayCancel POST /api/miniprogram/gift-pay/cancel 发起人取消 func GiftPayCancel(c *gin.Context) { var req struct { RequestSn string `json:"requestSn" binding:"required"` UserID string `json:"userId" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少参数"}) return } db := database.DB() var gpr model.GiftPayRequest if err := db.Where("request_sn = ?", req.RequestSn).First(&gpr).Error; err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "代付请求不存在"}) return } if gpr.InitiatorUserID != req.UserID { c.JSON(http.StatusOK, gin.H{"success": false, "error": "无权取消"}) return } if gpr.Status != "pending" && gpr.Status != "pending_pay" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"}) return } db.Model(&gpr).Update("status", "cancelled") c.JSON(http.StatusOK, gin.H{"success": true, "message": "已取消"}) } // GiftPayMyRequests GET /api/miniprogram/gift-pay/my-requests?userId= 我发起的(含领取记录) func GiftPayMyRequests(c *gin.Context) { userID := c.Query("userId") if userID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少userId"}) return } db := database.DB() var list []model.GiftPayRequest db.Where("initiator_user_id = ? AND status != ?", userID, "cancelled").Order("created_at DESC").Limit(50).Find(&list) out := make([]gin.H, 0, len(list)) for _, r := range list { // 领取记录:orders 表 gift_pay_request_id + product_type=section + payment_method=gift_pay var redeemList []gin.H var orders []model.Order db.Where("gift_pay_request_id = ? AND product_type = ? AND status = ?", r.ID, "section", "paid").Order("created_at ASC").Find(&orders) // 好友领取订单 for _, o := range orders { if o.UserID == "" { continue } var u model.User nickname := "用户" avatar := "" if err := db.Where("id = ?", o.UserID).Select("nickname", "avatar").First(&u).Error; err == nil { if u.Nickname != nil && *u.Nickname != "" { nickname = *u.Nickname } if u.Avatar != nil && *u.Avatar != "" { avatar = resolveAvatarURL(*u.Avatar) } } redeemAt := o.CreatedAt.Format("2006-01-02 15:04") redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": redeemAt}) } out = append(out, gin.H{ "requestSn": r.RequestSN, "productType": r.ProductType, "productId": r.ProductID, "amount": r.Amount, "quantity": r.Quantity, "redeemedCount": r.RedeemedCount, "description": r.Description, "status": r.Status, "expireAt": r.ExpireAt.Format(time.RFC3339), "createdAt": r.CreatedAt.Format(time.RFC3339), "redeemList": redeemList, }) } c.JSON(http.StatusOK, gin.H{"success": true, "list": out}) } // AdminGiftPayRequestsList GET /api/admin/gift-pay-requests 管理端-代付请求列表 func AdminGiftPayRequestsList(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) status := strings.TrimSpace(c.Query("status")) if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } db := database.DB() q := db.Model(&model.GiftPayRequest{}) if status != "" { q = q.Where("status = ?", status) } var total int64 q.Count(&total) var list []model.GiftPayRequest q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list) userIDs := make(map[string]bool) for _, r := range list { userIDs[r.InitiatorUserID] = true if r.PayerUserID != nil && *r.PayerUserID != "" { userIDs[*r.PayerUserID] = true } } nicknames := make(map[string]string) if len(userIDs) > 0 { ids := make([]string, 0, len(userIDs)) for id := range userIDs { ids = append(ids, id) } var users []model.User db.Select("id, nickname").Where("id IN ?", ids).Find(&users) for _, u := range users { if u.Nickname != nil { nicknames[u.ID] = *u.Nickname } } } out := make([]gin.H, 0, len(list)) for _, r := range list { initiatorNick := nicknames[r.InitiatorUserID] payerNick := "" if r.PayerUserID != nil { payerNick = nicknames[*r.PayerUserID] } orderID := "" if r.OrderID != nil { orderID = *r.OrderID } out = append(out, gin.H{ "id": r.ID, "requestSn": r.RequestSN, "initiatorUserId": r.InitiatorUserID, "initiatorNick": initiatorNick, "productType": r.ProductType, "productId": r.ProductID, "amount": r.Amount, "quantity": r.Quantity, "redeemedCount": r.RedeemedCount, "description": r.Description, "status": r.Status, "payerUserId": r.PayerUserID, "payerNick": payerNick, "orderId": orderID, "expireAt": r.ExpireAt, "createdAt": r.CreatedAt, "updatedAt": r.UpdatedAt, }) } c.JSON(http.StatusOK, gin.H{"success": true, "data": out, "total": total}) }