Files
panel/internal/bootstrap/http.go
T
耗子andClaude Opus 4.8 443516a2cf refactor!: 迁移至 samber/do 依赖注入与三层架构
依赖注入:
- 移除 google/wire(含 wire.go/wire_gen.go 与全部 ProviderSet),
  改用 samber/do v2 单注入器 + 双入口惰性构建
- 贡献模型替代命令式注册:路由 routes:、命令 commands:、
  任务 jobs: 前缀经 internal/registry 收集与校验
- 构造函数统一 func NewXxx(i do.Injector) (T, error)

三层架构:
- 补全用例层:每个 biz.XxxRepo 配 XxxUsecase,
  service/command/job 表现层只依赖用例,不再直接引用仓储

路由与文档:
- route/http.go 按域拆为声明式 Endpoint 贡献,
  Endpoint 承载登录白名单与端点限流语义
- 调试模式下提供 OpenAPI 3.1 文档:/openapi.json 与 /docs(Scalar),
  从 validate 标签生成

目录与依赖:
- internal/http/{middleware,request,rule} 拍平至 internal/*
- CLI 命令拆至 internal/command
- 日志轮转 timberjack 换为 libtnb/logrotate
- 升级 validator/cron/sessions/gormstore/sqlite/securecookie
- 数据库迁移保持 gormigrate 不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:54:22 +08:00

185 lines
4.8 KiB
Go

package bootstrap
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/andybalholm/brotli"
"github.com/bddjr/hlfhr"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/validator"
"github.com/libtnb/validator/contrib/openapi"
"github.com/samber/do/v2"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/middleware"
"github.com/acepanel/panel/v3/internal/route"
"github.com/acepanel/panel/v3/pkg/apploader"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/embed"
"github.com/acepanel/panel/v3/pkg/tlscert"
)
func NewRouter(i do.Injector) (*chi.Mux, error) {
t := do.MustInvoke[*gotext.Locale](i)
mws := do.MustInvoke[*middleware.Middlewares](i)
conf := do.MustInvoke[*config.Config](i)
loader := do.MustInvoke[*apploader.Loader](i)
// 供 service.Bind / route.SpecJSON 使用
validator.SetDefault(do.MustInvoke[*validator.Validator](i))
// 数据驱动的登录白名单
public, err := route.PublicPaths(i)
if err != nil {
return nil, err
}
r := chi.NewRouter()
r.Use(mws.Globals(t, r, public)...)
// 注册各域路由
if err := route.HTTP(i, r); err != nil {
return nil, err
}
// 动态应用子路由
r.Route("/api/apps", func(r chi.Router) {
loader.Register(r)
})
// 仅调试模式挂载 OpenAPI 文档
if conf.App.Debug {
spec, err := route.SpecJSON(i, "AcePanel")
if err != nil {
return nil, err
}
docs := openapi.DocsHTML("AcePanel", "/openapi.json")
r.Get("/openapi.json", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(spec)
})
r.Get("/docs", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(docs)
})
}
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
// /api 开头的返回 404
if strings.HasPrefix(req.URL.Path, "/api") {
http.NotFound(w, req)
return
}
// 其他返回前端页面
frontend, _ := fs.Sub(embed.PublicFS, "frontend")
newPrecompressedSPAHandler(http.FS(frontend)).ServeHTTP(w, req)
})
return r, nil
}
func NewTLSReloader(i do.Injector) (*tlscert.Reloader, error) {
conf := do.MustInvoke[*config.Config](i)
if !conf.HTTP.IsHTTPS() {
return nil, nil
}
certFile := filepath.Join(app.Root, "panel/storage/cert.pem")
keyFile := filepath.Join(app.Root, "panel/storage/cert.key")
reloader, err := tlscert.NewReloader(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("failed to load certificate: %w", err)
}
return reloader, nil
}
func NewHttp(i do.Injector) (*hlfhr.Server, error) {
conf := do.MustInvoke[*config.Config](i)
mux := do.MustInvoke[*chi.Mux](i)
reloader := do.MustInvoke[*tlscert.Reloader](i)
srv := hlfhr.New(&http.Server{
Addr: fmt.Sprintf(":%d", conf.HTTP.Port),
Handler: mux,
MaxHeaderBytes: 4 << 20,
})
srv.Listen80RedirectTo443 = true
if conf.HTTP.IsHTTPS() && reloader != nil {
srv.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: reloader.GetCertificate,
}
}
return srv, nil
}
func newPrecompressedSPAHandler(fsys http.FileSystem) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
acceptBr := strings.Contains(r.Header.Get("Accept-Encoding"), "br")
if served := serveFileWithBr(w, r, fsys, path, acceptBr); served {
return
}
// 文件不存在,SPA fallback 到 index.html
serveFileWithBr(w, r, fsys, "/index.html", acceptBr)
}
}
func serveFileWithBr(w http.ResponseWriter, r *http.Request, fsys http.FileSystem, path string, acceptBr bool) bool {
name := filepath.Base(path)
// 尝试打开 .br 版本
if f, err := fsys.Open(path + ".br"); err == nil {
defer func(f http.File) { _ = f.Close() }(f)
fi, err := f.Stat()
if err != nil || fi.IsDir() {
return false
}
ct := mime.TypeByExtension(filepath.Ext(path))
if ct == "" {
ct = "application/octet-stream"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Vary", "Accept-Encoding")
if acceptBr {
// 客户端支持 br,直接透传
w.Header().Set("Content-Encoding", "br")
http.ServeContent(w, r, name, fi.ModTime(), f)
} else {
// 客户端不支持 br,解压后返回(由中间件处理 gzip)
decoded, err := io.ReadAll(brotli.NewReader(f))
if err != nil {
return false
}
http.ServeContent(w, r, name, fi.ModTime(), bytes.NewReader(decoded))
}
return true
}
// 回退到原始文件(字体、图片等未压缩的资源)
f, err := fsys.Open(path)
if err != nil {
return false
}
defer func(f http.File) { _ = f.Close() }(f)
fi, err := f.Stat()
if err != nil || fi.IsDir() {
return false
}
http.ServeContent(w, r, name, fi.ModTime(), f)
return true
}