Files
soul-yongping/soul-api/internal/handler/miniprogram.go

716 lines
21 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"
"io"
"net/http"
"strconv"
"strings"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 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 表查询真实购买记录Pluck 替代 Raw
var purchasedSections []string
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND product_type = ?", user.ID, "paid", "section").
Distinct("product_id").Pluck("product_id", &purchasedSections)
// 过滤空字符串
filtered := make([]string, 0, len(purchasedSections))
for _, s := range purchasedSections {
if s != "" {
filtered = append(filtered, s)
}
}
purchasedSections = filtered
// 构建返回的用户对象
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,gte=0"`
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 refID string
err := db.Model(&model.ReferralBinding{}).Where("referee_id = ? AND status = ? AND expiry_date > ?", req.UserID, "active", time.Now()).
Order("binding_date DESC").Limit(1).Pluck("referrer_id", &refID).Error
if err == nil && refID != "" {
referrerID = &refID
}
}
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 == "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 == "" {
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,
})
fmt.Printf("[PayGet] 主动同步订单已支付: %s\n", 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/notifyv3 支付回调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()
return db.Transaction(func(tx *gorm.DB) error {
buyerUserID := attach.UserID
if openID != "" {
var user model.User
if err := tx.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 := tx.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 := tx.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 := tx.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" {
tx.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
} else if attach.ProductType == "match" {
fmt.Printf("[PayNotify] 用户购买匹配次数: %s订单 %s\n", buyerUserID, orderSn)
} else if attach.ProductType == "section" && attach.ProductID != "" {
var count int64
tx.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"
}
tx.Where(
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
buyerUserID, attach.ProductType, productID, orderSn,
).Delete(&model.Order{})
processReferralCommission(tx, 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)
}
// 处理分销佣金
// amount 为实付金额若有好友优惠则已打折order 用于判断是否有推荐人从而反推原价
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string, order *model.Order) {
// 获取分成配置,默认 90%;好友优惠用于反推原价(佣金按原价计算)
distributorShare := 0.9
userDiscount := 0.0
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 share, ok := config["distributorShare"].(float64); ok {
distributorShare = share / 100
}
if disc, ok := config["userDiscount"].(float64); ok {
userDiscount = disc / 100
}
}
}
// 佣金按原价计算:若有推荐人则实付已打折,反推原价 = amount / (1 - userDiscount)
commissionBase := amount
if order != nil && userDiscount > 0 && (order.ReferrerID != nil && *order.ReferrerID != "" || order.ReferralCode != nil && *order.ReferralCode != "") {
if (1 - userDiscount) > 0 {
commissionBase = amount / (1 - userDiscount)
}
}
// 查找有效推广绑定GORM 替代 Raw
var binding model.ReferralBinding
err := db.Where("referee_id = ? AND status = ?", buyerUserID, "active").Order("binding_date DESC").First(&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
}
// 计算佣金(按原价)
commission := commissionBase * distributorShare
purchaseCount := 0
if binding.PurchaseCount != nil {
purchaseCount = *binding.PurchaseCount
}
totalCommission := 0.0
if binding.TotalCommission != nil {
totalCommission = *binding.TotalCommission
}
newPurchaseCount := purchaseCount + 1
newTotalCommission := totalCommission + commission
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f, shareRate=%.0f%%\n",
binding.ReferrerID, amount, commission, distributorShare*100)
// 更新推广者的待结算收益
db.Model(&model.User{}).Where("id = ?", binding.ReferrerID).
Update("pending_earnings", gorm.Expr("COALESCE(pending_earnings, 0) + ?", commission))
// 更新绑定记录COALESCE 避免 total_commission 为 NULL 时 NULL+?=NULL
db.Model(&model.ReferralBinding{}).Where("id = ?", binding.ID).Updates(map[string]interface{}{
"last_purchase_date": time.Now(),
"purchase_count": gorm.Expr("COALESCE(purchase_count, 0) + 1"),
"total_commission": gorm.Expr("COALESCE(total_commission, 0) + ?", commission),
})
fmt.Printf("[PayNotify] 分佣完成: 推广者 %s 获得 %.2f 元(第 %d 次购买,累计 %.2f 元)\n",
binding.ReferrerID, commission, newPurchaseCount, newTotalCommission)
}
// 微信支付回调响应
func successResponse() string {
return `<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`
}
func failResponse() string {
return `<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[ERROR]]></return_msg></xml>`
}
// 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()
}