优化阅读页跳转逻辑,优先传递章节中间ID(mid),以提升分享功能的一致性。更新相关页面以支持新逻辑,确保用户体验流畅。增加退款功能的相关处理,支持订单退款及退款原因的记录,增强订单管理的灵活性。
This commit is contained in:
@@ -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 将路径拼接到 BaseURL,path 应以 / 开头
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "退款成功"})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ----- 鉴权 -----
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user