2026-03-07 22:58:43 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// 记录注册行为到 user_tracks
|
|
|
|
|
|
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
|
|
|
|
|
db.Create(&model.UserTrack{ID: trackID, UserID: user.ID, Action: "register"})
|
2026-03-08 08:52:37 +08:00
|
|
|
|
// 新用户:异步调用神射手自动打标(手机号尚未绑定,phone 为空时暂不调用)
|
|
|
|
|
|
AdminShensheShouAutoTag(userID, "")
|
2026-03-07 22:58:43 +08:00
|
|
|
|
} 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),
|
2026-03-17 17:50:12 +08:00
|
|
|
|
"avatar": getUrlValue(user.Avatar),
|
2026-03-07 22:58:43 +08:00
|
|
|
|
"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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// 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,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 22:58:43 +08:00
|
|
|
|
// 辅助函数
|
|
|
|
|
|
func getStringValue(ptr *string) string {
|
|
|
|
|
|
if ptr == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return *ptr
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-03-17 17:50:12 +08:00
|
|
|
|
var finalAmount float64
|
|
|
|
|
|
var orderSn string
|
2026-03-07 22:58:43 +08:00
|
|
|
|
var referrerID *string
|
2026-03-17 17:50:12 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-17 17:50:12 +08:00
|
|
|
|
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()
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
totalFee := int(finalAmount * 100) // 转为分
|
|
|
|
|
|
description := req.Description
|
|
|
|
|
|
if description == "" {
|
2026-03-17 17:50:12 +08:00
|
|
|
|
if req.ProductType == "balance_recharge" {
|
|
|
|
|
|
description = fmt.Sprintf("余额充值 ¥%.2f", finalAmount)
|
|
|
|
|
|
} else if req.ProductType == "fullbook" {
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// 充值订单已存在,不重复创建
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// V1.3 修复:主动同步到本地 orders,并激活对应权益(VIP/全书),避免等待 PayNotify 延迟
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-03-17 17:50:12 +08:00
|
|
|
|
order.Status = strToPtr("paid")
|
|
|
|
|
|
order.PayTime = &now
|
2026-03-07 22:58:43 +08:00
|
|
|
|
orderPollLogf("主动同步订单已支付: %s", orderSn)
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// 激活权益
|
|
|
|
|
|
if order.UserID != "" {
|
|
|
|
|
|
activateOrderBenefits(db, &order, now)
|
|
|
|
|
|
}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 {
|
2026-03-17 17:50:12 +08:00
|
|
|
|
ProductType string `json:"productType"`
|
|
|
|
|
|
ProductID string `json:"productId"`
|
|
|
|
|
|
UserID string `json:"userId"`
|
|
|
|
|
|
GiftPayRequestSn string `json:"giftPayRequestSn"`
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
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()
|
2026-03-17 17:50:12 +08:00
|
|
|
|
updates := map[string]interface{}{
|
2026-03-07 22:58:43 +08:00
|
|
|
|
"status": status,
|
|
|
|
|
|
"transaction_id": transactionID,
|
|
|
|
|
|
"pay_time": now,
|
2026-03-17 17:50:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
if err := db.Model(&order).Updates(updates).Error; err != nil {
|
2026-03-07 22:58:43 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// 代付订单:更新 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 != "" {
|
2026-03-07 22:58:43 +08:00
|
|
|
|
if attach.ProductType == "fullbook" {
|
2026-03-17 17:50:12 +08:00
|
|
|
|
db.Model(&model.User{}).Where("id = ?", beneficiaryUserID).Update("has_full_book", true)
|
|
|
|
|
|
fmt.Printf("[PayNotify] 用户已购全书: %s\n", beneficiaryUserID)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
} else if attach.ProductType == "vip" {
|
|
|
|
|
|
vipActivatedAt := time.Now()
|
|
|
|
|
|
if order.PayTime != nil {
|
|
|
|
|
|
vipActivatedAt = *order.PayTime
|
|
|
|
|
|
}
|
2026-03-17 17:50:12 +08:00
|
|
|
|
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"))
|
2026-03-07 22:58:43 +08:00
|
|
|
|
} else if attach.ProductType == "match" {
|
2026-03-17 17:50:12 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
} 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 != ?",
|
2026-03-17 17:50:12 +08:00
|
|
|
|
beneficiaryUserID, attach.ProductID, orderSn,
|
2026-03-07 22:58:43 +08:00
|
|
|
|
).Count(&count)
|
|
|
|
|
|
if count == 0 {
|
2026-03-17 17:50:12 +08:00
|
|
|
|
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", beneficiaryUserID, attach.ProductID)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
} else {
|
2026-03-17 17:50:12 +08:00
|
|
|
|
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", beneficiaryUserID, attach.ProductID)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
productID := attach.ProductID
|
|
|
|
|
|
if productID == "" {
|
|
|
|
|
|
productID = "fullbook"
|
|
|
|
|
|
}
|
|
|
|
|
|
db.Where(
|
|
|
|
|
|
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
|
2026-03-17 17:50:12 +08:00
|
|
|
|
beneficiaryUserID, attach.ProductType, productID, orderSn,
|
2026-03-07 22:58:43 +08:00
|
|
|
|
).Delete(&model.Order{})
|
2026-03-17 17:50:12 +08:00
|
|
|
|
processReferralCommission(db, beneficiaryUserID, totalAmount, orderSn, &order)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 `<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)
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// 记录绑定手机号行为到 user_tracks
|
|
|
|
|
|
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
|
|
|
|
|
db.Create(&model.UserTrack{ID: trackID, UserID: req.UserID, Action: "bind_phone"})
|
2026-03-07 22:58:43 +08:00
|
|
|
|
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// 记录绑定手机行为
|
|
|
|
|
|
bindTrackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
|
|
|
|
|
|
database.DB().Create(&model.UserTrack{ID: bindTrackID, UserID: req.UserID, Action: "bind_phone"})
|
2026-03-08 08:52:46 +08:00
|
|
|
|
// 绑定手机号后,异步调用神射手自动完善标签
|
|
|
|
|
|
AdminShensheShouAutoTag(req.UserID, phoneNumber)
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// 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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 22:58:43 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// V4.1 修复:is_vip 同时校验过期时间(is_vip=1 且 vip_expire_date>NOW),而非仅凭订单数量
|
|
|
|
|
|
isVipActive, _ := isVipFromUsers(db, id)
|
|
|
|
|
|
if !isVipActive {
|
|
|
|
|
|
// 兜底:orders 表有有效 VIP 订单
|
|
|
|
|
|
isVipActive, _ = isVipFromOrders(db, id)
|
|
|
|
|
|
}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
// 用户信息与会员资料(vip*)、P3 资料扩展,供会员详情页完整展示
|
|
|
|
|
|
item := gin.H{
|
|
|
|
|
|
"id": user.ID,
|
|
|
|
|
|
"nickname": getStringValue(user.Nickname),
|
2026-03-17 17:50:12 +08:00
|
|
|
|
"avatar": getUrlValue(user.Avatar),
|
2026-03-07 22:58:43 +08:00
|
|
|
|
"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),
|
2026-03-17 17:50:12 +08:00
|
|
|
|
"is_vip": isVipActive,
|
2026-03-07 22:58:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
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]
|
2026-03-17 17:50:12 +08:00
|
|
|
|
// V4.1:is_vip 同时校验过期时间
|
|
|
|
|
|
uvip, _ := isVipFromUsers(db, u.ID)
|
|
|
|
|
|
if !uvip {
|
|
|
|
|
|
uvip, _ = isVipFromOrders(db, u.ID)
|
|
|
|
|
|
}
|
2026-03-07 22:58:43 +08:00
|
|
|
|
list = append(list, gin.H{
|
|
|
|
|
|
"id": u.ID,
|
|
|
|
|
|
"nickname": getStringValue(u.Nickname),
|
2026-03-17 17:50:12 +08:00
|
|
|
|
"avatar": getUrlValue(u.Avatar),
|
|
|
|
|
|
"is_vip": uvip,
|
2026-03-07 22:58:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
|
|
|
|
|
}
|
2026-03-17 17:50:12 +08:00
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|