优化阅读页跳转逻辑,优先传递章节中间ID(mid),以提升分享功能的一致性。更新相关页面以支持新逻辑,确保用户体验流畅。增加退款功能的相关处理,支持订单退款及退款原因的记录,增强订单管理的灵活性。

This commit is contained in:
Alex-larget
2026-02-28 10:19:46 +08:00
parent 9f77d1cfe2
commit 8af2d808f9
62 changed files with 1168 additions and 1798 deletions

View File

@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/joho/godotenv"
@@ -17,12 +18,15 @@ type Config struct {
CORSOrigins []string
Version string // APP_VERSION打包/部署前写在 .env/health 返回
// 统一 API 域名字段支付回调、转账回调、apiDomain 等均由 BaseURL 拼接
BaseURL string // API_BASE_URL如 https://soulapi.quwanzhi.com无尾部斜杠
// 微信小程序配置
WechatAppID string
WechatAppSecret string
WechatMchID string
WechatMchKey string
WechatNotifyURL string
WechatNotifyURL string // 由 BaseURL + /api/miniprogram/pay/notify 派生
WechatMiniProgramState string // 订阅消息跳转版本developer/formal从 .env WECHAT_MINI_PROGRAM_STATE 读取
// 微信转账配置API v3
@@ -30,12 +34,28 @@ type Config struct {
WechatCertPath string
WechatKeyPath string
WechatSerialNo string
WechatTransferURL string // 转账回调地址
WechatTransferURL string // 由 BaseURL + /api/payment/wechat/transfer/notify 派生
// 管理端登录(与 next-project 一致ADMIN_USERNAME / ADMIN_PASSWORD / ADMIN_SESSION_SECRET
AdminUsername string
AdminPassword string
AdminSessionSecret string
// 订单对账定时任务间隔分钟0 表示不启动内置定时任务
SyncOrdersIntervalMinutes int
}
// BaseURLJoin 将路径拼接到 BaseURLpath 应以 / 开头
func (c *Config) BaseURLJoin(path string) string {
base := strings.TrimSuffix(c.BaseURL, "/")
if base == "" {
return ""
}
p := strings.TrimSpace(path)
if p != "" && p[0] != '/' {
p = "/" + p
}
return base + p
}
// 默认 CORS 允许的源(零配置:不设环境变量也能用)
@@ -116,9 +136,14 @@ func Load() (*Config, error) {
if wechatMchKey == "" {
wechatMchKey = "wx3e31b068be59ddc131b068be59ddc2" // 默认API密钥(v2)
}
// 统一域名API_BASE_URL 派生支付/转账回调,可选 WECHAT_NOTIFY_URL 覆盖
baseURL := strings.TrimSpace(strings.TrimSuffix(os.Getenv("API_BASE_URL"), "/"))
if baseURL == "" {
baseURL = "https://soulapi.quwanzhi.com"
}
wechatNotifyURL := os.Getenv("WECHAT_NOTIFY_URL")
if wechatNotifyURL == "" {
wechatNotifyURL = "https://soul.quwanzhi.com/api/miniprogram/pay/notify" // 默认回调地址
wechatNotifyURL = baseURL + "/api/miniprogram/pay/notify"
}
wechatMiniProgramState := strings.TrimSpace(os.Getenv("WECHAT_MINI_PROGRAM_STATE"))
if wechatMiniProgramState != "developer" && wechatMiniProgramState != "trial" {
@@ -144,7 +169,7 @@ func Load() (*Config, error) {
}
wechatTransferURL := os.Getenv("WECHAT_TRANSFER_URL")
if wechatTransferURL == "" {
wechatTransferURL = "https://soul.quwanzhi.com/api/payment/wechat/transfer/notify" // 默认转账回调地址
wechatTransferURL = baseURL + "/api/payment/wechat/transfer/notify"
}
adminUsername := os.Getenv("ADMIN_USERNAME")
@@ -159,14 +184,21 @@ func Load() (*Config, error) {
if adminSessionSecret == "" {
adminSessionSecret = "soul-admin-secret-change-in-prod"
}
syncOrdersInterval := 5
if s := os.Getenv("SYNC_ORDERS_INTERVAL_MINUTES"); s != "" {
if n, e := strconv.Atoi(s); e == nil && n >= 0 {
syncOrdersInterval = n
}
}
return &Config{
Port: port,
Mode: mode,
DBDSN: dsn,
TrustedProxies: []string{"127.0.0.1", "::1"},
CORSOrigins: parseCORSOrigins(),
Version: version,
Port: port,
Mode: mode,
DBDSN: dsn,
TrustedProxies: []string{"127.0.0.1", "::1"},
CORSOrigins: parseCORSOrigins(),
Version: version,
BaseURL: baseURL,
WechatAppID: wechatAppID,
WechatAppSecret: wechatAppSecret,
WechatMchID: wechatMchID,
@@ -174,12 +206,13 @@ func Load() (*Config, error) {
WechatNotifyURL: wechatNotifyURL,
WechatMiniProgramState: wechatMiniProgramState,
WechatAPIv3Key: wechatAPIv3Key,
WechatCertPath: wechatCertPath,
WechatKeyPath: wechatKeyPath,
WechatSerialNo: wechatSerialNo,
WechatTransferURL: wechatTransferURL,
AdminUsername: adminUsername,
AdminPassword: adminPassword,
AdminSessionSecret: adminSessionSecret,
WechatCertPath: wechatCertPath,
WechatKeyPath: wechatKeyPath,
WechatSerialNo: wechatSerialNo,
WechatTransferURL: wechatTransferURL,
AdminUsername: adminUsername,
AdminPassword: adminPassword,
AdminSessionSecret: adminSessionSecret,
SyncOrdersIntervalMinutes: syncOrdersInterval,
}, nil
}

View File

@@ -33,6 +33,9 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.VipRole{}); err != nil {
log.Printf("database: vip_roles migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Order{}); err != nil {
log.Printf("database: orders migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}

View File

@@ -2,8 +2,13 @@ package handler
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"soul-api/internal/database"
@@ -13,24 +18,58 @@ import (
"github.com/gin-gonic/gin"
)
// CronSyncOrders GET/POST /api/cron/sync-orders
// 对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
func CronSyncOrders(c *gin.Context) {
db := database.DB()
var createdOrders []model.Order
// 只处理最近 24 小时内创建的未支付订单
cutoff := time.Now().Add(-24 * time.Hour)
if err := db.Where("status = ? AND created_at > ?", "created", cutoff).Find(&createdOrders).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
var (
syncOrdersLogger *log.Logger
syncOrdersLoggerOnce sync.Once
)
synced := 0
ctx := context.Background()
for _, o := range createdOrders {
tradeState, transactionID, totalFee, err := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN)
// syncOrdersLogf 将订单同步日志写入 log/sync-orders.log不输出到控制台
func syncOrdersLogf(format string, args ...interface{}) {
syncOrdersLoggerOnce.Do(func() {
_ = os.MkdirAll("log", 0755)
f, err := os.OpenFile(filepath.Join("log", "sync-orders.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
fmt.Printf("[SyncOrders] 查询订单 %s 失败: %v\n", o.OrderSN, err)
syncOrdersLogger = log.New(io.Discard, "", 0)
return
}
syncOrdersLogger = log.New(f, "[SyncOrders] ", log.Ldate|log.Ltime)
})
if syncOrdersLogger != nil {
syncOrdersLogger.Printf(format, args...)
}
}
// SyncOrdersLogf 供 main 等调用,将订单同步相关日志写入 log/sync-orders.log
func SyncOrdersLogf(format string, args ...interface{}) {
syncOrdersLogf(format, args...)
}
// RunSyncOrders 订单对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
// 可被 HTTP 接口和内置定时任务调用。days 为查询范围(天),建议 7。
func RunSyncOrders(ctx context.Context, days int) (synced, total int, err error) {
if days < 1 {
days = 7
}
if days > 30 {
days = 30
}
db := database.DB()
cutoff := time.Now().AddDate(0, 0, -days)
var createdOrders []model.Order
if err := db.Where("status = ? AND created_at > ?", "created", cutoff).Find(&createdOrders).Error; err != nil {
return 0, 0, err
}
total = len(createdOrders)
for _, o := range createdOrders {
select {
case <-ctx.Done():
return synced, total, ctx.Err()
default:
}
tradeState, transactionID, totalFee, qerr := wechat.QueryOrderByOutTradeNo(ctx, o.OrderSN)
if qerr != nil {
syncOrdersLogf("查询订单 %s 失败: %v", o.OrderSN, qerr)
continue
}
if tradeState != "SUCCESS" {
@@ -45,27 +84,74 @@ func CronSyncOrders(c *gin.Context) {
"pay_time": now,
"updated_at": now,
}).Error; err != nil {
fmt.Printf("[SyncOrders] 更新订单 %s 失败: %v\n", o.OrderSN, err)
syncOrdersLogf("更新订单 %s 失败: %v", o.OrderSN, err)
continue
}
synced++
fmt.Printf("[SyncOrders] 补齐漏单: %s, amount=%.2f\n", o.OrderSN, totalAmount)
syncOrdersLogf("补齐漏单: %s, amount=%.2f", o.OrderSN, totalAmount)
// 同步后续逻辑(全书、分销等
// 同步后续逻辑(全书、VIP、分销等与 PayNotify 一致
pt := "fullbook"
if o.ProductType != "" {
pt = o.ProductType
}
if pt == "fullbook" {
db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true)
productID := ""
if o.ProductID != nil {
productID = *o.ProductID
}
if productID == "" {
productID = "fullbook"
}
switch pt {
case "fullbook":
db.Model(&model.User{}).Where("id = ?", o.UserID).Update("has_full_book", true)
syncOrdersLogf("用户已购全书: %s", o.UserID)
case "vip":
expireDate := now.AddDate(0, 0, 365)
db.Model(&model.User{}).Where("id = ?", o.UserID).Updates(map[string]interface{}{
"is_vip": true,
"vip_expire_date": expireDate,
"vip_activated_at": now,
})
syncOrdersLogf("用户 VIP 已激活: %s, 过期日=%s", o.UserID, expireDate.Format("2006-01-02"))
case "match":
syncOrdersLogf("用户购买匹配次数: %s", o.UserID)
case "section":
syncOrdersLogf("用户购买章节: %s - %s", o.UserID, productID)
}
// 取消同商品未支付订单(与 PayNotify 一致)
db.Where(
"user_id = ? AND product_type = ? AND product_id = ? AND status = ? AND order_sn != ?",
o.UserID, pt, productID, "created", o.OrderSN,
).Delete(&model.Order{})
processReferralCommission(db, o.UserID, totalAmount, o.OrderSN, &o)
}
return synced, total, nil
}
// CronSyncOrders GET/POST /api/cron/sync-orders
// 对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
// 支持 ?days=7 扩展时间范围,默认 7 天
func CronSyncOrders(c *gin.Context) {
days := 7
if d := c.Query("days"); d != "" {
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 30 {
days = n
}
}
synced, total, err := RunSyncOrders(c.Request.Context(), days)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"synced": synced,
"total": len(createdOrders),
"total": total,
"days": days,
})
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -20,9 +21,13 @@ func GetPublicDBConfig(c *gin.Context) {
defaultFree := []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"}
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}
apiDomain := "https://soulapi.quwanzhi.com"
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
apiDomain = cfg.BaseURL
}
defaultMp := gin.H{
"appId": "wxb8bbb2b10dec74aa",
"apiDomain": "https://soulapi.quwanzhi.com", // 保留以兼容线上旧版小程序(仍从 config 读取)
"apiDomain": apiDomain,
"buyerDiscount": 5,
"referralBindDays": 30,
"minWithdraw": 10,
@@ -156,9 +161,13 @@ func DBConfigGet(c *gin.Context) {
// AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回免费章节、功能开关、站点/作者与价格、小程序配置
func AdminSettingsGet(c *gin.Context) {
db := database.DB()
apiDomain := "https://soulapi.quwanzhi.com"
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
apiDomain = cfg.BaseURL
}
defaultMp := gin.H{
"appId": "wxb8bbb2b10dec74aa",
"apiDomain": "https://soulapi.quwanzhi.com", // 保留以兼容线上旧版小程序
"apiDomain": apiDomain,
"withdrawSubscribeTmplId": "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",
"mchId": "1318592501",
"minWithdraw": float64(10),

View File

@@ -22,7 +22,7 @@ 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
@@ -36,13 +36,13 @@ func MiniprogramLogin(c *gin.Context) {
}
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
@@ -54,7 +54,7 @@ func MiniprogramLogin(c *gin.Context) {
pendingEarnings := 0.0
referralCount := 0
purchasedSections := "[]"
user = model.User{
ID: userID,
OpenID: &openID,
@@ -68,7 +68,7 @@ func MiniprogramLogin(c *gin.Context) {
PendingEarnings: &pendingEarnings,
ReferralCount: &referralCount,
}
if err := db.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
return
@@ -83,7 +83,7 @@ func MiniprogramLogin(c *gin.Context) {
var orderRows []struct {
ProductID string `gorm:"column:product_id"`
}
db.Raw(`
SELECT DISTINCT product_id
FROM orders
@@ -91,32 +91,32 @@ func MiniprogramLogin(c *gin.Context) {
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),
"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,
"earnings": getFloatValue(user.Earnings),
"pendingEarnings": getFloatValue(user.PendingEarnings),
"referralCount": getIntValue(user.ReferralCount),
"createdAt": user.CreatedAt,
}
// 生成 token
@@ -174,15 +174,15 @@ func MiniprogramPay(c *gin.Context) {
// 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"`
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
@@ -264,7 +264,7 @@ func miniprogramPayPost(c *gin.Context) {
if userID == "" {
userID = req.OpenID
}
productID := req.ProductID
if productID == "" {
switch req.ProductType {
@@ -276,22 +276,22 @@ func miniprogramPayPost(c *gin.Context) {
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,
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)
@@ -313,8 +313,8 @@ func miniprogramPayPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"orderSn": orderSn,
"prepayId": prepayID,
"orderSn": orderSn,
"prepayId": prepayID,
"payParams": payParams,
},
})
@@ -582,7 +582,7 @@ func MiniprogramPhone(c *gin.Context) {
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
@@ -622,7 +622,7 @@ func MiniprogramQrcode(c *gin.Context) {
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
@@ -709,7 +709,7 @@ func MiniprogramQrcodeImage(c *gin.Context) {
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) {
@@ -718,23 +718,23 @@ func base64Encode(data []byte) string {
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()
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"strconv"
@@ -9,6 +10,7 @@ import (
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
@@ -32,7 +34,7 @@ func OrdersList(c *gin.Context) {
if statusFilter == "completed" {
q = q.Where("status IN ?", []string{"paid", "completed"})
} else {
q = q.Where("status = ?", statusFilter)
q = q.Where("status = ?", statusFilter) // 含 refunded、pending、created、failed
}
}
if search != "" {
@@ -195,3 +197,55 @@ func MiniprogramOrders(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
// AdminOrderRefund PUT /api/admin/orders/refund 管理端-订单退款(仅支持已支付订单,调用微信支付退款)
func AdminOrderRefund(c *gin.Context) {
var req struct {
OrderSn string `json:"orderSn" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少订单号"})
return
}
db := database.DB()
var order model.Order
if err := db.Where("order_sn = ?", req.OrderSn).First(&order).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "订单不存在"})
return
}
status := ""
if order.Status != nil {
status = *order.Status
}
if status != "paid" && status != "completed" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅支持已支付订单退款"})
return
}
transactionID := ""
if order.TransactionID != nil {
transactionID = *order.TransactionID
}
if transactionID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "订单缺少微信支付单号,无法退款"})
return
}
totalCents := int(order.Amount * 100)
if totalCents < 1 {
totalCents = 1
}
if err := wechat.RefundOrder(context.Background(), order.OrderSN, transactionID, totalCents, req.Reason); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "微信退款失败: " + err.Error()})
return
}
refunded := "refunded"
updates := map[string]interface{}{"status": refunded}
if req.Reason != "" {
updates["refund_reason"] = req.Reason
}
if err := db.Model(&order).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "退款成功但更新订单状态失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "退款成功"})
}

View File

@@ -39,14 +39,14 @@ func UserAddressesGet(c *gin.Context) {
// UserAddressesPost POST /api/user/addresses
func UserAddressesPost(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
Name string `json:"name" binding:"required"`
Phone string `json:"phone" binding:"required"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
Detail string `json:"detail" binding:"required"`
IsDefault bool `json:"isDefault"`
UserID string `json:"userId" binding:"required"`
Name string `json:"name" binding:"required"`
Phone string `json:"phone" binding:"required"`
Province string `json:"province"`
City string `json:"city"`
District string `json:"district"`
Detail string `json:"detail" binding:"required"`
IsDefault bool `json:"isDefault"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "缺少必填项userId, name, phone, detail"})
@@ -107,12 +107,24 @@ func UserAddressesByID(c *gin.Context) {
}
_ = c.ShouldBindJSON(&body)
updates := make(map[string]interface{})
if body.Name != nil { updates["name"] = *body.Name }
if body.Phone != nil { updates["phone"] = *body.Phone }
if body.Province != nil { updates["province"] = *body.Province }
if body.City != nil { updates["city"] = *body.City }
if body.District != nil { updates["district"] = *body.District }
if body.Detail != nil { updates["detail"] = *body.Detail }
if body.Name != nil {
updates["name"] = *body.Name
}
if body.Phone != nil {
updates["phone"] = *body.Phone
}
if body.Province != nil {
updates["province"] = *body.Province
}
if body.City != nil {
updates["city"] = *body.City
}
if body.District != nil {
updates["district"] = *body.District
}
if body.Detail != nil {
updates["detail"] = *body.Detail
}
if body.IsDefault != nil {
updates["is_default"] = *body.IsDefault
if *body.IsDefault {
@@ -240,10 +252,18 @@ func UserProfilePost(c *gin.Context) {
return
}
updates := make(map[string]interface{})
if body.Nickname != nil { updates["nickname"] = *body.Nickname }
if body.Avatar != nil { updates["avatar"] = *body.Avatar }
if body.Phone != nil { updates["phone"] = *body.Phone }
if body.WechatID != nil { updates["wechat_id"] = *body.WechatID }
if body.Nickname != nil {
updates["nickname"] = *body.Nickname
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
}
if body.Phone != nil {
updates["phone"] = *body.Phone
}
if body.WechatID != nil {
updates["wechat_id"] = *body.WechatID
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "没有需要更新的字段"})
return
@@ -310,14 +330,14 @@ func UserPurchaseStatus(c *gin.Context) {
"hasReferrer": hasReferrer,
"matchCount": matchQuota.PurchasedTotal,
"matchQuota": gin.H{
"purchasedTotal": matchQuota.PurchasedTotal,
"purchasedTotal": matchQuota.PurchasedTotal,
"purchasedUsed": matchQuota.PurchasedUsed,
"matchesUsedToday": matchQuota.MatchesUsedToday,
"freeRemainToday": matchQuota.FreeRemainToday,
"purchasedRemain": matchQuota.PurchasedRemain,
"remainToday": matchQuota.RemainToday,
},
"earnings": earnings,
"earnings": earnings,
"pendingEarnings": pendingEarnings,
}})
}
@@ -449,10 +469,10 @@ func UserTrackGet(c *gin.Context) {
// UserTrackPost POST /api/user/track 记录行为GORM
func UserTrackPost(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
Phone string `json:"phone"`
Action string `json:"action"`
Target string `json:"target"`
UserID string `json:"userId"`
Phone string `json:"phone"`
Action string `json:"action"`
Target string `json:"target"`
ExtraData interface{} `json:"extraData"`
}
if err := c.ShouldBindJSON(&body); err != nil {
@@ -509,10 +529,18 @@ func UserUpdate(c *gin.Context) {
return
}
updates := make(map[string]interface{})
if body.Nickname != nil { updates["nickname"] = *body.Nickname }
if body.Avatar != nil { updates["avatar"] = *body.Avatar }
if body.Phone != nil { updates["phone"] = *body.Phone }
if body.Wechat != nil { updates["wechat_id"] = *body.Wechat }
if body.Nickname != nil {
updates["nickname"] = *body.Nickname
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
}
if body.Phone != nil {
updates["phone"] = *body.Phone
}
if body.Wechat != nil {
updates["wechat_id"] = *body.Wechat
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "没有需要更新的字段"})
return

View File

@@ -17,6 +17,7 @@ type Order struct {
PayTime *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`
ReferralCode *string `gorm:"column:referral_code;size:255" json:"referralCode,omitempty"`
ReferrerID *string `gorm:"column:referrer_id;size:255" json:"referrerId,omitempty"`
RefundReason *string `gorm:"column:refund_reason;size:500" json:"refundReason,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}

View File

@@ -66,6 +66,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.POST("/settings", handler.AdminSettingsPost)
admin.GET("/referral-settings", handler.AdminReferralSettingsGet)
admin.POST("/referral-settings", handler.AdminReferralSettingsPost)
admin.PUT("/orders/refund", handler.AdminOrderRefund)
}
// ----- 鉴权 -----

View File

@@ -15,13 +15,13 @@ import (
"soul-api/internal/config"
"github.com/ArtisanCloud/PowerLibs/v3/object"
subrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/basicService/subscribeMessage/request"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/power"
"github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
notifyrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/notify/request"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request"
subrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/basicService/subscribeMessage/request"
)
var (
@@ -104,7 +104,7 @@ func Init(c *config.Config) error {
CertPath: certPath,
KeyPath: keyPath,
SerialNo: cfg.WechatSerialNo,
NotifyURL: cfg.WechatNotifyURL,
NotifyURL: cfg.WechatNotifyURL,
HttpDebug: cfg.Mode == "debug",
}
paymentApp, err = payment.NewPayment(paymentConfig)
@@ -153,10 +153,10 @@ func GetPhoneNumber(code string) (phoneNumber, countryCode string, err error) {
}
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s", token)
reqBody := map[string]string{"code": code}
jsonData, _ := json.Marshal(reqBody)
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
if err != nil {
return "", "", fmt.Errorf("请求微信接口失败: %w", err)
@@ -164,21 +164,21 @@ func GetPhoneNumber(code string) (phoneNumber, countryCode string, err error) {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
PhoneInfo struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
} `json:"phone_info"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", "", fmt.Errorf("解析微信返回失败: %w", err)
}
if result.ErrCode != 0 {
return "", "", fmt.Errorf("微信返回错误: %d - %s", result.ErrCode, result.ErrMsg)
}
@@ -203,7 +203,7 @@ func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
}
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=%s", token)
if width <= 0 || width > 430 {
width = 280
}
@@ -263,12 +263,15 @@ func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
return body, nil
}
// GetPayNotifyURL 返回支付回调地址(与商户平台配置一致)
// GetPayNotifyURL 返回支付回调地址(从 config.BaseURL 派生,与商户平台配置一致)
func GetPayNotifyURL() string {
if cfg != nil && cfg.WechatNotifyURL != "" {
return cfg.WechatNotifyURL
}
return "https://soul.quwanzhi.com/api/miniprogram/pay/notify"
if cfg != nil && cfg.BaseURL != "" {
return cfg.BaseURLJoin("/api/miniprogram/pay/notify")
}
return "https://soulapi.quwanzhi.com/api/miniprogram/pay/notify"
}
// PayJSAPIOrder 微信支付 v3 小程序 JSAPI 统一下单,返回 prepay_id
@@ -288,7 +291,7 @@ func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int,
Total: amountCents,
Currency: "CNY",
},
Payer: &request.JSAPIPayer{OpenID: openID},
Payer: &request.JSAPIPayer{OpenID: openID},
Attach: attach,
}
res, err := paymentApp.Order.JSAPITransaction(ctx, req)