package cmd import ( "context" _ "github.com/gogf/gf/contrib/drivers/mysql/v2" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gcmd" "github.com/gogf/gf/v2/os/gcron" "github.com/gogf/gf/v2/os/gfile" "tool-api/internal/controller" "tool-api/internal/logic" ) // GoFrame gcron 的 pattern 是 **6 段**(秒 分 时 日 月 周),不是标准 crontab 的 5 段。 // // ⚠️ 写成 5 段既不会 panic 也不会启动失败,只会让 AddSingleton 返回 error 并被下面的 // WARNING 吞掉 —— 任务静默不注册、功能完全失效(本项目曾把「笔记提醒扫描」写成 // "*/1 * * * *" 而无人察觉)。故把 pattern 提为常量,并由 cmd_test.go 固化校验。 // 若确实要用标准 5 段 crontab,需前置一个 `#`(如 "# */1 * * * *")声明忽略秒。 const ( cronPatternSweepOrders = "0 */5 * * * *" // 会员订单扫单:每 5 分钟 cronPatternScanRemind = "0 */1 * * * *" // 笔记提醒扫描:每 1 分钟 cronPatternReleaseCoupons = "0 */5 * * * *" // 免单券超时占用释放:每 5 分钟 cronPatternCloseOrders = "0 */5 * * * *" // 订单超时关闭:每 5 分钟 cronPatternRefundPoll = "0 */2 * * * *" // 退款结果轮询:每 2 分钟(logic/order_refund.go) ) var ( Main = gcmd.Command{ Name: "main", Usage: "main", Brief: "start http server", Func: func(ctx context.Context, parser *gcmd.Parser) (err error) { // 启动时幂等迁移(建表 / 加列),数据库不可用时仅告警 logic.Migrate(ctx) // 启动时幂等写入种子数据(管理员/模块/等级/工具/会员套餐/次数包/额度设置),数据库不可用时仅告警 logic.Seed(ctx) // 会员订单兜底扫单:推送丢失时主动查单补发货(建议间隔 5 分钟) if _, err = gcron.AddSingleton(ctx, cronPatternSweepOrders, func(c context.Context) { logic.SweepPendingOrders(c) }, "member-order-sweep"); err != nil { g.Log().Warningf(ctx, "[cron] 会员订单扫单任务注册失败: %v", err) } else { g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "member-order-sweep", cronPatternSweepOrders) } // 笔记定时提醒扫描:每分钟扫描到点的提醒并发订阅消息(条件更新保证只发一次) if _, err = gcron.AddSingleton(ctx, cronPatternScanRemind, func(c context.Context) { logic.ScanDueReminders(c) }, "note-remind-scan"); err != nil { g.Log().Warningf(ctx, "[cron] 笔记提醒扫描任务注册失败: %v", err) } else { g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "note-remind-scan", cronPatternScanRemind) } // 免单券超时占用释放:订单取消/支付失败/超时(coupon.lock.ttlSeconds)后把券释放回可用 if _, err = gcron.AddSingleton(ctx, cronPatternReleaseCoupons, func(c context.Context) { logic.ReleaseExpiredCouponLocks(c) }, "coupon-lock-release"); err != nil { g.Log().Warningf(ctx, "[cron] 免单券释放任务注册失败: %v", err) } else { g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "coupon-lock-release", cronPatternReleaseCoupons) } // 订单超时关闭:把超时未支付的待支付单置为已关闭(status=3)并释放其占用中的券。 // 时限 orderPendingTtlSeconds 与上面「免单券释放」同源(同一 settings 键,D2)。 if _, err = gcron.AddSingleton(ctx, cronPatternCloseOrders, func(c context.Context) { logic.ClosePendingOrders(c) }, "order-close-pending"); err != nil { g.Log().Warningf(ctx, "[cron] 订单超时关闭任务注册失败: %v", err) } else { g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "order-close-pending", cronPatternCloseOrders) } // 退款结果轮询兜底:xpay_refund_notify 推送丢失时,主动查单确认「退款中」的订单 // (确认成功→终态+回收权益;超 24h 不可判定→置失败待人工)。每 2 分钟。 if _, err = gcron.AddSingleton(ctx, cronPatternRefundPoll, func(c context.Context) { logic.RefundStatusPoll(c) }, "refund-status-poll"); err != nil { g.Log().Warningf(ctx, "[cron] 退款轮询任务注册失败: %v", err) } else { g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "refund-status-poll", cronPatternRefundPoll) } s := g.Server() // 公开接口:小程序登录、开发登录、管理端登录、虚拟支付发货推送回调 s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(logic.CORS, logic.Response) group.Bind(controller.UserPub, controller.AdminPub, controller.EventPub) // 平台回调无 token,单独注册(响应体为 XML,不走统一 JSON 包装) group.POST("/pay/notify", controller.PayNotify) }) // 小程序用户接口 s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(logic.CORS, logic.Response, logic.UserAuth) group.Bind(controller.UserAuth, controller.Member, controller.Quota, controller.Note, controller.Promo, controller.Coupon) }) // 管理端接口:鉴权(AdminAuth)+ 权限分级(AdminPerm,按角色权限点放行) s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(logic.CORS, logic.Response, logic.AdminAuth, logic.AdminPerm) group.Bind(controller.AdminAuth, controller.Event, controller.AdminPromo, controller.AdminCoupon, controller.AdminOrder) }) // 活动小程序码静态资源(生成时落在 manifest/qrcode,供管理端/前端访问) // ⚠️ AddStaticPath 在目录不存在时**直接 FATAL 退出、不降级**,而该目录原先只在 // 生成码图时才惰性创建 → 首次启动必然崩在「目录还没被创建」上(先有鸡先有蛋)。 // 故此处先幂等建目录(gfile.Mkdir = os.MkdirAll,已存在返回 nil)再注册。 if err = gfile.Mkdir(logic.QrCodeDir); err != nil { return gerror.Wrapf(err, "预创建码图目录 %s 失败(静态路由 /qrcode 无法注册)", logic.QrCodeDir) } s.AddStaticPath("/qrcode", logic.QrCodeDir) // 用户头像静态资源(N3):落盘于 manifest/static/avatar/...,由 /static 提供访问。 // 与 /qrcode 同一套坑与治法:先幂等建目录再注册静态路由(AddStaticPath 目录不存在会 FATAL)。 if err = gfile.Mkdir(logic.StaticDir); err != nil { return gerror.Wrapf(err, "预创建静态目录 %s 失败(静态路由 /static 无法注册)", logic.StaticDir) } s.AddStaticPath("/static", logic.StaticDir) s.Run() return nil }, } )