迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)

This commit is contained in:
2026-08-24 12:35:24 +08:00
parent d056f01965
commit 961523d94c
218 changed files with 13391 additions and 3232 deletions
+23
View File
@@ -0,0 +1,23 @@
package common
import (
"net/http"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// AdminAuth 管理端静态 token 鉴权中间件:请求头 X-Admin-Token 须与 config.yml
// admin.token 一致;token 未配置(空)时管理接口全部拒绝。
func AdminAuth(r *ghttp.Request) {
want := g.Cfg().MustGet(r.GetCtx(), "admin.token", "").String()
if want == "" || r.Header.Get("X-Admin-Token") != want {
r.Response.WriteStatusExit(http.StatusUnauthorized, g.Map{
"code": gcode.CodeNotAuthorized.Code(),
"message": "管理端未授权",
"data": nil,
})
}
r.Middleware.Next()
}
+126
View File
@@ -0,0 +1,126 @@
package common
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// 登录 tokenHMAC-SHA256 自签名,无状态):
// token = base64url(payload) + "." + hex(HMAC-SHA256(payload, auth.secret))
// payload = {"phone": "...", "exp": <unix 秒>}secret 轮换即全员下线。
var ctxKeyPhone = struct{}{}
// AuthTokenTtl 登录 token 有效期(秒),来自 config.yml auth.tokenTtl,缺失或非法回退 30 天。
func AuthTokenTtl(ctx context.Context) time.Duration {
ttl := g.Cfg().MustGet(ctx, "auth.tokenTtl", 2592000).Int64()
if ttl <= 0 {
ttl = 2592000
}
return time.Duration(ttl) * time.Second
}
// SignToken 签发登录 tokensecret 未配置时报错,注册/登录不可用)
func SignToken(ctx context.Context, phone string) (string, error) {
secret := g.Cfg().MustGet(ctx, "auth.secret", "").String()
if secret == "" {
return "", gerror.New("登录未配置(检查 config.yml auth.secret")
}
payload, err := json.Marshal(map[string]any{
"phone": phone,
"exp": time.Now().Add(AuthTokenTtl(ctx)).Unix(),
})
if err != nil {
return "", gerror.Wrap(err, "序列化 token 载荷失败")
}
return tokenOf(payload, secret), nil
}
// ParseToken 校验并解析 token,返回 phone
func ParseToken(ctx context.Context, token string) (string, error) {
secret := g.Cfg().MustGet(ctx, "auth.secret", "").String()
if secret == "" {
return "", gerror.New("登录未配置")
}
i := strings.LastIndex(token, ".")
if i <= 0 {
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 格式错误")
}
payload, sig := token[:i], token[i+1:]
raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 载荷无效")
}
// 签名覆盖原始载荷字节(签发时对 raw 签名,非 base64 串)
if !hmac.Equal([]byte(sig), []byte(signPayload(string(raw), secret))) {
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 签名无效")
}
var body struct {
Phone string `json:"phone"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 载荷无效")
}
if body.Phone == "" || body.Exp < time.Now().Unix() {
return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 已过期")
}
return body.Phone, nil
}
func tokenOf(payload []byte, secret string) string {
return base64.RawURLEncoding.EncodeToString(payload) + "." + signPayload(string(payload), secret)
}
func signPayload(payload, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))
}
// AuthRequired 登录态鉴权中间件:校验 Authorization: Bearer <token>
// 解出手机号注入请求上下文(service 经 PhoneFromCtx 读取)。
func AuthRequired(r *ghttp.Request) {
phone, err := ParseToken(r.GetCtx(), bearerToken(r.Header.Get("Authorization")))
if err != nil {
r.Response.WriteStatusExit(http.StatusUnauthorized, g.Map{
"code": gcode.CodeNotAuthorized.Code(),
"message": "登录已失效,请重新登录",
"data": nil,
})
}
r.SetCtx(WithPhone(r.GetCtx(), phone))
r.Middleware.Next()
}
func bearerToken(header string) string {
if strings.HasPrefix(header, "Bearer ") {
return strings.TrimPrefix(header, "Bearer ")
}
return ""
}
// PhoneFromCtx 取 AuthRequired 注入的登录手机号
func PhoneFromCtx(ctx context.Context) string {
if v, ok := ctx.Value(ctxKeyPhone).(string); ok {
return v
}
return ""
}
// WithPhone 注入登录手机号到 ctxAuthRequired 中间件与白盒测试共用)
func WithPhone(ctx context.Context, phone string) context.Context {
return context.WithValue(ctx, ctxKeyPhone, phone)
}
+89
View File
@@ -0,0 +1,89 @@
package common
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"regexp"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// IsNoRows 判断 Scan 空结果(结构体 Scan 无行时返回 sql.ErrNoRows),
// dao 查询方法据此返回 nil 实体而非错误。
func IsNoRows(err error) bool {
return err != nil && errors.Is(err, sql.ErrNoRows)
}
// CacheTTL 查询缓存 TTL(秒),来自 config.yml database.cache.ttl,缺失或非法回退默认值。
func CacheTTL(ctx context.Context) time.Duration {
ttl := g.Cfg().MustGet(ctx, "database.cache.ttl", 30).Int()
if ttl <= 0 {
ttl = 30
}
return time.Duration(ttl) * time.Second
}
// CacheOption 构造带显式缓存键的查询缓存选项:键须含业务参数(如 license:设备号),
// 写操作后必须按同键调用 ClearCache,否则「库里已改、查询还是旧值」。
func CacheOption(ctx context.Context, key string) gdb.CacheOption {
return gdb.CacheOption{Duration: CacheTTL(ctx), Force: false, Name: key}
}
// gdb 的查询缓存键 = 固定前缀 + 自定义 Name(见 gdb.genSelectCacheKey,前缀常量未导出),
// 清除时必须拼上同一前缀,否则键对不上、缓存永远清不掉。
const selectCachePrefix = "SelectCache:"
// ClearCache 清除查询缓存(写操作后必须调用)。清除失败仅记录日志:缓存 TTL 自愈兜底,
// 不阻断业务主链路。
func ClearCache(ctx context.Context, keys ...string) {
if len(keys) == 0 {
return
}
for _, key := range keys {
if _, err := g.DB().GetCache().Remove(ctx, selectCachePrefix+key); err != nil {
g.Log().Warningf(ctx, "清除查询缓存 %q 失败: %+v", key, err)
}
}
}
// DropLegacyTableIfHasColumn 存量库迁移:表存在旧版本废弃列(账号体系上线前的 device_id)
// 时 DROP 整表(存量数据作废,用户决策),由 dao init 以新结构重建。
func DropLegacyTableIfHasColumn(ctx context.Context, table, column string) {
res, err := g.DB().GetAll(ctx, "PRAGMA table_info("+table+")")
if err != nil {
panic("检查表结构失败 " + table + ": " + err.Error())
}
for _, r := range res {
if gconv.String(r["name"]) == column {
if _, err := g.DB().Exec(ctx, "DROP TABLE "+table); err != nil {
panic("DROP 旧表失败 " + table + ": " + err.Error())
}
g.Log().Warningf(ctx, "存量表 %s 为旧结构(列 %s),已 DROP 由新结构重建(存量数据作废)", table, column)
return
}
}
}
var fileLinkRegexp = regexp.MustCompile(`@file\(([^)]+)\)`)
// init 确保 SQLite 数据文件所在目录存在(驱动打开文件前必须已创建,否则报 unable to open)。
// 必须在任何 g.DB() 调用(含 dao 包 init 建表)之前执行。
func init() {
ctx := context.Background()
link := g.Cfg().MustGet(ctx, "database.default.link", "").String()
file := link
if m := fileLinkRegexp.FindStringSubmatch(link); len(m) == 2 {
file = m[1]
}
if dir := filepath.Dir(file); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
panic("创建数据目录失败: " + err.Error())
}
}
}
+9
View File
@@ -0,0 +1,9 @@
package common
import "github.com/gogf/gf/v2/net/ghttp"
// BindController 反射注册一组 controller 到路由组:接口 path/method 唯一来源为 dto 内嵌的
// g.Meta(携带 path/method/summary),禁止在 main.go 手动逐条注册。
func BindController(group *ghttp.RouterGroup, controllers ...interface{}) {
group.Bind(controllers...)
}
+14
View File
@@ -0,0 +1,14 @@
package common
import "github.com/gogf/gf/v2/errors/gcode"
// 业务错误码(1000+,框架保留 <1000):统一 HTTP 200 + body code!=0 表示失败,
// 客户端按 code 分支。错误统一用 gerror.NewCode(common.CodeXxx, "...") 构造。
var (
CodePlanNotConfigured = gcode.New(1001, "套餐不存在或未配置", nil)
CodePaymentNotConfigured = gcode.New(1002, "支付渠道未配置", nil)
CodeOrderNotFound = gcode.New(1003, "订单不存在", nil)
CodeOrderClosed = gcode.New(1004, "订单已关闭,需重新下单", nil)
CodeCallbackVerifyFailed = gcode.New(1005, "回调验签失败", nil)
CodeCallbackMismatch = gcode.New(1006, "回调商户/金额不匹配", nil)
)
+15
View File
@@ -0,0 +1,15 @@
package common
// NormalizePage 分页钳制:page ≥1(默认 1)、size 1..100(默认 20
func NormalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
return page, size
}
+37
View File
@@ -0,0 +1,37 @@
package common
import (
"context"
"github.com/gogf/gf/v2/frame/g"
)
// Plan 套餐定价(config.yml plans 节点,静态配置;展示名由 days 派生"N天"
type Plan struct {
Id string `mapstructure:"id"`
Days int `mapstructure:"days"`
PriceCents int64 `mapstructure:"price_cents"`
}
// ListPlans 全部套餐(按配置声明顺序)
func ListPlans(ctx context.Context) ([]*Plan, error) {
var plans []*Plan
if err := g.Cfg().MustGet(ctx, "plans").Scan(&plans); err != nil {
return nil, err
}
return plans, nil
}
// GetPlan 按套餐 ID 查找,不存在返回 (nil, nil),调用方用 CodePlanNotConfigured 包装
func GetPlan(ctx context.Context, id string) (*Plan, error) {
plans, err := ListPlans(ctx)
if err != nil {
return nil, err
}
for _, p := range plans {
if p.Id == id {
return p, nil
}
}
return nil, nil
}
+52
View File
@@ -0,0 +1,52 @@
package common
import (
"context"
"sync"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/grpool"
"observer-server/biz/consts"
)
// CallbackPool 支付回调处理并发池:验签/落授权等 IO 任务在池内执行并返回结果,
// 并发度来自 config.yml payment.poolSize,缺失或非法时回退 consts 默认值。
// 池内任务禁止再提交本池(防 worker 饿死死锁)。
type CallbackPool struct {
pool *grpool.Pool
}
var (
callbackPoolOnce sync.Once
callbackPool *CallbackPool
)
// CallbackPoolInstance 进程级回调池单例(懒初始化,读取配置)。
func CallbackPoolInstance() *CallbackPool {
callbackPoolOnce.Do(func() {
ctx := context.Background()
size := g.Cfg().MustGet(ctx, "payment.poolSize", consts.PaymentPoolDefaultSize).Int()
if size <= 0 {
size = consts.PaymentPoolDefaultSize
}
callbackPool = &CallbackPool{pool: grpool.New(size, size)}
})
return callbackPool
}
// Submit 提交回调任务并等待执行完成,返回任务的 error。
func (p *CallbackPool) Submit(ctx context.Context, fn func(ctx context.Context) error) error {
res := make(chan error, 1)
if err := p.pool.Add(ctx, func(ctx context.Context) {
res <- fn(ctx)
}); err != nil {
return err
}
select {
case err := <-res:
return err
case <-ctx.Done():
return ctx.Err()
}
}
+30
View File
@@ -0,0 +1,30 @@
package common
import (
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// UnifiedResponse 统一响应中间件:挂在客户端路由组上,输出 {"code":0,"message":"ok","data":...}。
// handler 返回 error 时输出 {code, message, data:null}code 取 gerror 附带的业务码);
// 已直接写响应体的例外场景(SSE、文件导出等)跳过包装。
func UnifiedResponse(r *ghttp.Request) {
r.Middleware.Next()
// handler 已直接写响应体(例外场景),不再包装
if r.Response.BufferLength() > 0 {
return
}
if err := r.GetError(); err != nil {
code := gerror.Code(err)
if code == gcode.CodeNil {
code = gcode.CodeInternalError
}
r.Response.WriteJson(g.Map{"code": code.Code(), "message": err.Error(), "data": nil})
return
}
r.Response.WriteJson(g.Map{"code": 0, "message": "ok", "data": r.GetHandlerResponse()})
}
+45
View File
@@ -0,0 +1,45 @@
package common
import "context"
// serialWriter 单写者执行器:所有 SQLite 写链路(含「查订单→算 expiresAt→更新订单→写 license→
// 清缓存」等读改写事务)排入同一 goroutine 串行执行,规避无 WAL 时并发写 "database is locked"
// 任务结果经 buffered channel 回调用方,不额外开池(写路径天然无并行点)。
type serialWriter struct {
jobs chan func()
}
var serial = &serialWriter{jobs: make(chan func(), 64)}
func init() {
go func() {
for f := range serial.jobs {
f()
}
}()
}
// Serial 返回进程级单写者:SQLite 写操作必须经此串行执行。
func Serial() *serialWriter {
return serial
}
// Submit 排队执行写任务并等待结果,返回任务的 error;ctx 取消/超时不再等待。
// 已排队未执行的任务在 ctx 取消后仍会执行(结果入 buffered channel 无泄漏),
// 调用方以返回的 ctx.Err() 为准不再消费其结果。
func (w *serialWriter) Submit(ctx context.Context, fn func() error) error {
res := make(chan error, 1)
select {
case w.jobs <- func() {
res <- fn()
}:
case <-ctx.Done():
return ctx.Err()
}
select {
case err := <-res:
return err
case <-ctx.Done():
return ctx.Err()
}
}
+34
View File
@@ -0,0 +1,34 @@
package common
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gogf/gf/v2/net/ghttp"
)
// SpaFallback 单页应用 history 路由回退中间件:prefix 路径下请求对应的静态文件不存在
// (即前端路由,如 /admin/orders)时回退 index.html;真实静态文件交给后续静态服务处理。
// root 为前端构建产物目录,prefix 为托管路径前缀(如 "/admin")。
func SpaFallback(root, prefix string) ghttp.HandlerFunc {
root = filepath.Clean(root)
index := filepath.Join(root, "index.html")
return func(r *ghttp.Request) {
path := r.URL.Path
if r.Method == http.MethodGet && (path == prefix || strings.HasPrefix(path, prefix+"/")) {
rel := strings.TrimPrefix(path, prefix)
candidate := filepath.Clean(filepath.Join(root, rel))
if !strings.HasPrefix(candidate, root) {
r.Middleware.Next()
return
}
if _, err := os.Stat(candidate); err != nil {
r.Response.ServeFile(index)
return
}
}
r.Middleware.Next()
}
}
+5
View File
@@ -0,0 +1,5 @@
package common
// 全局统一注册 SQLite 驱动(main 与服务层白盒测试共用同一注册点,
// 测试二进制不编译 main.go,驱动须在 common 层保证加载)
import _ "github.com/gogf/gf/contrib/drivers/sqlite/v2"