小程序一些功能更新

This commit is contained in:
李琦
2026-09-29 10:57:01 +08:00
parent 0cb70a7618
commit 3bb7fa8db3
96 changed files with 13890 additions and 213 deletions

View File

@@ -4,15 +4,30 @@ 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 分钟
)
var (
Main = gcmd.Command{
Name: "main",
@@ -25,30 +40,77 @@ var (
logic.Seed(ctx)
// 会员订单兜底扫单:推送丢失时主动查单补发货(建议间隔 5 分钟)
if _, err = gcron.AddSingleton(ctx, "0 */5 * * * *", func(c context.Context) {
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)
}
s := g.Server()
// 公开接口:小程序登录、开发登录、管理端登录、虚拟支付发货推送回调
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(logic.CORS, logic.Response)
group.Bind(controller.UserPub, controller.AdminPub)
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)
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)
group.Bind(controller.AdminAuth)
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
},

121
internal/cmd/cmd_test.go Normal file
View File

@@ -0,0 +1,121 @@
package cmd
import (
"context"
"os"
"testing"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcron"
"github.com/gogf/gf/v2/os/gfile"
"tool-api/internal/logic"
)
// ============================================================================
// 启动期(cmd.Main.Func)两条易静默失效/直接崩的路径,各自固化一个用例。
//
// 背景:这两条都是「本地跑起来才发现」的问题,都不会被业务单测覆盖 ——
// ① cron pattern 写错 → AddSingleton 返回 error 被 WARNING 吞掉,任务静默不注册;
// ② 静态目录不存在 → AddStaticPath 调 Logger().Fatalf → os.Exit(1),进程直接死。
// ============================================================================
// TestCronPatternsAreValid 固化启动期两个定时任务必须注册成功。
//
// GoFrame gcron 的 pattern 是 **6 段**(秒 分 时 日 月 周),不是标准 crontab 的 5 段。
// 本项目曾把「笔记提醒扫描」写成 5 段 "*/1 * * * *":
// AddSingleton 返回 error → 被启动日志里的 WARNING 吞掉 → 服务照常启动、无人察觉,
// 但该任务从未注册、笔记定时提醒功能完全失效。
func TestCronPatternsAreValid(t *testing.T) {
patterns := map[string]string{
"member-order-sweep": cronPatternSweepOrders,
"note-remind-scan": cronPatternScanRemind,
"coupon-lock-release": cronPatternReleaseCoupons,
"order-close-pending": cronPatternCloseOrders,
}
for name, pattern := range patterns {
name, pattern := name, pattern
t.Run(name, func(t *testing.T) {
if _, err := gcron.AddSingleton(
context.Background(), pattern, func(ctx context.Context) {}, name,
); err != nil {
t.Fatalf("cron 任务 %s 注册失败,pattern=%q:%v\n"+
"(gcron 需要 6 段「秒 分 时 日 月 周」;标准 5 段需前置 '#')", name, pattern, err)
}
gcron.Remove(name)
})
}
}
// TestFivePartCronPatternIsRejected 反证上面的用例不是空转:
// 标准 5 段 crontab 写法确实会被 gcron 拒绝。
// 若哪天这条断言失效(gcron 升级后支持了 5 段),说明本文件的假设已过期,需重新核对。
func TestFivePartCronPatternIsRejected(t *testing.T) {
const bad = "*/1 * * * *"
if _, err := gcron.AddSingleton(
context.Background(), bad, func(ctx context.Context) {}, "probe-bad-5part",
); err == nil {
t.Fatalf("5 段 pattern %q 竟被 gcron 接受,本文件假设已失效,请重新核对 gcron 行为", bad)
}
gcron.Remove("probe-bad-5part")
}
// TestQrCodeStaticPathBootOrder 固化「静态目录必须先存在」这一启动约束。
//
// 机理(已读 gf 源码确认,net/ghttp/ghttp_server_config_static.go:93):
//
// if p, err := gfile.Search(path); err != nil {
// s.Logger().Fatalf(ctx, `AddStaticPath failed: %+v`, err)
// }
//
// 是 Fatalf → os.Exit(1),**不是 panic、无法 recover**。
// 而 manifest/qrcode 原先只在「生成小程序码」时才惰性创建 →
// 全新环境首次启动必然崩在注册静态路由这一步(先有鸡先有蛋的死锁)。
// 修法:cmd.go 先 gfile.Mkdir(logic.QrCodeDir) 再 AddStaticPath。
func TestQrCodeStaticPathBootOrder(t *testing.T) {
chdirRepoRoot(t)
// ① 目录不存在时 gfile.Search 会报错(这就是上面 Fatalf 的触发条件)。
// 用 Search 探测而不真的调 AddStaticPath —— 后者会直接 os.Exit(1) 杀掉测试进程。
t.Run("missing_dir_makes_gf_search_fail", func(t *testing.T) {
if _, err := gfile.Search("manifest/__definitely_absent__"); err == nil {
t.Error("gfile.Search 对不存在的目录竟返回成功,本用例假设已失效(gf 行为可能已变更)")
}
})
// ② 预创建目录后,AddStaticPath 可以正常注册(即 cmd.go 的修复路径)。
// AddStaticPath 无返回值,能走到下一行即为通过。
t.Run("precreated_dir_registers_ok", func(t *testing.T) {
if err := gfile.Mkdir(logic.QrCodeDir); err != nil {
t.Fatalf("gfile.Mkdir(%q) 失败:%v", logic.QrCodeDir, err)
}
if !gfile.Exists(logic.QrCodeDir) {
t.Fatalf("Mkdir 之后目录仍不存在:%q", logic.QrCodeDir)
}
g.Server("static-path-probe").AddStaticPath("/qrcode", logic.QrCodeDir)
})
}
// chdirRepoRoot 把工作目录切到仓库根目录。
// go test 的 CWD 是「包目录」(internal/cmd),直接用相对路径会跑偏到 internal/cmd/manifest/qrcode,
// 与被测代码在真实运行时的 CWD(仓库根)不一致。
func chdirRepoRoot(t *testing.T) {
t.Helper()
old, err := os.Getwd()
if err != nil {
t.Fatalf("获取当前工作目录失败:%v", err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
dir := old
for i := 0; i < 8; i++ {
if gfile.Exists(gfile.Join(dir, "go.mod")) {
if err = os.Chdir(dir); err != nil {
t.Fatalf("切换到仓库根目录 %s 失败:%v", dir, err)
}
return
}
dir = gfile.Dir(dir)
}
t.Fatal("向上 8 层未找到 go.mod,无法定位仓库根目录")
}