Files
qitongxue-api/internal/cmd/cmd.go
2026-09-29 15:48:13 +08:00

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