Files
soul-yongping/soul-api/internal/handler/miniprogram.go
Alex-larget a8c7dc9306 Merge branch 'yongxu' into devlop
# Conflicts:
#	.cursor/meeting/README.md   resolved by yongxu version
#	.gitignore   resolved by yongxu version
#	miniprogram/pages/index/index.js   resolved by yongxu version
#	miniprogram/pages/read/read.js   resolved by yongxu version
#	miniprogram/pages/read/read.wxml   resolved by yongxu version
#	soul-admin/dist/index.html   resolved by yongxu version
#	soul-admin/src/App.tsx   resolved by yongxu version
#	soul-admin/src/components/RichEditor.css   resolved by yongxu version
#	soul-admin/src/components/RichEditor.tsx   resolved by yongxu version
#	soul-admin/src/components/modules/user/UserDetailModal.tsx   resolved by yongxu version
#	soul-admin/src/layouts/AdminLayout.tsx   resolved by yongxu version
#	soul-admin/src/pages/chapters/ChaptersPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/content/ContentPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/dashboard/DashboardPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/FindPartnerPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/CKBConfigPanel.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/FindPartnerTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MatchRecordsTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MentorBookingTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/MentorTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/ResourceDockingTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/find-partner/tabs/TeamRecruitTab.tsx   resolved by yongxu version
#	soul-admin/src/pages/mentors/MentorsPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/settings/SettingsPage.tsx   resolved by yongxu version
#	soul-admin/src/pages/users/UsersPage.tsx   resolved by yongxu version
#	soul-admin/tsconfig.tsbuildinfo   resolved by yongxu version
#	soul-api/internal/database/database.go   resolved by yongxu version
#	soul-api/internal/handler/admin_dashboard.go   resolved by yongxu version
#	soul-api/internal/handler/book.go   resolved by yongxu version
#	soul-api/internal/handler/ckb.go   resolved by yongxu version
#	soul-api/internal/handler/db_book.go   resolved by yongxu version
#	soul-api/internal/handler/db_person.go   resolved by yongxu version
#	soul-api/internal/handler/match_records.go   resolved by yongxu version
#	soul-api/internal/handler/user.go   resolved by yongxu version
#	soul-api/internal/model/chapter.go   resolved by yongxu version
#	soul-api/internal/model/person.go   resolved by yongxu version
#	soul-api/internal/router/router.go   resolved by yongxu version
#	开发文档/10、项目管理/运营与变更.md   resolved by yongxu version
#	开发文档/1、需求/需求汇总.md   resolved by yongxu version
#	开发文档/README.md   resolved by yongxu version
2026-03-10 20:20:59 +08:00

938 lines
28 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"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var (
orderPollLogger *log.Logger
orderPollLoggerOnce sync.Once
)
// orderPollLogf 将订单轮询检测日志写入 log/order-poll.log不输出到控制台
func orderPollLogf(format string, args ...interface{}) {
orderPollLoggerOnce.Do(func() {
_ = os.MkdirAll("log", 0755)
f, err := os.OpenFile(filepath.Join("log", "order-poll.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
orderPollLogger = log.New(io.Discard, "", 0)
return
}
orderPollLogger = log.New(f, "[OrderPoll] ", log.Ldate|log.Ltime)
})
if orderPollLogger != nil {
orderPollLogger.Printf(format, args...)
}
}
// MiniprogramLogin POST /api/miniprogram/login
func MiniprogramLogin(c *gin.Context) {
var req struct {
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少登录code"})
return
}
// 调用微信接口获取 openid 和 session_key
openID, sessionKey, _, err := wechat.Code2Session(req.Code)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("微信登录失败: %v", err)})
return
}
db := database.DB()
// 查询用户是否存在
var user model.User
result := db.Where("open_id = ?", openID).First(&user)
isNewUser := result.Error != nil
if isNewUser {
// 创建新用户
userID := openID // 直接使用 openid 作为用户 ID
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
nickname := "微信用户" + openID[len(openID)-4:]
avatar := ""
hasFullBook := false
earnings := 0.0
pendingEarnings := 0.0
referralCount := 0
purchasedSections := "[]"
user = model.User{
ID: userID,
OpenID: &openID,
SessionKey: &sessionKey,
Nickname: &nickname,
Avatar: &avatar,
ReferralCode: &referralCode,
HasFullBook: &hasFullBook,
PurchasedSections: &purchasedSections,
Earnings: &earnings,
PendingEarnings: &pendingEarnings,
ReferralCount: &referralCount,
}
if err := db.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
return
}
// 新用户异步调用神射手自动打标手机号尚未绑定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": getStringValue(user.Avatar),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"referralCode": getStringValue(user.ReferralCode),
"hasFullBook": getBoolValue(user.HasFullBook),
"purchasedSections": purchasedSections,
"earnings": getFloatValue(user.Earnings),
"pendingEarnings": getFloatValue(user.PendingEarnings),
"referralCount": getIntValue(user.ReferralCount),
"createdAt": user.CreatedAt,
}
// 生成 token
token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"openId": openID,
"user": responseUser,
"token": token,
},
"isNewUser": isNewUser,
})
}
// 辅助函数
func getStringValue(ptr *string) string {
if ptr == nil {
return ""
}
return *ptr
}
func getBoolValue(ptr *bool) bool {
if ptr == nil {
return false
}
return *ptr
}
func getFloatValue(ptr *float64) float64 {
if ptr == nil {
return 0.0
}
return *ptr
}
func getIntValue(ptr *int) int {
if ptr == nil {
return 0
}
return *ptr
}
// MiniprogramPay GET/POST /api/miniprogram/pay
func MiniprogramPay(c *gin.Context) {
if c.Request.Method == "POST" {
miniprogramPayPost(c)
} else {
miniprogramPayGet(c)
}
}
// POST - 创建小程序支付订单
func miniprogramPayPost(c *gin.Context) {
var req struct {
OpenID string `json:"openId" binding:"required"`
ProductType string `json:"productType" binding:"required"`
ProductID string `json:"productId"`
Amount float64 `json:"amount" binding:"required"`
Description string `json:"description"`
UserID string `json:"userId"`
ReferralCode string `json:"referralCode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少openId参数请先登录"})
return
}
if req.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "支付金额无效"})
return
}
db := database.DB()
// -------- V1.1 后端价格:从 DB 读取标准价,客户端传值仅用于日志对比,实际以后端计算为准 --------
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
if priceErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
return
}
// 查询用户的有效推荐人(先查 binding再查 referralCode
var referrerID *string
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
}
}
// 有推荐人时应用好友优惠,以后端标准价为基准计算最终金额,忽略客户端传值
finalAmount := standardPrice
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 = standardPrice * (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, req.ProductID, req.UserID)
}
// 生成订单号
orderSn := wechat.GenerateOrderSn()
totalFee := int(finalAmount * 100) // 转为分
description := req.Description
if description == "" {
if req.ProductType == "fullbook" {
description = "《一场Soul的创业实验》全书"
} else if req.ProductType == "vip" {
description = "卡若创业派对VIP年度会员365天"
} else if req.ProductType == "match" {
description = "购买匹配次数"
} else {
description = fmt.Sprintf("章节购买-%s", req.ProductID)
}
}
// 获取客户端 IP
clientIP := c.ClientIP()
if clientIP == "" {
clientIP = "127.0.0.1"
}
// 插入订单到数据库
userID := req.UserID
if userID == "" {
userID = req.OpenID
}
productID := req.ProductID
if productID == "" {
switch req.ProductType {
case "vip":
productID = "vip_annual"
case "match":
productID = "match"
default:
productID = "fullbook"
}
}
status := "created"
order := model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: userID,
OpenID: req.OpenID,
ProductType: req.ProductType,
ProductID: &productID,
Amount: finalAmount,
Description: &description,
Status: &status,
ReferrerID: referrerID,
ReferralCode: &req.ReferralCode,
}
if err := db.Create(&order).Error; err != nil {
// 订单创建失败,但不中断支付流程
fmt.Printf("[MiniprogramPay] 插入订单失败: %v\n", err)
}
attach := fmt.Sprintf(`{"productType":"%s","productId":"%s","userId":"%s"}`, req.ProductType, req.ProductID, userID)
ctx := c.Request.Context()
prepayID, err := wechat.PayJSAPIOrder(ctx, req.OpenID, orderSn, totalFee, description, attach)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("微信支付请求失败: %v", err)})
return
}
payParams, err := wechat.GetJSAPIPayParams(prepayID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("生成支付参数失败: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"orderSn": orderSn,
"prepayId": prepayID,
"payParams": payParams,
},
})
}
// GET - 查询订单状态(并主动同步:若微信已支付但本地未标记,则更新本地订单,便于配额即时生效)
func miniprogramPayGet(c *gin.Context) {
orderSn := c.Query("orderSn")
if orderSn == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少订单号"})
return
}
ctx := c.Request.Context()
tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, orderSn)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"status": "unknown",
"orderSn": orderSn,
},
})
return
}
status := "paying"
switch tradeState {
case "SUCCESS":
status = "paid"
// 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.UserID, order.ProductType, 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"`
}
if attachStr != "" {
_ = json.Unmarshal([]byte(attachStr), &attach)
}
db := database.DB()
buyerUserID := attach.UserID
if openID != "" {
var user model.User
if err := db.Where("open_id = ?", openID).First(&user).Error; err == nil {
if attach.UserID != "" && user.ID != attach.UserID {
fmt.Printf("[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准\n")
}
buyerUserID = user.ID
}
}
if buyerUserID == "" && attach.UserID != "" {
buyerUserID = attach.UserID
}
var order model.Order
result := db.Where("order_sn = ?", orderSn).First(&order)
if result.Error != nil {
fmt.Printf("[PayNotify] 订单不存在,补记订单: %s\n", orderSn)
productID := attach.ProductID
if productID == "" {
productID = "fullbook"
}
productType := attach.ProductType
if productType == "" {
productType = "unknown"
}
desc := "支付回调补记订单"
status := "paid"
now := time.Now()
order = model.Order{
ID: orderSn,
OrderSN: orderSn,
UserID: buyerUserID,
OpenID: openID,
ProductType: productType,
ProductID: &productID,
Amount: totalAmount,
Description: &desc,
Status: &status,
TransactionID: &transactionID,
PayTime: &now,
}
if err := db.Create(&order).Error; err != nil {
fmt.Printf("[PayNotify] 补记订单失败: %s, err=%v\n", orderSn, err)
return fmt.Errorf("create order: %w", err)
}
} else if *order.Status != "paid" {
status := "paid"
now := time.Now()
if err := db.Model(&order).Updates(map[string]interface{}{
"status": status,
"transaction_id": transactionID,
"pay_time": now,
}).Error; err != nil {
fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err)
return fmt.Errorf("update order: %w", err)
}
fmt.Printf("[PayNotify] 订单状态已更新为已支付: %s\n", orderSn)
} else {
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
}
if buyerUserID != "" && attach.ProductType != "" {
if attach.ProductType == "fullbook" {
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
} else if attach.ProductType == "vip" {
// V4.2 修复:续费时累加剩余天数(从 max(now, vip_expire_date) 加 365 天)
vipActivatedAt := time.Now()
if order.PayTime != nil {
vipActivatedAt = *order.PayTime
}
expireDate := activateVIP(db, buyerUserID, 365, vipActivatedAt)
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
} else if attach.ProductType == "match" {
fmt.Printf("[PayNotify] 用户购买匹配次数: %s订单 %s\n", buyerUserID, orderSn)
} 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 != ?",
buyerUserID, attach.ProductID, orderSn,
).Count(&count)
if count == 0 {
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", buyerUserID, attach.ProductID)
} else {
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", buyerUserID, attach.ProductID)
}
}
productID := attach.ProductID
if productID == "" {
productID = "fullbook"
}
db.Where(
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
buyerUserID, attach.ProductType, productID, orderSn,
).Delete(&model.Order{})
processReferralCommission(db, buyerUserID, totalAmount, orderSn, &order)
}
return nil
})
if err != nil {
fmt.Printf("[PayNotify] 处理回调失败: %v\n", err)
c.String(http.StatusOK, failResponse())
return
}
defer resp.Body.Close()
for k, v := range resp.Header {
if len(v) > 0 {
c.Header(k, v[0])
}
}
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}
// 处理分销佣金(会员订单 20%/10%,内容订单 90%
func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64, orderSn string, order *model.Order) {
type Binding struct {
ID int `gorm:"column:id"`
ReferrerID string `gorm:"column:referrer_id"`
ExpiryDate time.Time `gorm:"column:expiry_date"`
PurchaseCount int `gorm:"column:purchase_count"`
TotalCommission float64 `gorm:"column:total_commission"`
}
var binding Binding
err := db.Raw(`
SELECT id, referrer_id, expiry_date, purchase_count, total_commission
FROM referral_bindings
WHERE referee_id = ? AND status = 'active'
ORDER BY binding_date DESC
LIMIT 1
`, buyerUserID).Scan(&binding).Error
if err != nil {
fmt.Printf("[PayNotify] 用户无有效推广绑定,跳过分佣: %s\n", buyerUserID)
return
}
if time.Now().After(binding.ExpiryDate) {
fmt.Printf("[PayNotify] 绑定已过期,跳过分佣: %s\n", buyerUserID)
return
}
// 确保 order 有 referrer_id补记订单可能缺失
if order != nil && (order.ReferrerID == nil || *order.ReferrerID == "") {
order.ReferrerID = &binding.ReferrerID
db.Model(order).Update("referrer_id", binding.ReferrerID)
}
// 构建用于计算的 order若为 nil 则用 binding 信息)
calcOrder := order
if calcOrder == nil {
calcOrder = &model.Order{Amount: amount, ProductType: "unknown", ReferrerID: &binding.ReferrerID}
}
commission := computeOrderCommission(db, calcOrder, nil)
if commission <= 0 {
fmt.Printf("[PayNotify] 佣金为 0跳过分佣: orderSn=%s\n", orderSn)
return
}
newPurchaseCount := binding.PurchaseCount + 1
newTotalCommission := binding.TotalCommission + commission
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f\n",
binding.ReferrerID, amount, commission)
db.Model(&model.User{}).Where("id = ?", binding.ReferrerID).
Update("pending_earnings", db.Raw("pending_earnings + ?", commission))
db.Exec(`
UPDATE referral_bindings
SET last_purchase_date = NOW(),
purchase_count = COALESCE(purchase_count, 0) + 1,
total_commission = COALESCE(total_commission, 0) + ?
WHERE id = ?
`, commission, binding.ID)
fmt.Printf("[PayNotify] 分佣完成: 推广者 %s 获得 %.2f 元(第 %d 次购买,累计 %.2f 元)\n",
binding.ReferrerID, commission, newPurchaseCount, newTotalCommission)
}
// 微信支付回调响应
func successResponse() string {
return `<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`
}
func failResponse() string {
return `<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[ERROR]]></return_msg></xml>`
}
// MiniprogramPhone POST /api/miniprogram/phone
func MiniprogramPhone(c *gin.Context) {
var req struct {
Code string `json:"code" binding:"required"`
UserID string `json:"userId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少code参数"})
return
}
// 获取手机号
phoneNumber, countryCode, err := wechat.GetPhoneNumber(req.Code)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "获取手机号失败",
"error": err.Error(),
})
return
}
// 如果提供了 userId更新到数据库
if req.UserID != "" {
db := database.DB()
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
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)
}
// 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
}
// V4.1 修复is_vip 同时校验过期时间is_vip=1 且 vip_expire_date>NOW而非仅凭订单数量
isVipActive, _ := isVipFromUsers(db, id)
if !isVipActive {
// 兜底orders 表有有效 VIP 订单
isVipActive, _ = isVipFromOrders(db, id)
}
// 用户信息与会员资料vip*、P3 资料扩展,供会员详情页完整展示
item := gin.H{
"id": user.ID,
"nickname": getStringValue(user.Nickname),
"avatar": getStringValue(user.Avatar),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"vipName": getStringValue(user.VipName),
"vipAvatar": getStringValue(user.VipAvatar),
"vipContact": getStringValue(user.VipContact),
"vipProject": getStringValue(user.VipProject),
"vipBio": getStringValue(user.VipBio),
"mbti": getStringValue(user.Mbti),
"region": getStringValue(user.Region),
"industry": getStringValue(user.Industry),
"position": getStringValue(user.Position),
"businessScale": getStringValue(user.BusinessScale),
"skills": getStringValue(user.Skills),
"storyBestMonth": getStringValue(user.StoryBestMonth),
"storyAchievement": getStringValue(user.StoryAchievement),
"storyTurning": getStringValue(user.StoryTurning),
"helpOffer": getStringValue(user.HelpOffer),
"helpNeed": getStringValue(user.HelpNeed),
"projectIntro": getStringValue(user.ProjectIntro),
"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]
// V4.1is_vip 同时校验过期时间
uvip, _ := isVipFromUsers(db, u.ID)
if !uvip {
uvip, _ = isVipFromOrders(db, u.ID)
}
list = append(list, gin.H{
"id": u.ID,
"nickname": getStringValue(u.Nickname),
"avatar": 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, userID, productType string, payTime time.Time) {
switch productType {
case "fullbook":
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
case "vip":
activateVIP(db, userID, 365, payTime)
}
}
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
// productType: fullbook / vip / section / match
// productId: 章节购买时为章节 ID
func getStandardPrice(db *gorm.DB, productType, productID string) (float64, error) {
switch productType {
case "fullbook", "vip", "match":
// 从 system_config 读取
configKey := "chapter_config"
if productType == "vip" {
configKey = "vip_config"
}
var row model.SystemConfig
if err := db.Where("config_key = ?", configKey).First(&row).Error; err == nil {
var cfg map[string]interface{}
if json.Unmarshal(row.ConfigValue, &cfg) == nil {
fieldMap := map[string]string{
"fullbook": "fullbookPrice",
"vip": "price",
"match": "matchPrice",
}
if v, ok := cfg[fieldMap[productType]].(float64); ok && v > 0 {
return v, nil
}
}
}
// 兜底默认值
defaults := map[string]float64{"fullbook": 9.9, "vip": 1980, "match": 68}
if p, ok := defaults[productType]; ok {
return p, nil
}
return 0, fmt.Errorf("未知商品类型: %s", productType)
case "section":
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
default:
return 0, fmt.Errorf("未知商品类型: %s", productType)
}
}