kra-new/internal/server/gin.go

580 lines
19 KiB
Go

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()
}
}