优化结构
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/modules/system/service"
|
||||
"kra/internal/modules/system/transport/handler"
|
||||
"kra/internal/modules/system/transport/httpx"
|
||||
servermiddleware "kra/internal/modules/system/transport/middleware"
|
||||
serverrouter "kra/internal/modules/system/transport/router"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
kratoshttp "github.com/go-kratos/kratos/v3/transport/http"
|
||||
)
|
||||
|
||||
func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, handlers *handler.Set, auth *service.AuthService, security *service.SecurityService, audit *service.AuditRecorder, logger *slog.Logger, version string) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine := gin.New()
|
||||
if err := engine.SetTrustedProxies(nil); err != nil && logger != nil {
|
||||
logger.Error("disable trusted proxies failed", "error", err)
|
||||
}
|
||||
engine.Use(servermiddleware.RequestMeta(), servermiddleware.Recovery(logger), servermiddleware.AccessLog(runtime, logger, version), servermiddleware.CORS(runtime), servermiddleware.ErrorAudit(logger), servermiddleware.SecurityRateLimit(security))
|
||||
|
||||
prefix := ""
|
||||
config := runtime.Admin()
|
||||
if config != nil {
|
||||
prefix = strings.TrimSuffix(config.RouterPrefix, "/")
|
||||
}
|
||||
public := engine.Group(prefix)
|
||||
public.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, "ok") })
|
||||
serverrouter.RegisterPublic(public, engine, handlers.Public)
|
||||
|
||||
private := engine.Group(prefix)
|
||||
// The reference administration behavior installs operation recording after JWT,
|
||||
// password and Casbin/data-scope middleware. Keeping the recorder after the
|
||||
// equivalent Kra guards avoids persisting rejected unauthenticated or
|
||||
// unauthorized requests as successful business operations.
|
||||
private.Use(servermiddleware.Auth(auth), servermiddleware.MustChangePassword(), servermiddleware.AccessControl(runtime, access), servermiddleware.OperationAudit(runtime, audit))
|
||||
serverrouter.RegisterUser(private, handlers.User)
|
||||
serverrouter.RegisterNavigation(private, handlers.Navigation)
|
||||
serverrouter.RegisterSession(private, handlers.Session)
|
||||
serverrouter.RegisterAuthority(private, handlers.Authority)
|
||||
serverrouter.RegisterMenu(private, handlers.Menu)
|
||||
serverrouter.RegisterAPI(private, public, engine, handlers.API)
|
||||
serverrouter.RegisterPermission(private, handlers.Permission)
|
||||
serverrouter.RegisterOrganization(private, handlers.Organization)
|
||||
serverrouter.RegisterDictionary(private, handlers.Dictionary)
|
||||
serverrouter.RegisterParameter(private, handlers.Parameter)
|
||||
serverrouter.RegisterAPIToken(private, handlers.APIToken)
|
||||
serverrouter.RegisterSystemConfig(private, handlers.SystemConfig)
|
||||
serverrouter.RegisterVersion(private, handlers.Version)
|
||||
serverrouter.RegisterExport(private, public, handlers.Export)
|
||||
serverrouter.RegisterAudit(private, public, handlers.Audit)
|
||||
serverrouter.RegisterTask(private, handlers.Task)
|
||||
serverrouter.RegisterMedia(private, handlers.Media)
|
||||
serverrouter.RegisterAnnouncement(private, public, handlers.Announcement)
|
||||
serverrouter.RegisterEmail(private, handlers.Email)
|
||||
serverrouter.RegisterPayment(private, public, handlers.Payment)
|
||||
registerSwagger(engine, prefix, version, logger)
|
||||
registerLocalStorage(engine, runtime)
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
if serveLocalStorage(c, runtime) {
|
||||
return
|
||||
}
|
||||
httpx.Fail(c, "请求的接口不存在")
|
||||
})
|
||||
logRegisteredRoutes(engine, logger)
|
||||
return engine
|
||||
}
|
||||
|
||||
func logRegisteredRoutes(engine *gin.Engine, logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
routes := append([]gin.RouteInfo(nil), engine.Routes()...)
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
if routes[i].Path == routes[j].Path {
|
||||
return routes[i].Method < routes[j].Method
|
||||
}
|
||||
return routes[i].Path < routes[j].Path
|
||||
})
|
||||
systemLogger := logger.With("mod", "system")
|
||||
for _, route := range routes {
|
||||
systemLogger.Info("router registered", "method", route.Method, "path", route.Path)
|
||||
}
|
||||
systemLogger.Info("router register success", "route_count", len(routes))
|
||||
}
|
||||
|
||||
func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.Server {
|
||||
network, address := "tcp", ":8000"
|
||||
if c != nil && c.Http != nil {
|
||||
if c.Http.Network != "" {
|
||||
network = c.Http.Network
|
||||
}
|
||||
if c.Http.Addr != "" {
|
||||
address = c.Http.Addr
|
||||
}
|
||||
}
|
||||
timeout := 10 * time.Minute
|
||||
if c != nil && c.Http != nil && c.Http.Timeout != nil && c.Http.Timeout.AsDuration() > 0 {
|
||||
timeout = c.Http.Timeout.AsDuration()
|
||||
}
|
||||
server := kratoshttp.NewServer(kratoshttp.Network(network), kratoshttp.Address(address), kratoshttp.Timeout(timeout))
|
||||
server.HandlePrefix("/", engine)
|
||||
server.Server.ReadHeaderTimeout = 10 * time.Second
|
||||
server.Server.ReadTimeout = timeout
|
||||
server.Server.WriteTimeout = timeout
|
||||
server.Server.IdleTimeout = 2 * time.Minute
|
||||
server.Server.MaxHeaderBytes = 1 << 20
|
||||
return server
|
||||
}
|
||||
|
||||
// serveLocalStorage resolves the local path for every request so a config
|
||||
// reload takes effect without rebuilding the Gin engine.
|
||||
func serveLocalStorage(c *gin.Context, runtime *conf.Runtime) bool {
|
||||
config := runtime.Admin()
|
||||
if config == nil || config.Local == nil || config.Local.StorePath == "" {
|
||||
return false
|
||||
}
|
||||
prefix := "/" + strings.Trim(config.Local.PathPrefix, "/")
|
||||
return serveLocalStorageAt(c, runtime, prefix)
|
||||
}
|
||||
|
||||
func registerLocalStorage(engine *gin.Engine, runtime *conf.Runtime) {
|
||||
config := runtime.Admin()
|
||||
if config == nil || config.Local == nil || config.Local.StorePath == "" || strings.Trim(config.Local.PathPrefix, "/") == "" {
|
||||
return
|
||||
}
|
||||
if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" {
|
||||
return
|
||||
}
|
||||
prefix := "/" + strings.Trim(config.Local.PathPrefix, "/")
|
||||
if localStorageRouteConflicts(engine.Routes(), prefix) {
|
||||
return
|
||||
}
|
||||
handler := func(c *gin.Context) {
|
||||
if !serveLocalStorageAt(c, runtime, prefix) {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
engine.GET(prefix+"/*filepath", handler)
|
||||
engine.HEAD(prefix+"/*filepath", handler)
|
||||
}
|
||||
|
||||
func localStorageRouteConflicts(routes []gin.RouteInfo, prefix string) bool {
|
||||
staticRoot := strings.Split(strings.TrimPrefix(prefix, "/"), "/")[0]
|
||||
for _, route := range routes {
|
||||
if route.Method != http.MethodGet && route.Method != http.MethodHead {
|
||||
continue
|
||||
}
|
||||
routeRoot := strings.Split(strings.TrimPrefix(route.Path, "/"), "/")[0]
|
||||
if routeRoot == staticRoot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func serveLocalStorageAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool {
|
||||
config := runtime.Admin()
|
||||
if config == nil || config.Local == nil || config.Local.StorePath == "" {
|
||||
return false
|
||||
}
|
||||
if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" {
|
||||
return false
|
||||
}
|
||||
if currentPrefix := "/" + strings.Trim(config.Local.PathPrefix, "/"); currentPrefix != prefix {
|
||||
return false
|
||||
}
|
||||
if prefix == "/" || (c.Request.URL.Path != prefix && !strings.HasPrefix(c.Request.URL.Path, prefix+"/")) {
|
||||
return false
|
||||
}
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
filename := path.Base(c.Request.URL.Path)
|
||||
if !canServeUploadInline(filename) {
|
||||
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
|
||||
}
|
||||
http.StripPrefix(prefix, http.FileServer(filesOnly{FileSystem: http.Dir(config.Local.StorePath)})).ServeHTTP(c.Writer, c.Request)
|
||||
return true
|
||||
}
|
||||
|
||||
func canServeUploadInline(filename string) bool {
|
||||
switch strings.ToLower(path.Ext(filename)) {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".avif",
|
||||
".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac",
|
||||
".mp4", ".webm", ".mov", ".avi", ".mkv":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type filesOnly struct{ http.FileSystem }
|
||||
|
||||
func (f filesOnly) Open(name string) (http.File, error) {
|
||||
file, err := f.FileSystem.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, statErr := file.Stat()
|
||||
if statErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, statErr
|
||||
}
|
||||
if info.IsDir() {
|
||||
_ = file.Close()
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/internal/modules/system/transport/handler"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func emptyHandlers() *handler.Set {
|
||||
return &handler.Set{
|
||||
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
|
||||
Permission: &handler.Permission{}, Organization: &handler.Organization{},
|
||||
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Payment: &handler.Payment{}, Task: &handler.Task{},
|
||||
Media: &handler.Media{}, Audit: &handler.Audit{}, Export: &handler.Export{},
|
||||
Version: &handler.Version{}, Dictionary: &handler.Dictionary{}, Parameter: &handler.Parameter{},
|
||||
APIToken: &handler.APIToken{}, SystemConfig: &handler.SystemConfig{}, Public: &handler.Public{},
|
||||
User: &handler.User{}, Navigation: &handler.Navigation{}, Session: &handler.Session{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGinRouteContract(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
routes := engine.Routes()
|
||||
actual := make([]string, 0, len(routes))
|
||||
seen := make(map[string]struct{}, len(routes))
|
||||
for _, route := range routes {
|
||||
key := route.Method + " " + route.Path
|
||||
if _, exists := seen[key]; exists {
|
||||
t.Fatalf("duplicate route %s", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
actual = append(actual, key)
|
||||
}
|
||||
sort.Strings(actual)
|
||||
if value := strings.Join(actual, "\n"); value != expectedGinRouteContract {
|
||||
t.Fatalf("route contract changed:\n%s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&output, nil))
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, logger, "test")
|
||||
text := output.String()
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":186`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)
|
||||
engine.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("swagger document status = %d, body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{`"swagger":"2.0"`, `"version":"v1.0.0"`, `"/base/login"`, `"/timedTask/triggerTimedTask"`, `"/mediaUpload/{uploadId}"`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("swagger document missing %s", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwaggerSupportsRouterPrefix(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{RouterPrefix: "/admin"}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
for _, path := range []string{"/admin/swagger/index.html", "/admin/swagger/doc.json"} {
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
engine.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("swagger path %s status = %d, body=%s", path, response.Code, response.Body.String())
|
||||
}
|
||||
if strings.HasSuffix(path, "/doc.json") && !strings.Contains(response.Body.String(), `"basePath":"/admin"`) {
|
||||
t.Fatalf("swagger document does not use router prefix: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil))
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"basePath":"/"`) {
|
||||
t.Fatalf("swagger basePath mismatch: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
engine.GET("/client-ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) })
|
||||
request := httptest.NewRequest(http.MethodGet, "/client-ip", nil)
|
||||
request.RemoteAddr = "203.0.113.10:4321"
|
||||
request.Header.Set("X-Forwarded-For", "198.51.100.20")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || response.Body.String() != "203.0.113.10" {
|
||||
t.Fatalf("client IP = %q, status=%d", response.Body.String(), response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementDataSourceRequiresAuthentication(t *testing.T) {
|
||||
engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/info/getInfoDataSource", nil))
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageResponseHeaders(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, body := range map[string]string{"script.html": "<script>alert(1)</script>", "image.png": "png"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
attachment bool
|
||||
}{
|
||||
{path: "/uploads/file/script.html", attachment: true},
|
||||
{path: "/uploads/file/image.png", attachment: false},
|
||||
} {
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, test.path, nil)
|
||||
engine.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || response.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("unexpected static response for %s: status=%d headers=%v", test.path, response.Code, response.Header())
|
||||
}
|
||||
hasAttachment := strings.Contains(response.Header().Get("Content-Disposition"), "attachment")
|
||||
if hasAttachment != test.attachment {
|
||||
t.Fatalf("attachment header for %s = %v, want %v", test.path, hasAttachment, test.attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageRoutesAreRegistered(t *testing.T) {
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
seen := map[string]bool{}
|
||||
for _, route := range engine.Routes() {
|
||||
seen[route.Method+" "+route.Path] = true
|
||||
}
|
||||
for _, expected := range []string{"GET /uploads/file/*filepath", "HEAD /uploads/file/*filepath"} {
|
||||
if !seen[expected] {
|
||||
t.Fatalf("missing registered static route %s", expected)
|
||||
}
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil))
|
||||
if strings.Contains(response.Body.String(), "/uploads/file/") {
|
||||
t.Fatal("swagger document should not expose static file wildcard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageOldPrefixStopsServingAfterReload(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "image.png"), []byte("png"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
runtime.Replace(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: root, PathPrefix: "files"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
|
||||
|
||||
oldResponse := httptest.NewRecorder()
|
||||
engine.ServeHTTP(oldResponse, httptest.NewRequest(http.MethodGet, "/uploads/file/image.png", nil))
|
||||
if oldResponse.Code != http.StatusNotFound {
|
||||
t.Fatalf("old static prefix status = %d, want %d", oldResponse.Code, http.StatusNotFound)
|
||||
}
|
||||
newResponse := httptest.NewRecorder()
|
||||
engine.ServeHTTP(newResponse, httptest.NewRequest(http.MethodGet, "/files/image.png", nil))
|
||||
if newResponse.Code != http.StatusOK {
|
||||
t.Fatalf("new static prefix status = %d, want %d", newResponse.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageConflictingPrefixFallsBackWithoutStartupPanic(t *testing.T) {
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "api"}, Storage: &conf.AdminBackend_Storage{Type: "local"}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
for _, route := range engine.Routes() {
|
||||
if route.Method == http.MethodGet && route.Path == "/api/*filepath" {
|
||||
t.Fatal("conflicting static wildcard must not be registered")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageRouteIsNotRegisteredForRemoteStorage(t *testing.T) {
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Local: &conf.AdminBackend_Local{StorePath: t.TempDir(), PathPrefix: "uploads/file"}, Storage: &conf.AdminBackend_Storage{Type: "s3"}})
|
||||
engine := NewGinEngine(runtime, nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
for _, route := range engine.Routes() {
|
||||
if strings.HasSuffix(route.Path, "/*filepath") && strings.HasPrefix(route.Path, "/uploads/file/") {
|
||||
t.Fatalf("remote storage registered local route %s %s", route.Method, route.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedGinRouteContract = `DELETE /api/deleteApisByIds
|
||||
DELETE /dataAccessLog/deleteDataAccessLogByIds
|
||||
DELETE /department/deleteDepartment
|
||||
DELETE /info/deleteInfo
|
||||
DELETE /info/deleteInfoByIds
|
||||
DELETE /mediaUpload/:uploadId
|
||||
DELETE /position/deletePosition
|
||||
DELETE /sysDictionary/deleteSysDictionary
|
||||
DELETE /sysDictionaryDetail/deleteSysDictionaryDetail
|
||||
DELETE /sysError/deleteSysError
|
||||
DELETE /sysError/deleteSysErrorByIds
|
||||
DELETE /sysExportTemplate/deleteSysExportTemplate
|
||||
DELETE /sysExportTemplate/deleteSysExportTemplateByIds
|
||||
DELETE /sysLoginLog/deleteLoginLog
|
||||
DELETE /sysLoginLog/deleteLoginLogByIds
|
||||
DELETE /sysOperationRecord/deleteSysOperationRecord
|
||||
DELETE /sysOperationRecord/deleteSysOperationRecordByIds
|
||||
DELETE /sysParams/deleteSysParams
|
||||
DELETE /sysParams/deleteSysParamsByIds
|
||||
DELETE /sysVersion/deleteSysVersion
|
||||
DELETE /sysVersion/deleteSysVersionByIds
|
||||
DELETE /timedTask/deleteTimedTask
|
||||
DELETE /user/deleteUser
|
||||
GET /api/freshCasbin
|
||||
GET /api/getApiGroups
|
||||
GET /api/getApiRoles
|
||||
GET /api/syncApi
|
||||
GET /attachmentCategory/getCategoryList
|
||||
GET /authority/getDataScopeDepts
|
||||
GET /authority/getUsersByAuthority
|
||||
GET /department/findDepartment
|
||||
GET /department/getDepartmentUsers
|
||||
GET /fileUploadAndDownload/findFile
|
||||
GET /health
|
||||
GET /info/findInfo
|
||||
GET /info/getInfoDataSource
|
||||
GET /info/getInfoList
|
||||
GET /info/getInfoPublic
|
||||
GET /logViewer/content
|
||||
GET /logViewer/dates
|
||||
GET /logViewer/files
|
||||
GET /menu/getMenuRoles
|
||||
GET /payment/configs
|
||||
GET /payment/orders
|
||||
GET /position/findPosition
|
||||
GET /position/getPositionUsers
|
||||
GET /securityConfig/getSecurityConfig
|
||||
GET /swagger/*any
|
||||
GET /sysDictionary/exportSysDictionary
|
||||
GET /sysDictionary/findSysDictionary
|
||||
GET /sysDictionary/getSysDictionaryList
|
||||
GET /sysDictionary/getSysDictionaryListWithDetails
|
||||
GET /sysDictionaryDetail/findSysDictionaryDetail
|
||||
GET /sysDictionaryDetail/getDictionaryDetailsByParent
|
||||
GET /sysDictionaryDetail/getDictionaryPath
|
||||
GET /sysDictionaryDetail/getDictionaryTreeList
|
||||
GET /sysDictionaryDetail/getDictionaryTreeListByType
|
||||
GET /sysDictionaryDetail/getSysDictionaryDetailList
|
||||
GET /sysError/findSysError
|
||||
GET /sysError/getSysErrorList
|
||||
GET /sysExportTemplate/exportExcel
|
||||
GET /sysExportTemplate/exportExcelByToken
|
||||
GET /sysExportTemplate/exportTemplate
|
||||
GET /sysExportTemplate/exportTemplateByToken
|
||||
GET /sysExportTemplate/findSysExportTemplate
|
||||
GET /sysExportTemplate/getSysExportTemplateList
|
||||
GET /sysExportTemplate/previewSQL
|
||||
GET /sysLoginLog/findLoginLog
|
||||
GET /sysLoginLog/getLoginLogList
|
||||
GET /sysOperationRecord/findSysOperationRecord
|
||||
GET /sysOperationRecord/getSysOperationRecordList
|
||||
GET /sysParams/findSysParams
|
||||
GET /sysParams/getSysParam
|
||||
GET /sysParams/getSysParamsList
|
||||
GET /sysVersion/downloadVersionJson
|
||||
GET /sysVersion/findSysVersion
|
||||
GET /sysVersion/getSysVersionList
|
||||
GET /timedTask/alertStream
|
||||
GET /timedTask/getRegisteredMethods
|
||||
GET /timedTask/getTimedTaskList
|
||||
GET /timedTask/getTimedTaskLogList
|
||||
GET /user/getUserInfo
|
||||
POST /api/createApi
|
||||
POST /api/deleteApi
|
||||
POST /api/enterSyncApi
|
||||
POST /api/getAllApis
|
||||
POST /api/getApiById
|
||||
POST /api/getApiList
|
||||
POST /api/ignoreApi
|
||||
POST /api/setApiRoles
|
||||
POST /api/updateApi
|
||||
POST /attachmentCategory/addCategory
|
||||
POST /attachmentCategory/deleteCategory
|
||||
POST /authority/copyAuthority
|
||||
POST /authority/createAuthority
|
||||
POST /authority/deleteAuthority
|
||||
POST /authority/getAuthorityList
|
||||
POST /authority/setDataScope
|
||||
POST /authority/setRoleUsers
|
||||
POST /authorityBtn/canRemoveAuthorityBtn
|
||||
POST /authorityBtn/getAuthorityBtn
|
||||
POST /authorityBtn/setAuthorityBtn
|
||||
POST /base/captcha
|
||||
POST /base/login
|
||||
POST /casbin/getPolicyPathByAuthorityId
|
||||
POST /casbin/updateCasbin
|
||||
POST /dataAccessLog/getDataAccessLogList
|
||||
POST /department/createDepartment
|
||||
POST /department/getDepartmentList
|
||||
POST /department/setDepartmentUsers
|
||||
POST /email/emailTest
|
||||
POST /email/sendEmail
|
||||
POST /fileUploadAndDownload/deleteFile
|
||||
POST /fileUploadAndDownload/deleteFiles
|
||||
POST /fileUploadAndDownload/editFileName
|
||||
POST /fileUploadAndDownload/getFileList
|
||||
POST /fileUploadAndDownload/importURL
|
||||
POST /fileUploadAndDownload/listOssFiles
|
||||
POST /fileUploadAndDownload/upload
|
||||
POST /info/createInfo
|
||||
POST /init/checkdb
|
||||
POST /init/initdb
|
||||
POST /jwt/jsonInBlacklist
|
||||
POST /mediaUpload/chunk
|
||||
POST /mediaUpload/complete
|
||||
POST /mediaUpload/init
|
||||
POST /menu/addBaseMenu
|
||||
POST /menu/addMenuAuthority
|
||||
POST /menu/deleteBaseMenu
|
||||
POST /menu/getBaseMenuById
|
||||
POST /menu/getBaseMenuTree
|
||||
POST /menu/getMenu
|
||||
POST /menu/getMenuAuthority
|
||||
POST /menu/getMenuList
|
||||
POST /menu/setMenuRoles
|
||||
POST /menu/updateBaseMenu
|
||||
POST /payment/callback/:provider
|
||||
POST /payment/config
|
||||
POST /payment/create
|
||||
POST /payment/order
|
||||
POST /payment/query
|
||||
POST /payment/refund
|
||||
POST /position/createPosition
|
||||
POST /position/getPositionList
|
||||
POST /position/setPositionUsers
|
||||
POST /securityConfig/setSecurityConfig
|
||||
POST /sysApiToken/createApiToken
|
||||
POST /sysApiToken/deleteApiToken
|
||||
POST /sysApiToken/getApiTokenList
|
||||
POST /sysDictionary/createSysDictionary
|
||||
POST /sysDictionary/importSysDictionary
|
||||
POST /sysDictionaryDetail/createSysDictionaryDetail
|
||||
POST /sysError/createSysError
|
||||
POST /sysExportTemplate/createSysExportTemplate
|
||||
POST /sysExportTemplate/importExcel
|
||||
POST /sysParams/createSysParams
|
||||
POST /sysVersion/exportVersion
|
||||
POST /sysVersion/importVersion
|
||||
POST /system/getServerInfo
|
||||
POST /system/getSystemConfig
|
||||
POST /system/reloadSystem
|
||||
POST /system/setSystemConfig
|
||||
POST /timedTask/createTimedTask
|
||||
POST /timedTask/toggleTimedTask
|
||||
POST /timedTask/triggerTimedTask
|
||||
POST /user/admin_register
|
||||
POST /user/changePassword
|
||||
POST /user/getUserList
|
||||
POST /user/resetPassword
|
||||
POST /user/setUserAuthorities
|
||||
POST /user/setUserAuthority
|
||||
POST /user/setUserDepartments
|
||||
POST /user/setUserPositions
|
||||
PUT /authority/updateAuthority
|
||||
PUT /department/updateDepartment
|
||||
PUT /info/updateInfo
|
||||
PUT /position/updatePosition
|
||||
PUT /sysDictionary/updateSysDictionary
|
||||
PUT /sysDictionaryDetail/updateSysDictionaryDetail
|
||||
PUT /sysError/updateSysError
|
||||
PUT /sysExportTemplate/updateSysExportTemplate
|
||||
PUT /sysParams/updateSysParams
|
||||
PUT /timedTask/updateTimedTask
|
||||
PUT /user/setSelfInfo
|
||||
PUT /user/setSelfSetting
|
||||
PUT /user/setUserInfo`
|
||||
@@ -0,0 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"kra/internal/modules/system/transport/handler"
|
||||
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// ProviderSet is server providers.
|
||||
var ProviderSet = wire.NewSet(NewGinEngine, NewGinServer, handler.NewAuthority, handler.NewMenu, handler.NewAPI, handler.NewPermission, handler.NewOrganization, handler.NewAnnouncement, handler.NewEmail, handler.NewPayment, handler.NewTask, handler.NewMedia, handler.NewAudit, handler.NewExport, handler.NewVersion, handler.NewDictionary, handler.NewParameter, handler.NewAPIToken, handler.NewSystemConfig, handler.NewPublic, handler.NewUser, handler.NewNavigation, handler.NewSession, handler.NewSet)
|
||||
@@ -0,0 +1,170 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"kra/internal/modules/system/routeinfo"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
"github.com/swaggo/swag"
|
||||
)
|
||||
|
||||
const swaggerInstanceName = "kra-admin"
|
||||
|
||||
var (
|
||||
swaggerPathParameter = regexp.MustCompile(`:([A-Za-z0-9_]+)`)
|
||||
swaggerRegistration sync.Once
|
||||
swaggerDocument runtimeSwaggerDocument
|
||||
)
|
||||
|
||||
type runtimeSwaggerDocument struct {
|
||||
mu sync.RWMutex
|
||||
doc string
|
||||
}
|
||||
|
||||
func (d *runtimeSwaggerDocument) ReadDoc() string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.doc
|
||||
}
|
||||
|
||||
func (d *runtimeSwaggerDocument) replace(doc string) {
|
||||
d.mu.Lock()
|
||||
d.doc = doc
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
func registerSwagger(engine *gin.Engine, prefix, version string, logger *slog.Logger) {
|
||||
swaggerRegistration.Do(func() { swag.Register(swaggerInstanceName, &swaggerDocument) })
|
||||
swaggerDocument.replace(buildSwaggerDocument(engine.Routes(), prefix, version))
|
||||
path := strings.TrimSuffix(prefix, "/") + "/swagger/*any"
|
||||
engine.GET(path, ginSwagger.WrapHandler(
|
||||
swaggerFiles.Handler,
|
||||
ginSwagger.InstanceName(swaggerInstanceName),
|
||||
ginSwagger.URL("doc.json"),
|
||||
ginSwagger.PersistAuthorization(true),
|
||||
))
|
||||
if logger != nil {
|
||||
logger.Info("register swagger handler", "mod", "system", "path", path)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSwaggerDocument(routes []gin.RouteInfo, prefix, version string) string {
|
||||
basePath := strings.TrimSuffix(prefix, "/")
|
||||
if basePath == "" {
|
||||
basePath = "/"
|
||||
}
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
if routes[i].Path == routes[j].Path {
|
||||
return routes[i].Method < routes[j].Method
|
||||
}
|
||||
return routes[i].Path < routes[j].Path
|
||||
})
|
||||
paths := make(map[string]map[string]any, len(routes))
|
||||
for _, route := range routes {
|
||||
if strings.HasSuffix(route.Path, "/*filepath") {
|
||||
continue
|
||||
}
|
||||
method := strings.ToLower(route.Method)
|
||||
switch method {
|
||||
case "get", "post", "put", "delete", "patch":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
apiPath := route.Path
|
||||
if prefix != "" {
|
||||
apiPath = strings.TrimPrefix(apiPath, strings.TrimSuffix(prefix, "/"))
|
||||
}
|
||||
if apiPath == "" {
|
||||
apiPath = "/"
|
||||
}
|
||||
documentPath := swaggerPathParameter.ReplaceAllString(apiPath, `{$1}`)
|
||||
group, description := routeinfo.Metadata(route.Method, apiPath)
|
||||
if description == "" {
|
||||
description = route.Method + " " + apiPath
|
||||
}
|
||||
operation := map[string]any{
|
||||
"tags": []string{group},
|
||||
"summary": description,
|
||||
"operationId": swaggerOperationID(route.Method, apiPath),
|
||||
"produces": []string{"application/json"},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{"description": "OK", "schema": map[string]any{"$ref": "#/definitions/Response"}},
|
||||
},
|
||||
}
|
||||
if parameters := swaggerPathParameters(apiPath); len(parameters) > 0 {
|
||||
operation["parameters"] = parameters
|
||||
}
|
||||
if !swaggerPublicPath(apiPath) {
|
||||
operation["security"] = []map[string][]string{{"ApiKeyAuth": {}}}
|
||||
}
|
||||
if paths[documentPath] == nil {
|
||||
paths[documentPath] = map[string]any{}
|
||||
}
|
||||
paths[documentPath][method] = operation
|
||||
}
|
||||
document := map[string]any{
|
||||
"swagger": "2.0",
|
||||
"info": map[string]any{"title": "Kra Administration API", "version": version},
|
||||
"basePath": basePath,
|
||||
"schemes": []string{"http", "https"},
|
||||
"consumes": []string{"application/json"},
|
||||
"produces": []string{"application/json"},
|
||||
"securityDefinitions": map[string]any{
|
||||
"ApiKeyAuth": map[string]any{"type": "apiKey", "name": "x-token", "in": "header"},
|
||||
},
|
||||
"paths": paths,
|
||||
"definitions": map[string]any{
|
||||
"Response": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"code": map[string]any{"type": "integer"},
|
||||
"data": map[string]any{"type": "object"},
|
||||
"msg": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(document)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func swaggerOperationID(method, path string) string {
|
||||
value := strings.ToLower(method) + "_" + strings.Trim(path, "/")
|
||||
value = swaggerPathParameter.ReplaceAllString(value, "$1")
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '_' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, value)
|
||||
}
|
||||
|
||||
func swaggerPathParameters(path string) []map[string]any {
|
||||
matches := swaggerPathParameter.FindAllStringSubmatch(path, -1)
|
||||
parameters := make([]map[string]any, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
parameters = append(parameters, map[string]any{"name": match[1], "in": "path", "required": true, "type": "string"})
|
||||
}
|
||||
return parameters
|
||||
}
|
||||
|
||||
func swaggerPublicPath(path string) bool {
|
||||
for _, marker := range []string{
|
||||
"/health", "/base/login", "/base/captcha", "/init/checkdb", "/init/initdb",
|
||||
"/api/freshCasbin", "/sysExportTemplate/exportExcelByToken", "/sysExportTemplate/exportTemplateByToken",
|
||||
"/sysError/createSysError", "/info/getInfoDataSource", "/info/getInfoPublic",
|
||||
} {
|
||||
if path == marker {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user