Files
soul-yongping/soul-api/internal/handler/cron.go
卡若 76965adb23 chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/
- 移除并忽略 .env 与小程序私有配置
- 同步小程序/管理端/API与脚本改动

Made-with: Cursor
2026-03-17 17:50:12 +08:00

206 lines
6.5 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"
"strings"
"sync"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
)
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...)
}
// 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 {
// 微信返回「订单不存在」:说明该 out_trade_no 在微信侧已无效,直接将本地订单标记为关闭
if strings.Contains(qerr.Error(), "ORDER_NOT_EXIST") {
now := time.Now()
if err := db.Model(&o).Updates(map[string]interface{}{
"status": "closed",
"updated_at": now,
}).Error; err != nil {
syncOrdersLogf("微信提示订单不存在,标记订单 %s 为关闭失败: %v", o.OrderSN, err)
} else {
syncOrdersLogf("微信提示订单不存在,已将本地订单标记为关闭: %s", o.OrderSN)
}
continue
}
syncOrdersLogf("查询订单 %s 失败: %v", o.OrderSN, qerr)
continue
}
// 根据微信支付状态决定本地订单后续处理:
// - SUCCESS补齐漏单发放权益
// - NOTPAY/USERPAYING在有效期内保持 created超过一定时间自动标记为关闭
// - 其他终态CLOSED、REVOKED、PAYERROR 等):标记为关闭,避免无限轮询
if tradeState != "SUCCESS" {
// 对仍未支付的订单设置超时关闭(避免长时间轮询)
if tradeState == "NOTPAY" || tradeState == "USERPAYING" {
// 超过 30 分钟仍未支付,视为关闭
if time.Since(o.CreatedAt) > 30*time.Minute {
now := time.Now()
if err := db.Model(&o).Updates(map[string]interface{}{
"status": "closed",
"updated_at": now,
}).Error; err != nil {
syncOrdersLogf("标记超时未支付订单 %s 为关闭失败: %v", o.OrderSN, err)
} else {
syncOrdersLogf("订单超时未支付,标记为关闭: %s", o.OrderSN)
}
}
continue
}
// 其他非 SUCCESS 状态(如 CLOSED、REVOKED、PAYERROR 等),直接在本地标记为关闭
now := time.Now()
if err := db.Model(&o).Updates(map[string]interface{}{
"status": "closed",
"updated_at": now,
}).Error; err != nil {
syncOrdersLogf("标记订单 %s 为关闭状态失败trade_state=%s: %v", o.OrderSN, tradeState, err)
} else {
syncOrdersLogf("订单在微信已为终态 %s本地标记为关闭: %s", tradeState, o.OrderSN)
}
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)
// 同步后续逻辑全书、VIP、分销等与 PayNotify 一致)
pt := "fullbook"
if o.ProductType != "" {
pt = o.ProductType
}
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": total,
"days": days,
})
}
// CronUnbindExpired GET/POST /api/cron/unbind-expired
func CronUnbindExpired(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}