Files
Mycontent/soul-api/internal/handler/miniprogram.go
乘风 c78a0d531f feat: enhance login modal and avatar selection process
- 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.
2026-05-06 17:21:10 +08:00

2131 lines
67 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package handler
import (
"encoding/json"
"fmt"
"io"
"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_ENVGin 默认 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=debugreactH5 请将 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": "缺少预约单 IDproductId"})
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/notifyv3 支付回调PowerWeChat 验签解密)
func MiniprogramPayNotify(c *gin.Context) {
resp, err := wechat.HandlePayNotify(c.Request, func(orderSn, transactionID string, totalFee int, attachStr, openID string) error {
totalAmount := float64(totalFee) / 100
fmt.Printf("[PayNotify] 支付成功: orderSn=%s, transactionId=%s, amount=%.2f\n", orderSn, transactionID, totalAmount)
var attach struct {
ProductType string `json:"productType"`
ProductID string `json:"productId"`
UserID string `json:"userId"`
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|qtytipSource 为 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)
}
}