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

289 lines
9.3 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})
}
// RunSyncVipCkbPlans 扫描已到期 VIP 用户,自动停用其绑定 Person 的存客宝计划
// - 最佳努力:停用失败只记日志,不中断整体任务
// - 幂等:重复执行不会产生额外副作用(计划已停用则仍然 update
func RunSyncVipCkbPlans(ctx context.Context, limit int) (scanned, disabled int, err error) {
if limit < 1 {
limit = 200
}
if limit > 2000 {
limit = 2000
}
db := database.DB()
// 只处理“有过期日且已过期,并且绑定了 Person(user_id) 且有 planId”的用户
// 说明persons.user_id 为新增字段;历史未绑定的不在本任务处理范围内
type row struct {
UserID string `gorm:"column:user_id"`
PlanID int64 `gorm:"column:ckb_plan_id"`
Nickname string `gorm:"column:nickname"`
}
rows := make([]row, 0)
q := `
SELECT u.id as user_id, p.ckb_plan_id, COALESCE(u.nickname,'') as nickname
FROM users u
INNER JOIN persons p ON p.user_id = u.id
WHERE u.is_vip = 1
AND u.vip_expire_date IS NOT NULL
AND u.vip_expire_date <= NOW()
AND p.ckb_plan_id > 0
ORDER BY u.vip_expire_date ASC
LIMIT ?`
if err := db.Raw(q, limit).Scan(&rows).Error; err != nil {
return 0, 0, err
}
scanned = len(rows)
if scanned == 0 {
return scanned, 0, nil
}
openToken, tokErr := ckbOpenGetToken()
if tokErr != nil {
// 没 token 直接失败,让 cron 重试(避免把用户标记成非 VIP 但计划未停用)
return scanned, 0, tokErr
}
for _, r := range rows {
select {
case <-ctx.Done():
return scanned, disabled, ctx.Err()
default:
}
if r.PlanID <= 0 || r.UserID == "" {
continue
}
if err := setCkbPlanEnabled(openToken, r.PlanID, false); err != nil {
syncOrdersLogf("停用存客宝计划失败: userId=%s, planId=%d, nickname=%s, err=%v", r.UserID, r.PlanID, r.Nickname, err)
continue
}
disabled++
syncOrdersLogf("已停用存客宝计划: userId=%s, planId=%d, nickname=%s", r.UserID, r.PlanID, r.Nickname)
// 兜底清理脏标记:到期用户将 is_vip 置为 0vip_expire_date 保留)
_ = db.Model(&model.User{}).Where("id = ?", r.UserID).Update("is_vip", false).Error
}
return scanned, disabled, nil
}
// CronSyncVipCkbPlans GET/POST /api/cron/sync-vip-ckb-plans
// ?limit=200 每次最多处理 N 个到期用户
func CronSyncVipCkbPlans(c *gin.Context) {
limit := 200
if s := strings.TrimSpace(c.Query("limit")); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
limit = n
}
}
scanned, disabled, err := RunSyncVipCkbPlans(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "scanned": scanned, "disabled": disabled})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "scanned": scanned, "disabled": disabled, "limit": limit})
}