Files
soul-yongping/soul-api/internal/handler/cron.go

220 lines
6.4 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 (
"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})
}