- Updated the login modal to include a user agreement section with clickable links to the user agreement and privacy policy. - Improved button styling and layout for better user experience. - Refactored avatar selection process to utilize local image paths, enhancing compatibility and reliability across different environments. - Added utility functions for resolving avatar file paths and selecting images from the gallery or camera. This update aims to streamline user interactions during login and avatar selection, ensuring a smoother experience.
2131 lines
67 KiB
Go
2131 lines
67 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"soul-api/internal/database"
|
||
"soul-api/internal/model"
|
||
"soul-api/internal/wechat"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"golang.org/x/crypto/bcrypt"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
var (
|
||
orderPollLogger *log.Logger
|
||
orderPollLoggerOnce sync.Once
|
||
)
|
||
|
||
// normalizePayProductType 清洗 productType,避免不可见字符导致未命中 link_karuo_tip 分支
|
||
func normalizePayProductType(raw string) string {
|
||
s := strings.TrimSpace(raw)
|
||
s = strings.ReplaceAll(s, "\ufeff", "")
|
||
s = strings.ReplaceAll(s, "\u00a0", "")
|
||
s = strings.ReplaceAll(s, "\u200b", "")
|
||
s = strings.ReplaceAll(s, "\u200c", "")
|
||
s = strings.ReplaceAll(s, "\u200d", "")
|
||
s = strings.ReplaceAll(s, "\uff3f", "_")
|
||
s = strings.ReplaceAll(s, "-", "_")
|
||
s = strings.ToLower(s)
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
out := b.String()
|
||
if strings.ReplaceAll(out, "_", "") == "linkkaruotip" {
|
||
return "link_karuo_tip"
|
||
}
|
||
return out
|
||
}
|
||
|
||
// isLinkKaruoTipProductType 打赏/上麦商品类型(兼容 normalize 漏网、同形连接符等)
|
||
func isLinkKaruoTipProductType(s string) bool {
|
||
s = strings.TrimSpace(strings.ToLower(normalizePayProductType(s)))
|
||
if s == "link_karuo_tip" {
|
||
return true
|
||
}
|
||
compact := strings.ReplaceAll(strings.ReplaceAll(s, "_", ""), "-", "")
|
||
return compact == "linkkaruotip"
|
||
}
|
||
|
||
// 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 {
|
||
// 创建新用户(含软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突)
|
||
userID := "user_" + randomSuffix()
|
||
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
|
||
}
|
||
// 新用户:异步调用神射手自动打标(手机号尚未绑定,phone 为空时暂不调用)
|
||
AdminShensheShouAutoTag(userID, "")
|
||
} else {
|
||
// 更新 session_key
|
||
db.Model(&user).Update("session_key", sessionKey)
|
||
}
|
||
|
||
// 从 orders 表查询真实购买记录
|
||
var purchasedSections []string
|
||
var orderRows []struct {
|
||
ProductID string `gorm:"column:product_id"`
|
||
}
|
||
|
||
db.Raw(`
|
||
SELECT DISTINCT product_id
|
||
FROM orders
|
||
WHERE user_id = ?
|
||
AND status = 'paid'
|
||
AND product_type = 'section'
|
||
`, user.ID).Scan(&orderRows)
|
||
|
||
for _, row := range orderRows {
|
||
if row.ProductID != "" {
|
||
purchasedSections = append(purchasedSections, row.ProductID)
|
||
}
|
||
}
|
||
|
||
if purchasedSections == nil {
|
||
purchasedSections = []string{}
|
||
}
|
||
|
||
// 构建返回的用户对象
|
||
responseUser := map[string]interface{}{
|
||
"id": user.ID,
|
||
"openId": getStringValue(user.OpenID),
|
||
"nickname": getStringValue(user.Nickname),
|
||
"avatar": resolveAvatarURLWithRequest(c, 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,
|
||
})
|
||
}
|
||
|
||
// 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": resolveAvatarURLWithRequest(c, 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,
|
||
}
|
||
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,
|
||
},
|
||
})
|
||
}
|
||
|
||
// saveH5UserPasswordHash 用原生 SQL 更新 users.password_hash,避免 GORM Model+Updates 在部分库/版本下更新失败。
|
||
func saveH5UserPasswordHash(db *gorm.DB, userID, bcryptHash string) error {
|
||
userID = strings.TrimSpace(userID)
|
||
if userID == "" {
|
||
return fmt.Errorf("empty user id")
|
||
}
|
||
at := time.Now()
|
||
r := db.Exec("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", bcryptHash, at, userID)
|
||
if r.Error != nil {
|
||
return r.Error
|
||
}
|
||
if r.RowsAffected < 1 {
|
||
return fmt.Errorf("no rows updated for id=%s", userID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// miniprogramPhoneLoginAllowed 开发环境 或 显式开启 H5 手机号登录 时允许(按库内已绑定手机匹配用户,与小程序微信登录为同一套 users 表)
|
||
func miniprogramPhoneLoginAllowed() bool {
|
||
if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) == "development" {
|
||
return true
|
||
}
|
||
// 本地常未设 APP_ENV;Gin 默认 GIN_MODE=debug,与 release 生产区分,避免联调 H5 误 403
|
||
if strings.ToLower(strings.TrimSpace(os.Getenv("GIN_MODE"))) == "debug" {
|
||
return true
|
||
}
|
||
v := strings.TrimSpace(os.Getenv("H5_PHONE_LOGIN_ENABLED"))
|
||
if v == "" {
|
||
return false
|
||
}
|
||
v = strings.ToLower(v)
|
||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||
}
|
||
|
||
// MiniprogramDevLoginByPhone POST /api/miniprogram/dev/login-by-phone 与 POST /api/miniprogram/h5/login-by-phone
|
||
// 使用手机号在库中查找已注册用户;须至少 6 位密码:password_hash 为空时写入 bcrypt,否则校验。
|
||
// 放行条件:APP_ENV=development,或 GIN_MODE=debug(本地默认),或 H5_PHONE_LOGIN_ENABLED=1;生产须 release + 显式开关。
|
||
func MiniprogramDevLoginByPhone(c *gin.Context) {
|
||
if !miniprogramPhoneLoginAllowed() {
|
||
c.JSON(http.StatusForbidden, gin.H{
|
||
"success": false,
|
||
"error": "H5 手机号登录未开启:本机 soul-api 请使用 APP_ENV=development 或 GIN_MODE=debug;reactH5 请将 Vite 代理指向本机(默认 127.0.0.1:9100,见 vite.config)。若必须连线上 API,请在服务端设置 H5_PHONE_LOGIN_ENABLED=1",
|
||
})
|
||
return
|
||
}
|
||
var req struct {
|
||
Phone string `json:"phone" binding:"required"`
|
||
Password string `json:"password"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少手机号"})
|
||
return
|
||
}
|
||
phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", ""))
|
||
if phone == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "手机号不能为空"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
var user model.User
|
||
// 支持纯数字或带 +86 前缀
|
||
if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).First(&user).Error; err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "该手机号未注册"})
|
||
return
|
||
}
|
||
|
||
plain := strings.TrimSpace(req.Password)
|
||
storedHash := ""
|
||
if user.PasswordHash != nil {
|
||
storedHash = strings.TrimSpace(*user.PasswordHash)
|
||
}
|
||
if storedHash == "" {
|
||
if len(plain) < 6 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "首次登录请设置至少6位密码"})
|
||
return
|
||
}
|
||
hp, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"})
|
||
return
|
||
}
|
||
hs := string(hp)
|
||
if err := saveH5UserPasswordHash(db, user.ID, hs); err != nil {
|
||
log.Printf("login-by-phone: save password_hash user=%s err=%v", user.ID, err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存密码失败"})
|
||
return
|
||
}
|
||
} else {
|
||
if plain == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入密码"})
|
||
return
|
||
}
|
||
if bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) != nil {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "手机号或密码错误"})
|
||
return
|
||
}
|
||
}
|
||
|
||
openID := getStringValue(user.OpenID)
|
||
if openID == "" {
|
||
openID = user.ID
|
||
}
|
||
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": resolveAvatarURLWithRequest(c, 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,
|
||
}
|
||
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,
|
||
},
|
||
})
|
||
}
|
||
|
||
var h5PhoneRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||
|
||
// MiniprogramH5RegisterByPhone POST /api/miniprogram/h5/register-by-phone
|
||
// 与 H5 登录同开关:仅允许未占用手机号的纯 H5 账号(无微信 open_id);注册成功后返回与 login-by-phone 一致的 data。
|
||
func MiniprogramH5RegisterByPhone(c *gin.Context) {
|
||
if !miniprogramPhoneLoginAllowed() {
|
||
c.JSON(http.StatusForbidden, gin.H{
|
||
"success": false,
|
||
"error": "H5 手机号注册未开启:与本机 h5/login-by-phone 403 说明相同,请开启 development / GIN_MODE=debug / H5_PHONE_LOGIN_ENABLED",
|
||
})
|
||
return
|
||
}
|
||
var req struct {
|
||
Phone string `json:"phone" binding:"required"`
|
||
Password string `json:"password" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||
return
|
||
}
|
||
phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", ""))
|
||
if !h5PhoneRegexp.MatchString(phone) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入有效的大陆11位手机号"})
|
||
return
|
||
}
|
||
pwd := strings.TrimSpace(req.Password)
|
||
if len(pwd) < 6 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码至少6位"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
var existed model.User
|
||
if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).
|
||
First(&existed).Error; err == nil {
|
||
c.JSON(http.StatusConflict, gin.H{"success": false, "error": "该手机号已注册,请直接登录"})
|
||
return
|
||
}
|
||
hp, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"})
|
||
return
|
||
}
|
||
hs := string(hp)
|
||
|
||
nickname := fmt.Sprintf("用户%s", phone[len(phone)-4:])
|
||
avatar := ""
|
||
hasFullBook := false
|
||
earnings := 0.0
|
||
pendingEarnings := 0.0
|
||
referralCount := 0
|
||
purchasedJSON := "[]"
|
||
|
||
var createdUser model.User
|
||
created := false
|
||
for attempt := 0; attempt < 12; attempt++ {
|
||
userID := "user_" + randomSuffix()
|
||
refBase := strings.ToUpper(randomSuffix())
|
||
if len(refBase) < 6 {
|
||
refBase = refBase + strings.ToUpper(randomSuffix())
|
||
}
|
||
rc := "SOUL" + refBase
|
||
rc = strings.TrimSpace(rc)
|
||
if len(rc) > 20 {
|
||
rc = rc[:20]
|
||
}
|
||
var dupRC model.User
|
||
if db.Where("referral_code = ?", rc).First(&dupRC).Error == nil {
|
||
continue
|
||
}
|
||
|
||
ph := phone
|
||
u := model.User{
|
||
ID: userID,
|
||
Phone: &ph,
|
||
PasswordHash: &hs,
|
||
Nickname: &nickname,
|
||
Avatar: &avatar,
|
||
ReferralCode: &rc,
|
||
HasFullBook: &hasFullBook,
|
||
PurchasedSections: &purchasedJSON,
|
||
Earnings: &earnings,
|
||
PendingEarnings: &pendingEarnings,
|
||
ReferralCount: &referralCount,
|
||
}
|
||
|
||
if err := db.Create(&u).Error; err != nil {
|
||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||
continue
|
||
}
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "注册失败,请稍后重试"})
|
||
return
|
||
}
|
||
createdUser = u
|
||
created = true
|
||
break
|
||
}
|
||
if !created {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "注册繁忙,请稍后重试"})
|
||
return
|
||
}
|
||
|
||
AdminShensheShouAutoTag(createdUser.ID, phone)
|
||
|
||
// 对齐 login-by-phone 的 token 与用户 payload
|
||
openID := getStringValue(createdUser.OpenID)
|
||
if openID == "" {
|
||
openID = createdUser.ID
|
||
}
|
||
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'`, createdUser.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": createdUser.ID,
|
||
"openId": openID,
|
||
"nickname": nickname,
|
||
"avatar": resolveAvatarURLWithRequest(c, avatar),
|
||
"phone": phone,
|
||
"wechatId": "",
|
||
"referralCode": getStringValue(createdUser.ReferralCode),
|
||
"hasFullBook": false,
|
||
"purchasedSections": purchasedSections,
|
||
"earnings": 0.0,
|
||
"pendingEarnings": 0.0,
|
||
"referralCount": 0,
|
||
"createdAt": createdUser.CreatedAt,
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": map[string]interface{}{
|
||
"openId": openID,
|
||
"user": responseUser,
|
||
"token": token,
|
||
},
|
||
})
|
||
}
|
||
|
||
// MiniprogramH5ResetPassword POST /api/miniprogram/h5/reset-password
|
||
// 与 H5 手机号登录同开关;无短信/邮箱验码,依赖网关限流与 H5_PHONE_LOGIN_ENABLED 管控。
|
||
func MiniprogramH5ResetPassword(c *gin.Context) {
|
||
if !miniprogramPhoneLoginAllowed() {
|
||
c.JSON(http.StatusForbidden, gin.H{
|
||
"success": false,
|
||
"error": "H5 密码重置与登录共用开关,请见 h5/login-by-phone 403 说明或设置 H5_PHONE_LOGIN_ENABLED=1",
|
||
})
|
||
return
|
||
}
|
||
var req struct {
|
||
Phone string `json:"phone" binding:"required"`
|
||
NewPassword string `json:"newPassword" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||
return
|
||
}
|
||
phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", ""))
|
||
pwd := strings.TrimSpace(req.NewPassword)
|
||
if phone == "" || len(pwd) < 6 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请输入手机号及至少6位新密码"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
var user model.User
|
||
if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).First(&user).Error; err == nil {
|
||
hp, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "服务器繁忙"})
|
||
return
|
||
}
|
||
hashStr := string(hp)
|
||
if err := saveH5UserPasswordHash(db, user.ID, hashStr); err != nil {
|
||
log.Printf("MiniprogramH5ResetPassword: update password_hash user=%s err=%v", user.ID, err)
|
||
h := gin.H{"success": false, "error": "更新失败"}
|
||
if gin.Mode() == gin.DebugMode {
|
||
h["detail"] = err.Error()
|
||
}
|
||
c.JSON(http.StatusInternalServerError, h)
|
||
return
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "若该手机号已注册,已更新登录密码,请使用新密码登录",
|
||
})
|
||
}
|
||
|
||
// 辅助函数
|
||
func getStringValue(ptr *string) string {
|
||
if ptr == nil {
|
||
return ""
|
||
}
|
||
return *ptr
|
||
}
|
||
|
||
func getUrlValue(ptr *string) string {
|
||
if ptr == nil {
|
||
return ""
|
||
}
|
||
return *ptr
|
||
}
|
||
|
||
func getBoolValue(ptr *bool) bool {
|
||
if ptr == nil {
|
||
return false
|
||
}
|
||
return *ptr
|
||
}
|
||
|
||
func getFloatValue(ptr *float64) float64 {
|
||
if ptr == nil {
|
||
return 0.0
|
||
}
|
||
return *ptr
|
||
}
|
||
|
||
func getIntValue(ptr *int) int {
|
||
if ptr == nil {
|
||
return 0
|
||
}
|
||
return *ptr
|
||
}
|
||
|
||
// MiniprogramPay GET/POST /api/miniprogram/pay
|
||
func MiniprogramPay(c *gin.Context) {
|
||
if c.Request.Method == "POST" {
|
||
miniprogramPayPost(c)
|
||
} else {
|
||
miniprogramPayGet(c)
|
||
}
|
||
}
|
||
|
||
// POST - 创建小程序支付订单
|
||
func miniprogramPayPost(c *gin.Context) {
|
||
var req struct {
|
||
OpenID string `json:"openId" binding:"required"`
|
||
ProductType string `json:"productType" binding:"required"`
|
||
ProductID string `json:"productId"`
|
||
Amount float64 `json:"amount" binding:"required"`
|
||
Description string `json:"description"`
|
||
UserID string `json:"userId"`
|
||
ReferralCode string `json:"referralCode"`
|
||
TipSource string `json:"tipSource"`
|
||
}
|
||
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少openId参数,请先登录"})
|
||
return
|
||
}
|
||
|
||
req.ProductType = normalizePayProductType(req.ProductType)
|
||
if isLinkKaruoTipProductType(req.ProductType) {
|
||
req.ProductType = "link_karuo_tip"
|
||
}
|
||
|
||
if req.Amount <= 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "支付金额无效"})
|
||
return
|
||
}
|
||
|
||
db := database.DB()
|
||
|
||
// 尽早解析 userId,便于 mentor_consultation 等按用户校验预约单与标准价
|
||
userIDForPrice := strings.TrimSpace(req.UserID)
|
||
if userIDForPrice == "" && strings.TrimSpace(req.OpenID) != "" {
|
||
var u model.User
|
||
if err := db.Where("open_id = ?", strings.TrimSpace(req.OpenID)).First(&u).Error; err == nil {
|
||
userIDForPrice = u.ID
|
||
}
|
||
}
|
||
|
||
productID := strings.TrimSpace(req.ProductID)
|
||
if req.ProductType == "link_karuo_tip" && strings.TrimSpace(req.TipSource) == "live_mic" && productID != "" && !strings.HasPrefix(productID, "live_mic|") {
|
||
productID = "live_mic|" + productID
|
||
}
|
||
|
||
var finalAmount float64
|
||
var orderSn string
|
||
var referrerID *string
|
||
// 非充值单记录标准价:若最终发现自推,需撤销误用的好友折扣
|
||
var standardPriceSnapshot float64
|
||
|
||
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 读取标准价 --------
|
||
pricePID := productID
|
||
if pricePID == "" {
|
||
pricePID = strings.TrimSpace(req.ProductID)
|
||
}
|
||
standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID, userIDForPrice, req.TipSource)
|
||
if priceErr != nil && req.ProductType == "link_karuo_tip" {
|
||
if fb, _, canonID, ferr := linkKaruoTipFallbackByAmount(db, pricePID, req.Amount, req.TipSource); ferr == nil {
|
||
standardPrice = fb
|
||
priceErr = nil
|
||
_, qty, qe := parseLinkKaruoGiftQty(pricePID)
|
||
if qe != nil {
|
||
qty = 1
|
||
}
|
||
if strings.TrimSpace(req.TipSource) == "live_mic" {
|
||
productID = "live_mic|" + canonID + "|" + strconv.FormatInt(qty, 10)
|
||
} else {
|
||
productID = canonID + "|" + strconv.FormatInt(qty, 10)
|
||
}
|
||
}
|
||
}
|
||
if priceErr != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
|
||
return
|
||
}
|
||
finalAmount = standardPrice
|
||
standardPriceSnapshot = standardPrice
|
||
|
||
// 打赏不参与分销好友折扣(实付=标准价)
|
||
if req.ProductType != "link_karuo_tip" {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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, pricePID, req.UserID)
|
||
}
|
||
orderSn = wechat.GenerateOrderSn()
|
||
}
|
||
|
||
// 获取客户端 IP
|
||
clientIP := c.ClientIP()
|
||
if clientIP == "" {
|
||
clientIP = "127.0.0.1"
|
||
}
|
||
|
||
// userID:优先用客户端传入;为空时按 openid 查用户(排除软删除,避免订单归属到旧账号)
|
||
userID := strings.TrimSpace(req.UserID)
|
||
if userID == "" && userIDForPrice != "" {
|
||
userID = userIDForPrice
|
||
}
|
||
if userID == "" && req.OpenID != "" {
|
||
var u model.User
|
||
if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil {
|
||
userID = u.ID
|
||
} else {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请先登录后再支付"})
|
||
return
|
||
}
|
||
}
|
||
|
||
// 自购自推:不写 referrer、不按好友价(兼容脏绑定或误传自己的推荐码;userID 以最终解析为准)
|
||
if req.ProductType != "balance_recharge" && req.ProductType != "link_karuo_tip" && referrerID != nil && userID != "" &&
|
||
isSelfReferralBuyer(userID, *referrerID) {
|
||
fmt.Printf("[MiniprogramPay] 自推无效: userId=%s 忽略 referrer\n", userID)
|
||
referrerID = nil
|
||
if standardPriceSnapshot > 0 {
|
||
finalAmount = standardPriceSnapshot
|
||
}
|
||
}
|
||
|
||
totalFee := int(finalAmount * 100) // 转为分(须在自推纠正 finalAmount 之后)
|
||
|
||
if req.ProductType != "balance_recharge" {
|
||
if productID == "" {
|
||
switch req.ProductType {
|
||
case "vip":
|
||
productID = "vip_annual"
|
||
case "match":
|
||
productID = "match"
|
||
case "link_karuo_tip":
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "打赏缺少礼物标识"})
|
||
return
|
||
case "mentor_consultation":
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少预约单 ID(productId)"})
|
||
return
|
||
default:
|
||
productID = "fullbook"
|
||
}
|
||
}
|
||
}
|
||
|
||
description := strings.TrimSpace(req.Description)
|
||
if description == "" {
|
||
switch req.ProductType {
|
||
case "balance_recharge":
|
||
description = fmt.Sprintf("余额充值 ¥%.2f", finalAmount)
|
||
case "fullbook":
|
||
description = "加入读书会"
|
||
case "vip":
|
||
description = "卡若创业派对VIP年度会员(365天)"
|
||
case "match":
|
||
description = "购买匹配次数"
|
||
case "mentor_consultation":
|
||
description = "导师咨询服务"
|
||
case "link_karuo_tip":
|
||
giftID := productID
|
||
if strings.HasPrefix(giftID, "live_mic|") {
|
||
parts := strings.Split(giftID, "|")
|
||
if len(parts) >= 2 {
|
||
giftID = strings.TrimSpace(parts[1])
|
||
}
|
||
} else {
|
||
parts := strings.Split(giftID, "|")
|
||
giftID = strings.TrimSpace(parts[0])
|
||
}
|
||
g, _ := findLinkKaruoGiftInList(mergedLinkKaruoGiftsForPay(db, req.TipSource), giftID)
|
||
name := "打赏"
|
||
if g != nil {
|
||
if n, ok := g["name"].(string); ok && strings.TrimSpace(n) != "" {
|
||
name = strings.TrimSpace(n)
|
||
}
|
||
}
|
||
if strings.Contains(productID, "live_mic|") {
|
||
description = "上麦礼遇 · " + name
|
||
} else {
|
||
description = "首页打赏 · " + name
|
||
}
|
||
default:
|
||
description = fmt.Sprintf("章节购买-%s", productID)
|
||
}
|
||
}
|
||
|
||
// 充值订单已存在,不重复创建
|
||
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)
|
||
}
|
||
}
|
||
|
||
type payAttach struct {
|
||
ProductType string `json:"pt"`
|
||
ProductID string `json:"pid,omitempty"`
|
||
UserID string `json:"uid,omitempty"`
|
||
TipSource string `json:"ts,omitempty"`
|
||
}
|
||
attachBytes, _ := json.Marshal(payAttach{
|
||
ProductType: req.ProductType,
|
||
ProductID: productID,
|
||
UserID: userID,
|
||
TipSource: strings.TrimSpace(req.TipSource),
|
||
})
|
||
attach := string(attachBytes)
|
||
if len(attach) > 128 {
|
||
shortAttach, _ := json.Marshal(payAttach{
|
||
ProductType: req.ProductType,
|
||
UserID: userID,
|
||
})
|
||
attach = string(shortAttach)
|
||
if len(attach) > 128 {
|
||
attach = fmt.Sprintf(`{"pt":"%s"}`, req.ProductType)
|
||
}
|
||
}
|
||
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"
|
||
// V1.3 修复:主动同步到本地 orders,并激活对应权益(VIP/全书),避免等待 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,
|
||
})
|
||
order.Status = strToPtr("paid")
|
||
order.PayTime = &now
|
||
orderPollLogf("主动同步订单已支付: %s", orderSn)
|
||
// 激活权益
|
||
if order.UserID != "" {
|
||
activateOrderBenefits(db, &order, now)
|
||
}
|
||
}
|
||
case "CLOSED", "REVOKED", "PAYERROR":
|
||
status = "failed"
|
||
case "REFUND":
|
||
status = "refunded"
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": map[string]interface{}{
|
||
"status": status,
|
||
"orderSn": orderSn,
|
||
"transactionId": transactionID,
|
||
"totalFee": totalFee,
|
||
},
|
||
})
|
||
}
|
||
|
||
// MiniprogramPayNotify POST /api/miniprogram/pay/notify(v3 支付回调,PowerWeChat 验签解密)
|
||
func MiniprogramPayNotify(c *gin.Context) {
|
||
resp, err := wechat.HandlePayNotify(c.Request, func(orderSn, transactionID string, totalFee int, attachStr, openID string) error {
|
||
totalAmount := float64(totalFee) / 100
|
||
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
|
||
|
||
var attach struct {
|
||
ProductType string `json:"productType"`
|
||
ProductID string `json:"productId"`
|
||
UserID string `json:"userId"`
|
||
TipSource string `json:"tipSource"`
|
||
GiftPayRequestSn string `json:"giftPayRequestSn"`
|
||
GiftPayInitiatorPay bool `json:"giftPayInitiatorPay"`
|
||
PT string `json:"pt"`
|
||
PID string `json:"pid"`
|
||
UID string `json:"uid"`
|
||
TS string `json:"ts"`
|
||
SN string `json:"sn"`
|
||
IP int `json:"ip"`
|
||
}
|
||
if attachStr != "" {
|
||
_ = json.Unmarshal([]byte(attachStr), &attach)
|
||
if attach.ProductType == "" {
|
||
if attach.PT == "gpb" {
|
||
attach.ProductType = "gift_pay_batch"
|
||
} else {
|
||
attach.ProductType = attach.PT
|
||
}
|
||
}
|
||
if attach.ProductID == "" {
|
||
attach.ProductID = attach.PID
|
||
}
|
||
if attach.UserID == "" {
|
||
attach.UserID = attach.UID
|
||
}
|
||
if attach.TipSource == "" {
|
||
attach.TipSource = attach.TS
|
||
}
|
||
if attach.GiftPayRequestSn == "" {
|
||
attach.GiftPayRequestSn = attach.SN
|
||
}
|
||
if attach.IP != 0 {
|
||
attach.GiftPayInitiatorPay = true
|
||
}
|
||
}
|
||
|
||
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()
|
||
updates := map[string]interface{}{
|
||
"status": status,
|
||
"transaction_id": transactionID,
|
||
"pay_time": now,
|
||
}
|
||
if err := db.Model(&order).Updates(updates).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)
|
||
}
|
||
|
||
// 代付订单:更新 gift_pay_request、订单 payer_user_id
|
||
// 权益归属与分佣:旧版好友付归发起人;新版发起人付不发放权益(好友领取时再发)
|
||
giftPayRequestSn := attach.GiftPayRequestSn
|
||
if giftPayRequestSn == "" && order.GiftPayRequestID != nil && *order.GiftPayRequestID != "" {
|
||
var gpr model.GiftPayRequest
|
||
if err := db.Where("id = ?", *order.GiftPayRequestID).Select("request_sn").First(&gpr).Error; err == nil {
|
||
giftPayRequestSn = gpr.RequestSN
|
||
}
|
||
}
|
||
beneficiaryUserID := buyerUserID
|
||
if giftPayRequestSn != "" && order.UserID != "" && !attach.GiftPayInitiatorPay {
|
||
beneficiaryUserID = order.UserID
|
||
fmt.Printf("[PayNotify] 代付订单(好友付),权益归属发起人: %s\n", beneficiaryUserID)
|
||
}
|
||
if attach.GiftPayInitiatorPay {
|
||
fmt.Printf("[PayNotify] 代付订单(发起人付),不发放权益,好友领取时再发\n")
|
||
}
|
||
if 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 = ?", giftPayRequestSn).
|
||
Updates(map[string]interface{}{
|
||
"status": "paid",
|
||
"payer_user_id": payerUserID,
|
||
"order_id": orderSn,
|
||
"updated_at": time.Now(),
|
||
})
|
||
}
|
||
|
||
if beneficiaryUserID != "" && attach.ProductType != "" {
|
||
if attach.ProductType == "fullbook" {
|
||
db.Model(&model.User{}).Where("id = ?", beneficiaryUserID).Update("has_full_book", true)
|
||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", beneficiaryUserID)
|
||
} else if attach.ProductType == "vip" {
|
||
vipActivatedAt := time.Now()
|
||
if order.PayTime != nil {
|
||
vipActivatedAt = *order.PayTime
|
||
}
|
||
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"))
|
||
// 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力)
|
||
if err := ensurePersonForUser(db, beneficiaryUserID); err != nil {
|
||
fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, orderSn=%s, err=%v\n", beneficiaryUserID, orderSn, err)
|
||
}
|
||
} else if attach.ProductType == "match" {
|
||
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)
|
||
}
|
||
} 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 != ?",
|
||
beneficiaryUserID, attach.ProductID, orderSn,
|
||
).Count(&count)
|
||
if count == 0 {
|
||
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", beneficiaryUserID, attach.ProductID)
|
||
} else {
|
||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", beneficiaryUserID, attach.ProductID)
|
||
}
|
||
} else if attach.ProductType == "link_karuo_tip" {
|
||
fmt.Printf("[PayNotify] 打赏/上麦礼遇订单完成: user=%s order=%s\n", beneficiaryUserID, orderSn)
|
||
} else if attach.ProductType == "mentor_consultation" && attach.ProductID != "" {
|
||
if cid, err := strconv.Atoi(strings.TrimSpace(attach.ProductID)); err == nil && cid > 0 {
|
||
_ = db.Model(&model.MentorConsultation{}).
|
||
Where("id = ? AND user_id = ?", cid, beneficiaryUserID).
|
||
Updates(map[string]interface{}{"status": "paid", "updated_at": time.Now()})
|
||
fmt.Printf("[PayNotify] 导师预约已支付: user=%s consultId=%d order=%s\n", beneficiaryUserID, cid, orderSn)
|
||
}
|
||
}
|
||
productID := attach.ProductID
|
||
if productID == "" {
|
||
productID = "fullbook"
|
||
}
|
||
db.Where(
|
||
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
|
||
beneficiaryUserID, attach.ProductType, productID, orderSn,
|
||
).Delete(&model.Order{})
|
||
processReferralCommission(db, beneficiaryUserID, totalAmount, orderSn, &order)
|
||
}
|
||
// 支付成功后实时推送到 webhook;失败记录,交给定时补偿任务统一重推
|
||
if pushErr := pushPaidOrderWebhook(db, &order); pushErr != nil {
|
||
fmt.Printf("[PayNotify] webhook 推送失败: orderSn=%s, err=%v\n", orderSn, pushErr)
|
||
markOrderWebhookResult(db, orderSn, false, pushErr)
|
||
} else {
|
||
markOrderWebhookResult(db, orderSn, true, nil)
|
||
}
|
||
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) {
|
||
if order != nil && order.ProductType == "link_karuo_tip" {
|
||
fmt.Printf("[PayNotify] 打赏订单跳过分销佣金: orderSn=%s\n", orderSn)
|
||
return
|
||
}
|
||
if order != nil && order.ProductType == "mentor_consultation" {
|
||
fmt.Printf("[PayNotify] 导师咨询订单跳过分销佣金: orderSn=%s\n", orderSn)
|
||
return
|
||
}
|
||
type Binding struct {
|
||
ID string `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 isSelfReferralBuyer(buyerUserID, binding.ReferrerID) {
|
||
fmt.Printf("[PayNotify] 自购自推,跳过分佣: buyer=%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)
|
||
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
|
||
// 绑定手机号后,异步调用神射手自动完善标签
|
||
AdminShensheShouAutoTag(req.UserID, phoneNumber)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// 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,
|
||
})
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// 与 /vip/status 一致:显式 is_vip=false 时不认订单兜底
|
||
isVipActive, _ := isVipEffective(db, id)
|
||
// 用户信息与会员资料(vip*)、P3 资料扩展,供会员详情页完整展示
|
||
item := gin.H{
|
||
"id": user.ID,
|
||
"ckbLeadToken": personLeadTokenByUserID(db, user.ID),
|
||
"nickname": getStringValue(user.Nickname),
|
||
"avatar": resolveAvatarURLWithRequest(c, getStringValue(user.Avatar)),
|
||
"phone": getStringValue(user.Phone),
|
||
"wechatId": getStringValue(user.WechatID),
|
||
"vipName": getStringValue(user.VipName),
|
||
"vipAvatar": resolveAvatarURLWithRequest(c, getStringValue(user.VipAvatar)),
|
||
"vipContact": getStringValue(user.VipContact),
|
||
"vipProject": getStringValue(user.VipProject),
|
||
"vipBio": getStringValue(user.VipBio),
|
||
"mbti": getStringValue(user.Mbti),
|
||
"region": getStringValue(user.Region),
|
||
"industry": getStringValue(user.Industry),
|
||
"position": getStringValue(user.Position),
|
||
"businessScale": getStringValue(user.BusinessScale),
|
||
"skills": getStringValue(user.Skills),
|
||
"storyBestMonth": getStringValue(user.StoryBestMonth),
|
||
"storyAchievement": getStringValue(user.StoryAchievement),
|
||
"storyTurning": getStringValue(user.StoryTurning),
|
||
"helpOffer": getStringValue(user.HelpOffer),
|
||
"helpNeed": getStringValue(user.HelpNeed),
|
||
"projectIntro": getStringValue(user.ProjectIntro),
|
||
"is_vip": isVipActive,
|
||
}
|
||
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]
|
||
uvip, _ := isVipEffective(db, u.ID)
|
||
list = append(list, gin.H{
|
||
"id": u.ID,
|
||
"nickname": getStringValue(u.Nickname),
|
||
"avatar": resolveAvatarURLWithRequest(c, getStringValue(u.Avatar)),
|
||
"is_vip": uvip,
|
||
})
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
|
||
}
|
||
|
||
// 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)
|
||
// 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力,不阻断权益)
|
||
if err := ensurePersonForUser(db, userID); err != nil {
|
||
fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, err=%v\n", userID, err)
|
||
}
|
||
case "balance_recharge":
|
||
ConfirmBalanceRechargeByOrder(db, order)
|
||
case "link_karuo_tip":
|
||
// 首页打赏 / 上麦礼遇:仅收款,无额外会员或章节权益
|
||
case "mentor_consultation":
|
||
if order.ProductID == nil || strings.TrimSpace(*order.ProductID) == "" {
|
||
return
|
||
}
|
||
cid, err := strconv.Atoi(strings.TrimSpace(*order.ProductID))
|
||
if err != nil || cid <= 0 {
|
||
return
|
||
}
|
||
_ = db.Model(&model.MentorConsultation{}).
|
||
Where("id = ? AND user_id = ?", cid, userID).
|
||
Updates(map[string]interface{}{"status": "paid", "updated_at": payTime})
|
||
}
|
||
}
|
||
|
||
// loadMpConfigRoot 读取 system_config.mp_config 原始 JSON(用于礼物价、上麦 Webhook 等)
|
||
func loadMpConfigRoot(db *gorm.DB) map[string]interface{} {
|
||
var row model.SystemConfig
|
||
if err := db.Where("config_key = ?", "mp_config").First(&row).Error; err != nil {
|
||
return nil
|
||
}
|
||
var m map[string]interface{}
|
||
if err := json.Unmarshal(row.ConfigValue, &m); err != nil {
|
||
return nil
|
||
}
|
||
return m
|
||
}
|
||
|
||
func parseMoneyAny(v interface{}) float64 {
|
||
if v == nil {
|
||
return 0
|
||
}
|
||
switch t := v.(type) {
|
||
case float64:
|
||
return t
|
||
case int:
|
||
return float64(t)
|
||
case int64:
|
||
return float64(t)
|
||
case json.Number:
|
||
f, _ := t.Float64()
|
||
return f
|
||
case string:
|
||
f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
|
||
if err == nil {
|
||
return f
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func defaultLinkKaruoGifts() []map[string]interface{} {
|
||
return []map[string]interface{}{
|
||
{"id": "gift_star", "name": "人气票", "price": 99, "emoji": "🎫", "icon": "", "micOrder": 0, "iconScale": 1, "micFeatured": false},
|
||
{"id": "gift_lite", "name": "小心心", "price": 9.9, "emoji": "❤️", "icon": "", "micOrder": 1, "iconScale": 1, "micFeatured": true},
|
||
{"id": "gift_super", "name": "嘉年华", "price": 1980, "emoji": "🎡", "icon": "", "micOrder": 2, "iconScale": 1, "micFeatured": false},
|
||
}
|
||
}
|
||
|
||
func linkKaruoRewardSection(root map[string]interface{}) map[string]interface{} {
|
||
if root == nil {
|
||
return nil
|
||
}
|
||
mpUi, _ := root["mpUi"].(map[string]interface{})
|
||
if mpUi == nil {
|
||
return nil
|
||
}
|
||
home, _ := mpUi["homePage"].(map[string]interface{})
|
||
if home == nil {
|
||
return nil
|
||
}
|
||
rw, _ := home["linkKaruoReward"].(map[string]interface{})
|
||
return rw
|
||
}
|
||
|
||
func linkKaruoGiftsList(root map[string]interface{}) []map[string]interface{} {
|
||
sec := linkKaruoRewardSection(root)
|
||
if sec == nil {
|
||
return defaultLinkKaruoGifts()
|
||
}
|
||
raw, ok := sec["gifts"]
|
||
if !ok || raw == nil {
|
||
return defaultLinkKaruoGifts()
|
||
}
|
||
arr, ok := raw.([]interface{})
|
||
if !ok || len(arr) == 0 {
|
||
return defaultLinkKaruoGifts()
|
||
}
|
||
out := make([]map[string]interface{}, 0, len(arr))
|
||
for _, it := range arr {
|
||
m, ok := it.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
out = append(out, m)
|
||
}
|
||
if len(out) == 0 {
|
||
return defaultLinkKaruoGifts()
|
||
}
|
||
return out
|
||
}
|
||
|
||
func cloneGiftMap(m map[string]interface{}) map[string]interface{} {
|
||
if m == nil {
|
||
return nil
|
||
}
|
||
out := make(map[string]interface{}, len(m))
|
||
for k, v := range m {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// mergeGiftOverlayOrdered 顺序以置顶人物 overlay 为准,同 id 用 overlay 字段覆盖全局 base;仅存在于 base 的 id 按原顺序接在末尾
|
||
func mergeGiftOverlayOrdered(base []map[string]interface{}, overlay []map[string]interface{}) []map[string]interface{} {
|
||
if len(overlay) == 0 {
|
||
return base
|
||
}
|
||
baseByID := make(map[string]map[string]interface{})
|
||
for _, g := range base {
|
||
id, _ := g["id"].(string)
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
continue
|
||
}
|
||
baseByID[id] = g
|
||
}
|
||
seen := make(map[string]bool)
|
||
out := make([]map[string]interface{}, 0, len(overlay)+len(base))
|
||
for _, og := range overlay {
|
||
id, _ := og["id"].(string)
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
continue
|
||
}
|
||
var merged map[string]interface{}
|
||
if b, ok := baseByID[id]; ok {
|
||
merged = cloneGiftMap(b)
|
||
} else {
|
||
merged = make(map[string]interface{})
|
||
}
|
||
for k, v := range og {
|
||
merged[k] = v
|
||
}
|
||
if _, ok := merged["id"]; !ok {
|
||
merged["id"] = id
|
||
}
|
||
out = append(out, merged)
|
||
seen[id] = true
|
||
}
|
||
for _, g := range base {
|
||
id, _ := g["id"].(string)
|
||
id = strings.TrimSpace(id)
|
||
if id == "" || seen[id] {
|
||
continue
|
||
}
|
||
cm := cloneGiftMap(g)
|
||
if cm != nil {
|
||
out = append(out, cm)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func loadPinnedPersonHomeEntryGifts(db *gorm.DB) []map[string]interface{} {
|
||
if db == nil {
|
||
return nil
|
||
}
|
||
var p model.Person
|
||
if err := db.Where("is_pinned = ?", true).Order("updated_at DESC").First(&p).Error; err != nil {
|
||
return nil
|
||
}
|
||
if p.HomeEntryConfig == nil || strings.TrimSpace(*p.HomeEntryConfig) == "" {
|
||
return nil
|
||
}
|
||
var m map[string]interface{}
|
||
if json.Unmarshal([]byte(*p.HomeEntryConfig), &m) != nil {
|
||
return nil
|
||
}
|
||
rw, _ := m["linkKaruoReward"].(map[string]interface{})
|
||
if rw == nil {
|
||
return nil
|
||
}
|
||
raw, ok := rw["gifts"]
|
||
if !ok || raw == nil {
|
||
return nil
|
||
}
|
||
arr, ok := raw.([]interface{})
|
||
if !ok || len(arr) == 0 {
|
||
return nil
|
||
}
|
||
out := make([]map[string]interface{}, 0, len(arr))
|
||
for _, it := range arr {
|
||
g, ok := it.(map[string]interface{})
|
||
if ok {
|
||
out = append(out, g)
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// mergedLinkKaruoGiftsForPay 首页横排礼物/上麦:全局 mp_config 与置顶人物 home_entry_config 按 id 合并,计价与小程序展示一致
|
||
func mergedLinkKaruoGiftsForPay(db *gorm.DB, tipSource string) []map[string]interface{} {
|
||
root := loadMpConfigRoot(db)
|
||
base := linkKaruoGiftsList(root)
|
||
ts := strings.TrimSpace(tipSource)
|
||
if ts != "live_mic" && ts != "home_reward" {
|
||
return base
|
||
}
|
||
ov := loadPinnedPersonHomeEntryGifts(db)
|
||
if len(ov) == 0 {
|
||
return base
|
||
}
|
||
return mergeGiftOverlayOrdered(base, ov)
|
||
}
|
||
|
||
func findLinkKaruoGiftInList(gifts []map[string]interface{}, giftID string) (map[string]interface{}, float64) {
|
||
giftID = strings.TrimSpace(giftID)
|
||
if giftID == "" {
|
||
return nil, 0
|
||
}
|
||
for _, g := range gifts {
|
||
id, _ := g["id"].(string)
|
||
id = strings.TrimSpace(id)
|
||
if id == giftID || strings.EqualFold(id, giftID) {
|
||
price := parseMoneyAny(g["price"])
|
||
if price > 0 {
|
||
return g, price
|
||
}
|
||
break
|
||
}
|
||
}
|
||
return nil, 0
|
||
}
|
||
|
||
func findLinkKaruoGift(root map[string]interface{}, giftID string) (map[string]interface{}, float64) {
|
||
return findLinkKaruoGiftInList(linkKaruoGiftsList(root), giftID)
|
||
}
|
||
|
||
const linkKaruoAmountEpsilon = 0.051
|
||
|
||
// parseLinkKaruoGiftQty 解析 giftId|qty 或 live_mic|giftId|qty
|
||
func parseLinkKaruoGiftQty(productID string) (giftID string, qty int64, err error) {
|
||
s := strings.TrimSpace(productID)
|
||
parts := strings.Split(s, "|")
|
||
if len(parts) >= 2 && strings.TrimSpace(parts[0]) == "live_mic" {
|
||
parts = parts[1:]
|
||
}
|
||
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
||
return "", 0, fmt.Errorf("打赏商品格式错误")
|
||
}
|
||
giftID = strings.TrimSpace(parts[0])
|
||
qty = 1
|
||
if len(parts) >= 2 {
|
||
q, e := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||
if e != nil || q < 1 || q > 99 {
|
||
return "", 0, fmt.Errorf("打赏数量无效")
|
||
}
|
||
qty = q
|
||
}
|
||
return giftID, qty, nil
|
||
}
|
||
|
||
// linkKaruoTipFallbackByAmount 客户端礼物 id 与库不一致时,按金额唯一匹配后台档位并返回规范 id
|
||
func linkKaruoTipFallbackByAmount(db *gorm.DB, productID string, clientAmount float64, tipSource string) (total float64, giftName string, canonicalID string, err error) {
|
||
if clientAmount <= 0 {
|
||
return 0, "", "", fmt.Errorf("支付金额无效")
|
||
}
|
||
wantID, qty, err := parseLinkKaruoGiftQty(productID)
|
||
if err != nil {
|
||
return 0, "", "", err
|
||
}
|
||
type hit struct {
|
||
id string
|
||
unit float64
|
||
name string
|
||
}
|
||
var hits []hit
|
||
for _, g := range mergedLinkKaruoGiftsForPay(db, tipSource) {
|
||
id, _ := g["id"].(string)
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
continue
|
||
}
|
||
unit := parseMoneyAny(g["price"])
|
||
if unit <= 0 {
|
||
continue
|
||
}
|
||
t := unit * float64(qty)
|
||
if math.Abs(t-clientAmount) < linkKaruoAmountEpsilon {
|
||
nm, _ := g["name"].(string)
|
||
hits = append(hits, hit{id, unit, strings.TrimSpace(nm)})
|
||
}
|
||
}
|
||
if len(hits) == 0 {
|
||
return 0, "", "", fmt.Errorf("打赏金额与后台档位不一致,请检查全局礼物与置顶人物礼物配置后重试")
|
||
}
|
||
if len(hits) == 1 {
|
||
h := hits[0]
|
||
name := h.name
|
||
if name == "" {
|
||
name = "礼物"
|
||
}
|
||
return h.unit * float64(qty), name, h.id, nil
|
||
}
|
||
for _, h := range hits {
|
||
if strings.EqualFold(h.id, wantID) {
|
||
name := h.name
|
||
if name == "" {
|
||
name = "礼物"
|
||
}
|
||
return h.unit * float64(qty), name, h.id, nil
|
||
}
|
||
}
|
||
return 0, "", "", fmt.Errorf("后台存在同价多档礼物,请为每档设置不同价格或唯一 id")
|
||
}
|
||
|
||
// linkKaruoTipPriceTotal 打赏标准价:普通 giftId|qty;上麦 live_mic|giftId|qty(tipSource 为 live_mic/home_reward 时合并置顶人物礼物价)
|
||
func linkKaruoTipPriceTotal(db *gorm.DB, productID string, tipSource string) (float64, error) {
|
||
productID = strings.TrimSpace(productID)
|
||
if productID == "" {
|
||
return 0, fmt.Errorf("打赏缺少礼物标识")
|
||
}
|
||
var giftID string
|
||
var qty int64 = 1
|
||
if strings.HasPrefix(productID, "live_mic|") {
|
||
parts := strings.Split(productID, "|")
|
||
if len(parts) < 2 {
|
||
return 0, fmt.Errorf("上麦礼物参数无效")
|
||
}
|
||
giftID = strings.TrimSpace(parts[1])
|
||
if len(parts) >= 3 {
|
||
if q, err := strconv.ParseInt(strings.TrimSpace(parts[2]), 10, 64); err == nil && q > 0 {
|
||
qty = q
|
||
}
|
||
}
|
||
} else {
|
||
parts := strings.Split(productID, "|")
|
||
giftID = strings.TrimSpace(parts[0])
|
||
if len(parts) >= 2 {
|
||
if q, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); err == nil && q > 0 {
|
||
qty = q
|
||
}
|
||
}
|
||
}
|
||
gifts := mergedLinkKaruoGiftsForPay(db, tipSource)
|
||
_, price := findLinkKaruoGiftInList(gifts, giftID)
|
||
if price <= 0 {
|
||
return 0, fmt.Errorf("礼物不存在或未定价: %s", giftID)
|
||
}
|
||
return price * float64(qty), nil
|
||
}
|
||
|
||
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
|
||
// productType: fullbook / vip / section / match / link_karuo_tip / mentor_consultation
|
||
// productId: 章节购买时为章节 ID;打赏为 giftId|qty 或 live_mic|giftId|qty;导师咨询为 mentor_consultations.id
|
||
// buyerUserID: mentor_consultation 时校验预约归属;其他类型可传空字符串
|
||
// tipSource 可选:live_mic / home_reward 时 link_karuo_tip 计价与置顶人物礼物配置合并
|
||
func getStandardPrice(db *gorm.DB, productType, productID, buyerUserID string, tipSource ...string) (float64, error) {
|
||
productType = normalizePayProductType(productType)
|
||
ts := ""
|
||
if len(tipSource) > 0 {
|
||
ts = strings.TrimSpace(tipSource[0])
|
||
}
|
||
// 打赏/上麦:显式分支 + 模糊识别双保险,避免误入 default 报「未知商品类型」
|
||
if productType == "link_karuo_tip" || isLinkKaruoTipProductType(productType) {
|
||
return linkKaruoTipPriceTotal(db, productID, ts)
|
||
}
|
||
switch productType {
|
||
case "fullbook", "vip", "match":
|
||
// 价格来源要与小程序 buildMiniprogramConfig 一致:fullbook 优先 site_settings.baseBookPrice(管理端「站点与作者」)
|
||
if productType == "fullbook" {
|
||
var siteRow model.SystemConfig
|
||
if err := db.Where("config_key = ?", "site_settings").First(&siteRow).Error; err == nil && len(siteRow.ConfigValue) > 0 {
|
||
var siteVal map[string]interface{}
|
||
if json.Unmarshal(siteRow.ConfigValue, &siteVal) == nil {
|
||
if v, ok := siteVal["baseBookPrice"].(float64); ok && v > 0 {
|
||
return v, nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 从 system_config 读取(chapter_config / vip_config / match_config)
|
||
configKey := "chapter_config"
|
||
if productType == "vip" {
|
||
configKey = "vip_config"
|
||
} else if productType == "match" {
|
||
// 找伙伴匹配次数价格应使用 match_config
|
||
configKey = "match_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",
|
||
}
|
||
raw := cfg[fieldMap[productType]]
|
||
if raw != nil {
|
||
switch v := raw.(type) {
|
||
case float64:
|
||
if v > 0 {
|
||
return v, nil
|
||
}
|
||
case int:
|
||
if v > 0 {
|
||
return float64(v), nil
|
||
}
|
||
case int64:
|
||
if v > 0 {
|
||
return float64(v), nil
|
||
}
|
||
case json.Number:
|
||
f, err := v.Float64()
|
||
if err == nil && f > 0 {
|
||
return f, nil
|
||
}
|
||
case string:
|
||
f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||
if err == nil && f > 0 {
|
||
return f, nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 兜底默认值
|
||
defaults := map[string]float64{"fullbook": 365, "vip": 1980, "match": 1}
|
||
if p, ok := defaults[productType]; ok {
|
||
return p, nil
|
||
}
|
||
return 0, fmt.Errorf("未知商品类型: %s", productType)
|
||
|
||
case "section":
|
||
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 "mentor_consultation":
|
||
if productID == "" {
|
||
return 0, fmt.Errorf("导师咨询缺少预约单 ID")
|
||
}
|
||
cid, err := strconv.Atoi(strings.TrimSpace(productID))
|
||
if err != nil || cid <= 0 {
|
||
return 0, fmt.Errorf("无效的预约单 ID")
|
||
}
|
||
var mc model.MentorConsultation
|
||
if err := db.Where("id = ?", cid).First(&mc).Error; err != nil {
|
||
return 0, fmt.Errorf("预约单不存在")
|
||
}
|
||
if mc.Status != "" && mc.Status != "created" {
|
||
return 0, fmt.Errorf("预约单状态不可支付")
|
||
}
|
||
if strings.TrimSpace(buyerUserID) != "" && mc.UserID != buyerUserID {
|
||
return 0, fmt.Errorf("预约单与当前用户不匹配")
|
||
}
|
||
if mc.Amount <= 0 {
|
||
return 0, fmt.Errorf("预约金额无效")
|
||
}
|
||
return mc.Amount, nil
|
||
|
||
default:
|
||
return 0, fmt.Errorf("未知商品类型: %s", productType)
|
||
}
|
||
}
|