迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// 登录 token(HMAC-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 签发登录 token(secret 未配置时报错,注册/登录不可用)
|
||||
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 注入登录手机号到 ctx(AuthRequired 中间件与白盒测试共用)
|
||||
func WithPhone(ctx context.Context, phone string) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyPhone, phone)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package common
|
||||
|
||||
// 全局统一注册 SQLite 驱动(main 与服务层白盒测试共用同一注册点,
|
||||
// 测试二进制不编译 main.go,驱动须在 common 层保证加载)
|
||||
import _ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
Reference in New Issue
Block a user