Files
soul-yongping/soul-api/internal/handler/gift_pay.go
2026-03-18 12:40:51 +08:00

721 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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" {
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 = *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 = *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 == "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"
}
}
}
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 = *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})
}