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}) }