220 lines
6.4 KiB
Go
220 lines
6.4 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"sync"
|
||
"time"
|
||
|
||
"soul-api/internal/database"
|
||
"soul-api/internal/model"
|
||
"soul-api/internal/wechat"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
var (
|
||
syncOrdersLogger *log.Logger
|
||
syncOrdersLoggerOnce sync.Once
|
||
)
|
||
|
||
// 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 {
|
||
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...)
|
||
}
|
||
|
||
// processOrderPaidPostProcess 订单已支付后的统一后置逻辑:全书/VIP/匹配/章节权益、取消同商品未支付订单、分佣
|
||
func processOrderPaidPostProcess(db *gorm.DB, o *model.Order, transactionID string, totalAmount float64) {
|
||
pt := "fullbook"
|
||
if o.ProductType != "" {
|
||
pt = o.ProductType
|
||
}
|
||
productID := ""
|
||
if o.ProductID != nil {
|
||
productID = *o.ProductID
|
||
}
|
||
if productID == "" {
|
||
productID = "fullbook"
|
||
}
|
||
now := time.Now()
|
||
|
||
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)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// PollOrderUntilPaidOrTimeout 用户支付发起后,仅轮询该笔订单直到微信返回已支付或超时(防漏单,替代频繁全量扫描)
|
||
// 轮询间隔 8 秒,总超时 6 分钟;若微信已支付则更新订单并执行与 PayNotify 一致的后置逻辑
|
||
func PollOrderUntilPaidOrTimeout(orderSn string) {
|
||
const pollInterval = 8 * time.Second
|
||
const pollTimeout = 6 * time.Minute
|
||
ctx, cancel := context.WithTimeout(context.Background(), pollTimeout)
|
||
defer cancel()
|
||
|
||
db := database.DB()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
default:
|
||
}
|
||
qCtx, qCancel := context.WithTimeout(ctx, 15*time.Second)
|
||
tradeState, transactionID, totalFee, qerr := wechat.QueryOrderByOutTradeNo(qCtx, orderSn)
|
||
qCancel()
|
||
if qerr != nil {
|
||
syncOrdersLogf("轮询查询订单 %s 失败: %v", orderSn, qerr)
|
||
time.Sleep(pollInterval)
|
||
continue
|
||
}
|
||
if tradeState == "SUCCESS" {
|
||
var order model.Order
|
||
if err := db.Where("order_sn = ?", orderSn).First(&order).Error; err != nil {
|
||
syncOrdersLogf("轮询订单 %s 查库失败: %v", orderSn, err)
|
||
return
|
||
}
|
||
if order.Status != nil && *order.Status == "paid" {
|
||
return
|
||
}
|
||
now := time.Now()
|
||
if err := db.Model(&order).Updates(map[string]interface{}{
|
||
"status": "paid",
|
||
"transaction_id": transactionID,
|
||
"pay_time": now,
|
||
"updated_at": now,
|
||
}).Error; err != nil {
|
||
syncOrdersLogf("轮询更新订单 %s 失败: %v", orderSn, err)
|
||
return
|
||
}
|
||
totalAmount := float64(totalFee) / 100
|
||
syncOrdersLogf("轮询补齐: %s, amount=%.2f", orderSn, totalAmount)
|
||
processOrderPaidPostProcess(db, &order, transactionID, totalAmount)
|
||
return
|
||
}
|
||
switch tradeState {
|
||
case "CLOSED", "REVOKED", "PAYERROR":
|
||
return
|
||
}
|
||
time.Sleep(pollInterval)
|
||
}
|
||
}
|
||
|
||
// RunSyncOrders 订单对账:查询 status=created 的订单,向微信查询实际状态,若已支付则补齐更新(防漏单)
|
||
// 可被 HTTP 接口和内置定时任务调用;日常以 PollOrderUntilPaidOrTimeout 单笔轮询为主,本方法作兜底。
|
||
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" {
|
||
continue
|
||
}
|
||
totalAmount := float64(totalFee) / 100
|
||
now := time.Now()
|
||
if err := db.Model(&o).Updates(map[string]interface{}{
|
||
"status": "paid",
|
||
"transaction_id": transactionID,
|
||
"pay_time": now,
|
||
"updated_at": now,
|
||
}).Error; err != nil {
|
||
syncOrdersLogf("更新订单 %s 失败: %v", o.OrderSN, err)
|
||
continue
|
||
}
|
||
synced++
|
||
syncOrdersLogf("补齐漏单: %s, amount=%.2f", o.OrderSN, totalAmount)
|
||
processOrderPaidPostProcess(db, &o, transactionID, totalAmount)
|
||
}
|
||
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": total,
|
||
"days": days,
|
||
})
|
||
}
|
||
|
||
// CronUnbindExpired GET/POST /api/cron/unbind-expired
|
||
func CronUnbindExpired(c *gin.Context) {
|
||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||
}
|