package server import ( "context" "errors" "log/slog" "net" "net/http" "os" "strconv" "strings" "time" "kra/internal/biz" "kra/internal/conf" "kra/internal/service" "kra/pkg/adminauth" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/mojocn/base64Captcha" ) // GinServer adapts Gin to the Kratos transport.Server lifecycle. type GinServer struct { network string address string server *http.Server listener net.Listener logger *slog.Logger } func NewGinServer(c *conf.Server, config *conf.AdminBackend, svc *service.SystemService, access *service.AccessService, settings *service.SettingsService, versions *service.VersionService, exports *service.ExportService, audit *service.AuditService, tasks *service.TaskService, media *service.MediaService, announcements *service.AnnouncementService, emails *service.EmailService, scheduler *TaskScheduler, logger *slog.Logger) *GinServer { gin.SetMode(gin.ReleaseMode) engine := gin.New() engine.Use(ginRequestMeta(), emailErrorAlert(emails, logger), gin.Recovery(), securityRateLimit(svc, settings), operationAudit(audit)) if config != nil && config.Local != nil && config.Local.StorePath != "" { pathPrefix := "/" + strings.Trim(config.Local.PathPrefix, "/") if pathPrefix != "/" { engine.StaticFS(pathPrefix, filesOnly{FileSystem: http.Dir(config.Local.StorePath)}) } } prefix := "" if config != nil { prefix = strings.TrimSuffix(config.RouterPrefix, "/") } public := engine.Group(prefix) public.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, "ok") }) registerPublicRoutes(public, engine, config, svc, settings, access, audit) private := engine.Group(prefix) private.Use(jwtAuth(config, settings), accessControl(access, audit)) registerPrivateRoutes(private, svc) registerAccessRoutes(private, engine, access) registerSettingsRoutes(private, public, settings) registerSystemConfigRoutes(private, config, settings) registerVersionRoutes(private, versions) registerExportRoutes(private, public, svc, exports) registerAuditRoutes(private, public, audit) registerTaskRoutes(private, tasks, scheduler) registerMediaRoutes(private, media) registerAnnouncementRoutes(private, public, announcements) registerEmailRoutes(private, emails) engine.NoRoute(func(c *gin.Context) { fail(c, "请求的接口不存在") }) network := "tcp" address := ":8000" if c != nil && c.Http != nil { if c.Http.Network != "" { network = c.Http.Network } if c.Http.Addr != "" { address = c.Http.Addr } } return &GinServer{ network: network, address: address, server: &http.Server{Addr: address, Handler: engine}, logger: logger, } } func jwtAuth(config *conf.AdminBackend, settings *service.SettingsService) gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("x-token") if token == "" { token, _ = c.Cookie("x-token") } secret := "" if config != nil && config.Jwt != nil { secret = config.Jwt.SigningKey } claims, err := adminauth.Parse(token, secret) if err != nil { noAuth(c, "未登录或非法访问") return } if disabled, checkErr := settings.IsTokenDisabled(c.Request.Context(), token); checkErr != nil || disabled { noAuth(c, "登录状态已失效") return } c.Set("admin_claims", claims) c.Next() } } func currentClaims(c *gin.Context) *adminauth.Claims { claims, _ := c.Get("admin_claims") result, _ := claims.(*adminauth.Claims) return result } func registerPublicRoutes(group *gin.RouterGroup, engine *gin.Engine, config *conf.AdminBackend, svc *service.SystemService, settings *service.SettingsService, access *service.AccessService, audit *service.AuditService) { expiration := 3 * time.Minute keyLong, width, height := 6, 240, 80 if config != nil && config.Captcha != nil { if config.Captcha.StoreExpiration != nil { expiration = config.Captcha.StoreExpiration.AsDuration() } if config.Captcha.KeyLong > 0 { keyLong = int(config.Captcha.KeyLong) } if config.Captcha.ImgWidth > 0 { width = int(config.Captcha.ImgWidth) } if config.Captcha.ImgHeight > 0 { height = int(config.Captcha.ImgHeight) } } store := &captchaStore{service: svc, expiration: expiration} base := group.Group("/base") base.POST("/captcha", func(c *gin.Context) { security, _ := settings.CurrentSecurity(c.Request.Context()) openCaptcha := true if security != nil { keyLong, width, height = security.KeyLong, security.ImgWidth, security.ImgHeight if security.CaptchaOpen > 0 { failures, _ := cachedInt(c.Request.Context(), svc, "login:fail:"+c.ClientIP()) openCaptcha = failures >= security.CaptchaOpen } } if !openCaptcha { writeResult(c, codeSuccess, gin.H{"captchaId": "", "picPath": "", "captchaLength": keyLong, "openCaptcha": false}, "验证码获取成功") return } driver := base64Captcha.NewDriverDigit(height, width, keyLong, 0.7, 80) id, picture, _, err := base64Captcha.NewCaptcha(driver, store).Generate() if err != nil { fail(c, "验证码获取失败") return } writeResult(c, codeSuccess, gin.H{"captchaId": id, "picPath": picture, "captchaLength": keyLong, "openCaptcha": openCaptcha}, "验证码获取成功") }) base.POST("/login", func(c *gin.Context) { var req struct { Username string `json:"username"` Password string `json:"password"` Captcha string `json:"captcha"` CaptchaID string `json:"captchaId"` } if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" { fail(c, "请输入用户名和密码") return } security, _ := settings.CurrentSecurity(c.Request.Context()) if security != nil && security.LockEnable { if _, locked, _ := svc.CacheGet(c.Request.Context(), "login:lock:"+req.Username); locked { fail(c, "账户已锁定,请稍后重试") return } } requireCaptcha := security == nil || security.CaptchaOpen == 0 if security != nil && security.CaptchaOpen > 0 { failures, _ := cachedInt(c.Request.Context(), svc, "login:fail:"+c.ClientIP()) requireCaptcha = failures >= security.CaptchaOpen } if requireCaptcha && (req.CaptchaID == "" || req.Captcha == "" || !store.Verify(req.CaptchaID, req.Captcha, true)) { fail(c, "验证码错误") return } result, err := svc.Login(c.Request.Context(), req.Username, req.Password) if err != nil { _ = audit.RecordLogin(c.Request.Context(), &biz.LoginLog{Username: req.Username, IP: c.ClientIP(), Status: false, ErrorMessage: "用户名不存在或者密码错误", Agent: c.Request.UserAgent()}) ttl := time.Hour if security != nil && security.CaptchaTimeout > 0 { ttl = time.Duration(security.CaptchaTimeout) * time.Second } failures, _ := incrementCached(c.Request.Context(), svc, "login:fail:"+c.ClientIP(), ttl) if security != nil && security.LockEnable && failures >= security.LockThreshold { _ = svc.CacheSet(c.Request.Context(), "login:lock:"+req.Username, "1", time.Duration(security.LockDuration)*time.Minute) } fail(c, "用户名不存在或者密码错误") return } userID, _ := result.User["ID"].(uint) _ = audit.RecordLogin(c.Request.Context(), &biz.LoginLog{Username: req.Username, IP: c.ClientIP(), Status: true, Agent: c.Request.UserAgent(), UserID: userID}) _ = svc.CacheDelete(c.Request.Context(), "login:fail:"+c.ClientIP()) _ = svc.CacheDelete(c.Request.Context(), "login:lock:"+req.Username) maxAge := int(time.Until(time.UnixMilli(result.ExpiresAt)).Seconds()) http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: result.Token, Path: "/", MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteStrictMode}) writeResult(c, codeSuccess, result, "登录成功") }) initGroup := group.Group("/init") initGroup.POST("/checkdb", func(c *gin.Context) { initialized, err := svc.IsInitialized(c.Request.Context()) if err != nil { fail(c, "数据库状态检查失败") return } message := "数据库无需初始化" if !initialized { message = "前往初始化数据库" } writeResult(c, codeSuccess, gin.H{"needInit": !initialized}, message) }) initGroup.POST("/initdb", func(c *gin.Context) { if err := svc.Initialize(c.Request.Context()); err != nil { fail(c, "自动创建数据库失败") return } routes := engine.Routes() apis := make([]*biz.API, 0, len(routes)) for _, route := range routes { apis = append(apis, &biz.API{Path: route.Path, Method: route.Method, APIGroup: routeGroup(route.Path)}) } if err := access.ApplyAPISync(c.Request.Context(), apis, nil); err != nil { fail(c, "数据库已初始化,但 API 权限数据写入失败") return } writeResult(c, codeSuccess, gin.H{}, "自动创建数据库成功") }) } func routeGroup(path string) string { parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) > 0 && parts[0] != "" { return parts[0] } return "base" } func cachedInt(ctx context.Context, svc *service.SystemService, key string) (int, error) { value, ok, err := svc.CacheGet(ctx, key) if err != nil || !ok { return 0, err } result, err := strconv.Atoi(value) return result, err } func incrementCached(ctx context.Context, svc *service.SystemService, key string, ttl time.Duration) (int, error) { value, _ := cachedInt(ctx, svc, key) value++ return value, svc.CacheSet(ctx, key, strconv.Itoa(value), ttl) } func securityRateLimit(svc *service.SystemService, settings *service.SettingsService) gin.HandlerFunc { return func(c *gin.Context) { cfg, err := settings.CurrentSecurity(c.Request.Context()) if err != nil || cfg == nil || !cfg.LimitEnable { c.Next() return } window := cfg.LimitWindow if window < 1 { window = 60 } bucket := time.Now().Unix() / int64(window) key := "rate:" + c.ClientIP() + ":" + strconv.FormatInt(bucket, 10) count, cacheErr := incrementCached(c.Request.Context(), svc, key, time.Duration(window+1)*time.Second) if cacheErr == nil && count > cfg.LimitCount { c.AbortWithStatusJSON(http.StatusTooManyRequests, response{Code: codeError, Data: nil, Msg: "请求过于频繁"}) return } c.Next() } } type captchaStore struct { service *service.SystemService expiration time.Duration } func (s *captchaStore) key(id string) string { return "captcha:" + id } func (s *captchaStore) Set(id, value string) error { return s.service.CacheSet(context.Background(), s.key(id), value, s.expiration) } func (s *captchaStore) Get(id string, clear bool) string { value, ok, err := s.service.CacheGet(context.Background(), s.key(id)) if err != nil || !ok { return "" } if clear { _ = s.service.CacheDelete(context.Background(), s.key(id)) } return value } func (s *captchaStore) Verify(id, answer string, clear bool) bool { if id == "" || answer == "" { return false } return strings.EqualFold(s.Get(id, clear), answer) } 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 } func registerPrivateRoutes(group *gin.RouterGroup, svc *service.SystemService) { user := group.Group("/user") user.POST("/getUserList", func(c *gin.Context) { var req struct { Page int `json:"page"` PageSize int `json:"pageSize"` Username string `json:"username"` NickName string `json:"nickName"` Phone string `json:"phone"` Email string `json:"email"` OrderKey string `json:"orderKey"` Desc bool `json:"desc"` } if c.ShouldBindJSON(&req) != nil { fail(c, "参数错误") return } list, total, err := svc.ListUsers(c.Request.Context(), req.Page, req.PageSize, &biz.UserListFilter{Username: req.Username, NickName: req.NickName, Phone: req.Phone, Email: req.Email, OrderKey: req.OrderKey, Desc: req.Desc}) if err != nil { fail(c, "获取失败") return } writeResult(c, codeSuccess, pageResult{List: list, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") }) user.POST("/admin_register", func(c *gin.Context) { var req struct { Username string `json:"userName"` Password string `json:"passWord"` NickName string `json:"nickName"` HeaderImg string `json:"headerImg"` AuthorityID uint `json:"authorityId"` AuthorityIDs []uint `json:"authorityIds"` Enable int `json:"enable"` Phone string `json:"phone"` Email string `json:"email"` } if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" { fail(c, "参数错误") return } result, err := svc.CreateUser(c.Request.Context(), service.UserInput{Username: req.Username, Password: req.Password, NickName: req.NickName, HeaderImg: req.HeaderImg, AuthorityID: req.AuthorityID, AuthorityIDs: req.AuthorityIDs, Enable: req.Enable, Phone: req.Phone, Email: req.Email}) if err != nil { fail(c, "注册失败") return } writeResult(c, codeSuccess, gin.H{"user": result}, "注册成功") }) user.PUT("/setUserInfo", func(c *gin.Context) { var req struct { ID uint `json:"ID"` NickName string `json:"nickName"` HeaderImg string `json:"headerImg"` AuthorityID uint `json:"authorityId"` AuthorityIDs []uint `json:"authorityIds"` Enable int `json:"enable"` Phone string `json:"phone"` Email string `json:"email"` } if c.ShouldBindJSON(&req) != nil { fail(c, "参数错误") return } if err := svc.UpdateUser(c.Request.Context(), service.UserInput{ID: req.ID, NickName: req.NickName, HeaderImg: req.HeaderImg, AuthorityID: req.AuthorityID, AuthorityIDs: req.AuthorityIDs, Enable: req.Enable, Phone: req.Phone, Email: req.Email}); err != nil { fail(c, "修改失败") return } ok(c) }) user.PUT("/setSelfInfo", func(c *gin.Context) { claims := currentClaims(c) if claims == nil { noAuth(c, "未登录或非法访问") return } var req struct { NickName string `json:"nickName"` HeaderImg string `json:"headerImg"` Phone string `json:"phone"` Email string `json:"email"` } if c.ShouldBindJSON(&req) != nil { fail(c, "参数错误") return } if err := svc.UpdateUser(c.Request.Context(), service.UserInput{ID: claims.ID, NickName: req.NickName, HeaderImg: req.HeaderImg, Phone: req.Phone, Email: req.Email, Enable: 1}); err != nil { fail(c, "修改失败") return } ok(c) }) user.DELETE("/deleteUser", func(c *gin.Context) { var req struct { ID uint `form:"id" json:"id"` } _ = c.ShouldBind(&req) if req.ID == 0 { var body struct { ID uint `json:"ID"` } _ = c.ShouldBindJSON(&body) req.ID = body.ID } if err := svc.DeleteUser(c.Request.Context(), req.ID); err != nil { fail(c, "删除失败") return } ok(c) }) user.POST("/resetPassword", func(c *gin.Context) { var req struct { ID uint `json:"ID"` Password string `json:"password"` } if c.ShouldBindJSON(&req) != nil { fail(c, "参数错误") return } if err := svc.ResetPassword(c.Request.Context(), req.ID, req.Password); err != nil { fail(c, "重置失败") return } ok(c) }) user.POST("/changePassword", func(c *gin.Context) { claims := currentClaims(c) var req struct { Password string `json:"password"` NewPassword string `json:"newPassword"` } if claims == nil || c.ShouldBindJSON(&req) != nil { fail(c, "参数错误") return } if err := svc.ChangePassword(c.Request.Context(), claims.ID, req.Password, req.NewPassword); err != nil { fail(c, "修改失败,原密码与当前账户不符") return } ok(c) }) user.PUT("/setSelfSetting", func(c *gin.Context) { claims := currentClaims(c) var setting map[string]any if claims == nil || c.ShouldBindJSON(&setting) != nil { fail(c, "参数错误") return } if err := svc.SetUserSetting(c.Request.Context(), claims.ID, setting); err != nil { fail(c, "设置失败") return } ok(c) }) user.POST("/setUserAuthorities", func(c *gin.Context) { var req struct { ID uint `json:"ID"` AuthorityIDs []uint `json:"authorityIds"` } if c.ShouldBindJSON(&req) != nil || req.ID == 0 || len(req.AuthorityIDs) == 0 { fail(c, "参数错误") return } if err := svc.SetUserAuthorities(c.Request.Context(), req.ID, req.AuthorityIDs); err != nil { fail(c, "修改失败") return } ok(c) }) user.POST("/setUserAuthority", func(c *gin.Context) { claims := currentClaims(c) var req struct { AuthorityID uint `json:"authorityId"` } if claims == nil || c.ShouldBindJSON(&req) != nil || req.AuthorityID == 0 { fail(c, "参数错误") return } login, err := svc.SwitchAuthority(c.Request.Context(), claims.ID, req.AuthorityID) if err != nil { fail(c, err.Error()) return } c.Header("new-token", login.Token) c.Header("new-expires-at", strconv.FormatInt(login.ExpiresAt/1000, 10)) maxAge := int(time.Until(time.UnixMilli(login.ExpiresAt)).Seconds()) http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: login.Token, Path: "/", MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteStrictMode}) ok(c) }) user.GET("/getUserInfo", func(c *gin.Context) { claims := currentClaims(c) if claims == nil { noAuth(c, "未登录或非法访问") return } result, err := svc.User(c.Request.Context(), claims.ID) if err != nil { fail(c, "获取失败") return } writeResult(c, codeSuccess, gin.H{"userInfo": result}, "获取成功") }) menu := group.Group("/menu") menu.POST("/getMenu", func(c *gin.Context) { claims := currentClaims(c) if claims == nil { noAuth(c, "未登录或非法访问") return } menus, err := svc.Menus(c.Request.Context(), claims.AuthorityID) if err != nil { fail(c, "获取失败") return } writeResult(c, codeSuccess, gin.H{"menus": menus}, "获取成功") }) jwt := group.Group("/jwt") jwt.POST("/jsonInBlacklist", func(c *gin.Context) { http.SetCookie(c.Writer, &http.Cookie{Name: "x-token", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) ok(c) }) } func (s *GinServer) Start(ctx context.Context) error { listener, err := net.Listen(s.network, s.address) if err != nil { return err } s.listener = listener s.server.BaseContext = func(net.Listener) context.Context { return ctx } s.logger.InfoContext(ctx, "Gin HTTP server listening", "addr", listener.Addr().String()) if err := s.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } return nil } func (s *GinServer) Stop(ctx context.Context) error { s.logger.InfoContext(ctx, "Gin HTTP server stopping") return s.server.Shutdown(ctx) } func ginRequestMeta() gin.HandlerFunc { return func(c *gin.Context) { requestID := c.GetHeader("X-Request-Id") if requestID == "" { requestID = uuid.NewString() } c.Header("X-Request-Id", requestID) c.Set("request_id", requestID) c.Next() } }