diff --git a/cmd/kratos-admin/main.go b/cmd/kratos-admin/main.go index b7114c6..179a149 100644 --- a/cmd/kratos-admin/main.go +++ b/cmd/kratos-admin/main.go @@ -102,7 +102,7 @@ func main() { }) defer unsubscribeLogger() - app, cleanup, err := wireApp(bc.Server, runtime, logger) + app, cleanup, err := wireApp(bc.Server, runtime, logger, Version) if err != nil { panic(err) } diff --git a/cmd/kratos-admin/wire.go b/cmd/kratos-admin/wire.go index b37a194..6923583 100644 --- a/cmd/kratos-admin/wire.go +++ b/cmd/kratos-admin/wire.go @@ -20,6 +20,6 @@ import ( ) // wireApp init kratos application. -func wireApp(*conf.Server, *conf.Runtime, *slog.Logger) (*kratos.App, func(), error) { +func wireApp(*conf.Server, *conf.Runtime, *slog.Logger, string) (*kratos.App, func(), error) { panic(wire.Build(server.ProviderSet, worker.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp)) } diff --git a/cmd/kratos-admin/wire_gen.go b/cmd/kratos-admin/wire_gen.go index a8ed6fd..ed88aac 100644 --- a/cmd/kratos-admin/wire_gen.go +++ b/cmd/kratos-admin/wire_gen.go @@ -25,7 +25,7 @@ import ( // Injectors from wire.go: // wireApp init kratos application. -func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger) (*kratos.App, func(), error) { +func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger, string2 string) (*kratos.App, func(), error) { dataData, cleanup, err := data.NewData(runtime) if err != nil { return nil, nil, err @@ -90,7 +90,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger logFileRepo := data.NewLogFileRepo(dataData) logViewerUsecase := biz.NewLogViewerUsecase(logFileRepo) logViewerService := service.NewLogViewerService(logViewerUsecase) - audit := handler.NewAudit(auditService, auditRecorder, logViewerService) + audit := handler.NewAudit(auditService, auditRecorder, logViewerService, logger) exportRepo := data.NewExportRepo(dataData) exportUsecase := biz.NewExportUsecase(exportRepo) cache := data.NewCache(dataData) @@ -130,7 +130,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger navigation := handler.NewNavigation(userService) session := handler.NewSession(tokenService) set := handler.NewSet(authority, menu, api, permission, organization, announcement, email, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session) - engine := server.NewGinEngine(runtime, accessControlService, set, authService, securityService, auditRecorder, logger) + engine := server.NewGinEngine(runtime, accessControlService, set, authService, securityService, auditRecorder, logger, string2) httpServer := server.NewGinServer(confServer, engine) app := newApp(logger, httpServer, taskScheduler) return app, func() { diff --git a/configs/config.yaml b/configs/config.yaml index 04c266a..95e0cee 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -126,8 +126,8 @@ admin: retention_day: 7 access_req_body: true access_resp_data: true - access_req_headers: false - access_log_max_bytes: 32768 + access_req_headers: true + access_log_max_bytes: 1024 file_only_modules: [] cors: mode: whitelist diff --git a/internal/biz/authentication.go b/internal/biz/authentication.go index b296990..7dd5f4a 100644 --- a/internal/biz/authentication.go +++ b/internal/biz/authentication.go @@ -111,6 +111,10 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp if err != nil { return nil, fmt.Errorf("%w: %v", ErrTokenIssue, err) } + // Record a successful credential login immediately after issuing the JWT. + // Multipoint-session persistence happens afterwards, so a Redis/cache + // failure must not erase the successful-login audit event. + uc.recordLogin(ctx, attempt, true, "登录成功", user.ID) if uc.security.UseMultipoint() { oldToken, cacheErr := uc.security.ActiveToken(ctx, user.Username) if cacheErr != nil { @@ -123,7 +127,6 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp return nil, fmt.Errorf("%w: %v", ErrLoginState, cacheErr) } } - uc.recordLogin(ctx, attempt, true, "登录成功", user.ID) return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: user.MustChangePassword}, nil } diff --git a/internal/biz/authentication_test.go b/internal/biz/authentication_test.go new file mode 100644 index 0000000..813fb6b --- /dev/null +++ b/internal/biz/authentication_test.go @@ -0,0 +1,79 @@ +package biz + +import ( + "context" + "errors" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type authenticationUserRepo struct { + UserRepo + user *User +} + +func (r *authenticationUserRepo) FindUserByUsername(context.Context, string) (*User, error) { + return r.user, nil +} + +type authenticationSecurityRepo struct{ SecurityRepo } + +func (*authenticationSecurityRepo) SecurityConfig(context.Context) (*SecurityConfig, error) { + return &SecurityConfig{CaptchaOpen: 2, CaptchaTimeout: 60}, nil +} + +type authenticationCache struct{ activeErr error } + +func (c *authenticationCache) Get(_ context.Context, key string) (string, bool, error) { + if key == "admin" { + return "", false, c.activeErr + } + return "", false, nil +} +func (*authenticationCache) Set(context.Context, string, string, time.Duration) error { return nil } +func (*authenticationCache) Delete(context.Context, string) error { return nil } +func (*authenticationCache) Increment(context.Context, string, time.Duration) (int64, error) { + return 1, nil +} + +type authenticationSettings struct{ RuntimeSettings } + +func (*authenticationSettings) UseMultipoint() bool { return true } + +type authenticationIssuer struct{ TokenIssuer } + +func (*authenticationIssuer) IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error) { + return &IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil +} + +type authenticationAudit struct { + AuditRecordRepo + logins []*LoginLog +} + +func (a *authenticationAudit) RecordLogin(_ context.Context, value *LoginLog) error { + a.logins = append(a.logins, value) + return nil +} + +func TestLoginRecordsSuccessBeforeMultipointCacheFailure(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + users := NewUserUsecase(&authenticationUserRepo{user: &User{ID: 1, Username: "admin", Password: string(hash), AuthorityID: 888, Enable: 1}}) + cacheErr := errors.New("cache unavailable") + security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{activeErr: cacheErr}, &authenticationSettings{}, nil) + audit := &authenticationAudit{} + uc := NewAuthenticationUsecase(users, security, &authenticationIssuer{}, audit) + + _, err = uc.Login(context.Background(), &LoginAttempt{Username: "admin", Password: "secret", IP: "127.0.0.1", Agent: "test"}) + if !errors.Is(err, ErrLoginState) { + t.Fatalf("expected login-state failure, got %v", err) + } + if len(audit.logins) != 1 || !audit.logins[0].Status || audit.logins[0].ErrorMessage != "登录成功" || audit.logins[0].UserID != 1 { + t.Fatalf("expected successful login audit before cache failure, got %+v", audit.logins) + } +} diff --git a/internal/biz/department.go b/internal/biz/department.go index 704ca65..7f94cea 100644 --- a/internal/biz/department.go +++ b/internal/biz/department.go @@ -15,7 +15,7 @@ type Department struct { Sort int LeaderID uint Leader *User - Status bool + Status *bool Children []*Department NamePath string } diff --git a/internal/biz/media.go b/internal/biz/media.go index 0f443e9..f384996 100644 --- a/internal/biz/media.go +++ b/internal/biz/media.go @@ -76,9 +76,14 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedM } media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(name), "."), Key: key, Size: stored.Size, Mime: suppliedMIME, MD5: hex.EncodeToString(hash.Sum(nil)), UserID: userID} if save { - if err = uc.CreateMedia(ctx, media); err != nil { - _ = uc.files.Delete(ctx, key) - return nil, err + count, countErr := uc.MediaKeyReferences(ctx, key) + if countErr != nil { + return nil, countErr + } + if count == 0 { + if err = uc.CreateMedia(ctx, media); err != nil { + return nil, err + } } } return media, nil diff --git a/internal/biz/media_upload.go b/internal/biz/media_upload.go index 7b02348..4e2087e 100644 --- a/internal/biz/media_upload.go +++ b/internal/biz/media_upload.go @@ -17,6 +17,8 @@ import ( "github.com/google/uuid" ) +var ErrUploadSessionNotFound = errors.New("upload session not found") + func (uc *MediaUsecase) chunkPrefix(uploadID uint) string { directory := "uploads/chunks" if uc.settings != nil { @@ -44,11 +46,13 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash } } session, err := uc.FindUploadingSession(ctx, userID, hash) - if err != nil { + if errors.Is(err, ErrUploadSessionNotFound) { session = &UploadSession{UserID: userID, FileName: name, FileHash: hash, FileSize: size, ChunkSize: chunkSize, ChunkTotal: total, Status: "uploading"} if err = uc.CreateUploadSession(ctx, session); err != nil { return nil, nil, nil, err } + } else if err != nil { + return nil, nil, nil, err } chunks, err := uc.ListChunks(ctx, session.ID) if err != nil { @@ -146,7 +150,6 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin // byte count. media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(session.FileName), "."), Key: key, Size: session.FileSize, Mime: mime, MD5: hash, UserID: userID} if err = uc.CreateMedia(ctx, media); err != nil { - _ = uc.files.Delete(ctx, key) return fail(err) } _ = uc.CompleteUploadSession(ctx, uploadID, key, media.ID) diff --git a/internal/biz/position.go b/internal/biz/position.go index 937d7de..304e8cc 100644 --- a/internal/biz/position.go +++ b/internal/biz/position.go @@ -12,7 +12,7 @@ type Position struct { Name string Code string Sort int - Status bool + Status *bool Remark string } diff --git a/internal/data/department.go b/internal/data/department.go index d8a8490..54c1bf5 100644 --- a/internal/data/department.go +++ b/internal/data/department.go @@ -26,7 +26,7 @@ type departmentPO struct { Ancestors string Sort int LeaderID uint - Status bool `gorm:"default:true"` + Status *bool `gorm:"default:true"` } func (departmentPO) TableName() string { return "sys_departments" } diff --git a/internal/data/error_record.go b/internal/data/error_record.go index 4c1316a..02f8d27 100644 --- a/internal/data/error_record.go +++ b/internal/data/error_record.go @@ -35,7 +35,23 @@ func (r *auditRecorderRepo) CreateError(ctx context.Context, v *biz.ErrorRecord) return r.data.gormDB.WithContext(ctx).Create(&errorRecordPO{Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}).Error } func (r *auditQueryRepo) UpdateError(ctx context.Context, v *biz.ErrorRecord) error { - return r.data.gormDB.WithContext(ctx).Model(&errorRecordPO{}).Where("id = ?", v.ID).Updates(map[string]any{"form": v.Form, "info": v.Info, "level": v.Level, "solution": v.Solution, "status": v.Status}).Error + updates := make(map[string]any, 5) + if v.Form != "" { + updates["form"] = v.Form + } + if v.Info != "" { + updates["info"] = v.Info + } + if v.Level != "" { + updates["level"] = v.Level + } + if v.Solution != "" { + updates["solution"] = v.Solution + } + if v.Status != "" { + updates["status"] = v.Status + } + return r.data.gormDB.WithContext(ctx).Model(&errorRecordPO{}).Where("id = ?", v.ID).Updates(updates).Error } func (r *auditQueryRepo) DeleteErrors(ctx context.Context, ids []uint) error { return r.data.gormDB.WithContext(ctx).Delete(&errorRecordPO{}, ids).Error diff --git a/internal/data/media_upload.go b/internal/data/media_upload.go index 6f84c7c..82ec1b1 100644 --- a/internal/data/media_upload.go +++ b/internal/data/media_upload.go @@ -2,6 +2,7 @@ package data import ( "context" + "errors" "time" "kra/internal/biz" @@ -47,6 +48,9 @@ func uploadFromPO(v uploadSessionPO) *biz.UploadSession { func (r *mediaRepo) FindCompletedSession(ctx context.Context, userID uint, hash string) (*biz.UploadSession, error) { var po uploadSessionPO if err := r.data.gormDB.WithContext(ctx).Where("user_id = ? AND file_hash = ? AND status = ?", userID, hash, "completed").First(&po).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, biz.ErrUploadSessionNotFound + } return nil, err } return uploadFromPO(po), nil @@ -54,6 +58,9 @@ func (r *mediaRepo) FindCompletedSession(ctx context.Context, userID uint, hash func (r *mediaRepo) FindUploadingSession(ctx context.Context, userID uint, hash string) (*biz.UploadSession, error) { var po uploadSessionPO if err := r.data.gormDB.WithContext(ctx).Where("user_id = ? AND file_hash = ? AND status = ?", userID, hash, "uploading").First(&po).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, biz.ErrUploadSessionNotFound + } return nil, err } return uploadFromPO(po), nil diff --git a/internal/data/migrations.go b/internal/data/migrations.go index b470ba5..5625ad6 100644 --- a/internal/data/migrations.go +++ b/internal/data/migrations.go @@ -35,9 +35,8 @@ func migrateAll(db *gorm.DB) error { return reconcileReferenceIndexes(db) } -// Older Kra builds used a status label that is not part of the GVA error-log -// contract and is therefore rendered as an unknown state by the compatible -// administration page. +// Older builds used a status label outside the administration page's supported +// state set, so normalize existing rows during migration. func normalizeErrorRecordStatuses(db *gorm.DB) error { return db.Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error } diff --git a/internal/data/position.go b/internal/data/position.go index 2a23304..27a4922 100644 --- a/internal/data/position.go +++ b/internal/data/position.go @@ -22,7 +22,7 @@ type positionPO struct { Name string `gorm:"index"` Code string Sort int - Status bool `gorm:"default:true"` + Status *bool `gorm:"default:true"` Remark string } diff --git a/internal/data/system_init.go b/internal/data/system_init.go index c65feb2..5dcf90d 100644 --- a/internal/data/system_init.go +++ b/internal/data/system_init.go @@ -174,11 +174,12 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database return err } } - department := departmentPO{Name: "总公司", ParentID: 0, Ancestors: "0", Sort: 0, Status: true} + enabled := true + department := departmentPO{Name: "总公司", ParentID: 0, Ancestors: "0", Sort: 0, Status: &enabled} if err := tx.Where("name = ?", department.Name).FirstOrCreate(&department).Error; err != nil { return err } - for _, position := range []positionPO{{Name: "总经理", Code: "CEO", Sort: 1, Status: true}, {Name: "普通员工", Code: "STAFF", Sort: 2, Status: true}} { + for _, position := range []positionPO{{Name: "总经理", Code: "CEO", Sort: 1, Status: &enabled}, {Name: "普通员工", Code: "STAFF", Sort: 2, Status: &enabled}} { if err := tx.Where("code = ?", position.Code).FirstOrCreate(&position).Error; err != nil { return err } diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index 037e607..72271fd 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -63,6 +63,25 @@ func TestUserAuthorityWritesAreAtomic(t *testing.T) { } } +func TestSetUserAuthoritiesRejectsMissingUser(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + if err := data.gormDB.WithContext(ctx).Create(&authorityPO{AuthorityID: 888, AuthorityName: "admin"}).Error; err != nil { + t.Fatal(err) + } + repo := &userRepo{data: data} + if err := repo.SetUserAuthorities(ctx, 999999, []uint{888}); err == nil || err.Error() != "查询用户数据失败" { + t.Fatalf("expected the compatible missing-user error, got %v", err) + } + var links int64 + if err := data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_user_id = ?", 999999).Count(&links).Error; err != nil { + t.Fatal(err) + } + if links != 0 { + t.Fatalf("unexpected authority links for missing user: %d", links) + } +} + func TestDictionaryImportKeepsHierarchyInOneTransaction(t *testing.T) { data := newTransactionTestData(t) ctx := context.Background() diff --git a/internal/data/user.go b/internal/data/user.go index 557f26f..e417954 100644 --- a/internal/data/user.go +++ b/internal/data/user.go @@ -486,6 +486,10 @@ func (r *userRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error func (r *userRepo) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user userPO + if err := tx.Where("id = ?", id).First(&user).Error; err != nil { + return errors.New("查询用户数据失败") + } access := &authorityAccessRepo{data: r.data} for _, authorityID := range authorityIDs { if err := access.checkAuthorityIDAuth(ctx, authorityID); err != nil { diff --git a/internal/server/gin.go b/internal/server/gin.go index 527a35c..40a0c22 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path" + "sort" "strings" "time" @@ -20,10 +21,10 @@ import ( 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) *gin.Engine { +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() - engine.Use(servermiddleware.RequestMeta(), servermiddleware.Recovery(audit, logger), servermiddleware.AccessLog(runtime, logger), servermiddleware.ErrorAudit(audit), servermiddleware.SecurityRateLimit(security)) + engine.Use(servermiddleware.RequestMeta(), servermiddleware.Recovery(audit, logger), servermiddleware.AccessLog(runtime, logger, version), servermiddleware.CORS(runtime), servermiddleware.ErrorAudit(audit), servermiddleware.SecurityRateLimit(security)) prefix := "" config := runtime.Admin() @@ -66,9 +67,28 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h } 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 { diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go new file mode 100644 index 0000000..b8d0d40 --- /dev/null +++ b/internal/server/gin_test.go @@ -0,0 +1,33 @@ +package server + +import ( + "testing" + + "kra/internal/conf" + "kra/internal/server/handler" +) + +func TestGinRouteContract(t *testing.T) { + handlers := &handler.Set{ + Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{}, + Permission: &handler.Permission{}, Organization: &handler.Organization{}, + Announcement: &handler.Announcement{}, Email: &handler.Email{}, 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{}, + } + engine := NewGinEngine(conf.NewRuntime(nil, &conf.AdminBackend{}), nil, handlers, nil, nil, nil, nil, "test") + routes := engine.Routes() + if len(routes) != 177 { + t.Fatalf("route contract changed: got %d routes, want 177", 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{}{} + } +} diff --git a/internal/server/handler/audit.go b/internal/server/handler/audit.go index ea94dcf..3931749 100644 --- a/internal/server/handler/audit.go +++ b/internal/server/handler/audit.go @@ -3,6 +3,7 @@ package handler import ( "errors" "io" + "log/slog" "strconv" "time" @@ -18,10 +19,11 @@ type Audit struct { service *service.AuditService recorder *service.AuditRecorder logs *service.LogViewerService + logger *slog.Logger } -func NewAudit(service *service.AuditService, recorder *service.AuditRecorder, logs *service.LogViewerService) *Audit { - return &Audit{service: service, recorder: recorder, logs: logs} +func NewAudit(service *service.AuditService, recorder *service.AuditRecorder, logs *service.LogViewerService, logger *slog.Logger) *Audit { + return &Audit{service: service, recorder: recorder, logs: logs, logger: logger} } func page(c *gin.Context) (int, int) { @@ -53,8 +55,8 @@ func IDsFromQuery(c *gin.Context) []uint { return ids } -// auditID accepts both forms used by the GVA web client over time: DELETE -// requests may carry the identifier in the query string or in a JSON body. +// auditID accepts both DELETE encodings supported by the administration API: +// the identifier may be carried in the query string or in a JSON body. func auditID(c *gin.Context) (uint, error) { if raw := c.Query("ID"); raw != "" { value, err := strconv.ParseUint(raw, 10, 64) @@ -68,7 +70,7 @@ func auditID(c *gin.Context) (uint, error) { } // auditIDs mirrors auditID for bulk deletes and supports both query-array and -// JSON-body encodings used by compatible GVA pages. +// JSON-body encodings used by compatible administration pages. func auditIDs(c *gin.Context) ([]uint, error) { if ids := IDsFromQuery(c); len(ids) > 0 { return ids, nil @@ -230,7 +232,7 @@ func (h *Audit) LogDates(c *gin.Context) { } data, err := h.logs.LogDates(c.Request.Context(), month) if err != nil { - failLogViewer(c, err) + failLogViewer(c, err, h.logger) return } httpx.Write(c, httpx.CodeSuccess, data, "获取成功") @@ -243,7 +245,7 @@ func (h *Audit) LogFiles(c *gin.Context) { } data, err := h.logs.LogFiles(c.Request.Context(), date) if err != nil { - failLogViewer(c, err) + failLogViewer(c, err, h.logger) return } httpx.Write(c, httpx.CodeSuccess, data, "获取成功") @@ -267,13 +269,16 @@ func (h *Audit) LogContent(c *gin.Context) { // "日志文件路径不合法" error rather than rejecting them as binding errors. data, err := h.logs.LogContent(c.Request.Context(), date, path, cursor) if err != nil { - failLogViewer(c, err) + failLogViewer(c, err, h.logger) return } httpx.Write(c, httpx.CodeSuccess, data, "获取成功") } -func failLogViewer(c *gin.Context, err error) { +func failLogViewer(c *gin.Context, err error, logger *slog.Logger) { + if logger != nil { + logger.ErrorContext(c.Request.Context(), "日志查看失败", "mod", "log-viewer", "error", err) + } message := "读取日志失败" switch { case errors.Is(err, biz.ErrInvalidLogMonth): diff --git a/internal/server/handler/dictionary.go b/internal/server/handler/dictionary.go index 9fc45c6..42fcfd9 100644 --- a/internal/server/handler/dictionary.go +++ b/internal/server/handler/dictionary.go @@ -149,6 +149,10 @@ func (h *Dictionary) FindDetail(c *gin.Context) { httpx.Fail(c, err.Error()) return } + if req.ID == 0 { + httpx.Fail(c, "ID值不能为空") + return + } item, err := h.service.DictionaryDetail(c.Request.Context(), req.ID) if err != nil { httpx.Fail(c, "查询失败") diff --git a/internal/server/middleware/access_log.go b/internal/server/middleware/access_log.go index f00c550..fccd75b 100644 --- a/internal/server/middleware/access_log.go +++ b/internal/server/middleware/access_log.go @@ -4,6 +4,7 @@ import ( "bytes" "io" "log/slog" + "net/http" "strings" "time" @@ -14,38 +15,42 @@ import ( // AccessLog is the single global request/response capture point, matching // the reference middleware ordering and making every HTTP request observable. -func AccessLog(runtime *conf.Runtime, logger *slog.Logger) gin.HandlerFunc { +func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.HandlerFunc { return func(c *gin.Context) { + started := time.Now() var requestBody []byte multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") + bytesIn := c.Request.ContentLength if c.Request.Body != nil && !multipart { requestBody, _ = io.ReadAll(c.Request.Body) c.Request.Body = io.NopCloser(bytes.NewReader(requestBody)) + bytesIn = int64(len(requestBody)) + } + if bytesIn < 0 { + bytesIn = 0 } maxBytes := 1 << 20 writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes} c.Writer = writer - started := time.Now() + c.Header("X-Kra-Version", version) + config := runtime.Admin() + logLimit := 1024 + if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 { + logLimit = int(config.Zap.AccessLogMaxBytes) + } + requestText := "" + if multipart { + requestText = "[文件]" + } else { + requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit) + } + c.Set(ctxReqBodyKey, requestText) + c.Set(ctxRespBufferKey, &writer.body) c.Next() if logger == nil { return } - requestText, responseText := "", "" - config := runtime.Admin() - logLimit := 32768 - if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 { - logLimit = int(config.Zap.AccessLogMaxBytes) - } - if config == nil || config.Zap == nil || config.Zap.AccessReqBody { - if multipart { - requestText = "[文件]" - } else { - requestText = redactJSONLimit(requestBody, logLimit) - } - } - if config == nil || config.Zap == nil || config.Zap.AccessRespData { - responseText = redactJSONLimit(writer.body.Bytes(), logLimit) - } + responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit) userID, authorityID := uint(0), uint(0) if claims := Claims(c); claims != nil { userID, authorityID = claims.ID, claims.AuthorityID @@ -54,27 +59,41 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger) gin.HandlerFunc { if route == "" { route = "unmatched" } - attributes := []any{ - "ip", c.ClientIP(), "method", c.Request.Method, "path", c.Request.URL.Path, "http_route", route, - "status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(), - "request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"), - "bytes_in", len(requestBody), "bytes_out", c.Writer.Size(), "user_id", userID, "authority_id", authorityID, - "ua", c.Request.UserAgent(), "query", c.Request.URL.RawQuery, "request", requestText, "response", responseText} - if config != nil && config.Zap != nil && config.Zap.AccessReqHeaders { - attributes = append(attributes, "headers", redactHeaders(c.Request.Header)) + bytesOut := int64(c.Writer.Size()) + if bytesOut < 0 { + bytesOut = 0 } - logger.InfoContext(c.Request.Context(), "http access", attributes...) + privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n") + attributes := []any{ + "mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route, + "http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(), + "request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"), + "bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID, + "error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(), "req_query", c.Request.URL.RawQuery} + if config != nil && config.Zap != nil && config.Zap.AccessReqHeaders { + attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header)) + } + if config != nil && config.Zap != nil && config.Zap.AccessReqBody { + attributes = append(attributes, "req_body", requestText) + } + if config != nil && config.Zap != nil && config.Zap.AccessRespData { + attributes = append(attributes, "resp_data", responseText) + } + if privateErrors != "" { + attributes = append(attributes, "error_msg", privateErrors) + } + logger.InfoContext(c.Request.Context(), "请求完成", attributes...) } } -func redactHeaders(headers map[string][]string) map[string][]string { - out := make(map[string][]string, len(headers)) +func redactHeaders(headers map[string][]string) map[string]string { + out := make(map[string]string, len(headers)) for key, values := range headers { lower := strings.ToLower(key) - if strings.Contains(lower, "token") || lower == "authorization" || lower == "cookie" { - out[key] = []string{"******"} + if lower == "authorization" || lower == "cookie" || lower == "set-cookie" || lower == "x-token" { + out[key] = "***" } else { - out[key] = values + out[key] = strings.Join(values, ",") } } return out diff --git a/internal/server/middleware/audit.go b/internal/server/middleware/audit.go index adec34b..bd5d24a 100644 --- a/internal/server/middleware/audit.go +++ b/internal/server/middleware/audit.go @@ -31,7 +31,10 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H } if c.Request.Method == http.MethodGet { requestBody = operationQueryBody(c.Request.URL.RawQuery) + } else if value, ok := c.Get(ctxReqBodyKey); ok { + requestBody = []byte(stringValue(value)) } else if c.Request.Body != nil { + // Fallback for tests or custom middleware chains that omit AccessLog. if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") { requestBody = []byte("[文件]") } else { @@ -43,8 +46,6 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H // safety limit and only applies the configured operation-log limit when a // download response is recorded. Using maxBytes here would silently // truncate ordinary JSON responses before that decision is possible. - writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: 1 << 20} - c.Writer = writer started := time.Now() c.Next() userID := uint(0) @@ -55,7 +56,12 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H } requestID, _ := c.Get("request_id") status := c.Writer.Status() - responseBody := writer.body.String() + responseBody := "" + if value, ok := c.Get(ctxRespBufferKey); ok { + if body, bok := value.(*bytes.Buffer); bok { + responseBody = body.String() + } + } if isDownloadResponse(c) && len(responseBody) > maxBytes { responseBody = "[超出记录长度]" } diff --git a/internal/server/middleware/capture.go b/internal/server/middleware/capture.go index d4fd35d..b65dc46 100644 --- a/internal/server/middleware/capture.go +++ b/internal/server/middleware/capture.go @@ -8,6 +8,14 @@ import ( "github.com/gin-gonic/gin" ) +// The global access logger is the single request/response capture point. +// OperationAudit consumes these values after the handler returns, avoiding a +// second body read/writer wrapper. +const ( + ctxReqBodyKey = "kra_req_body" + ctxRespBufferKey = "kra_resp_buffer" +) + type captureWriter struct { gin.ResponseWriter body bytes.Buffer @@ -17,7 +25,7 @@ type captureWriter struct { func (w *captureWriter) Write(data []byte) (int, error) { limit := w.maxBytes if limit <= 0 { - limit = 32768 + limit = 1024 } if w.body.Len() < limit { remaining := limit - w.body.Len() @@ -30,44 +38,32 @@ func (w *captureWriter) Write(data []byte) (int, error) { return w.ResponseWriter.Write(data) } -func redactJSON(raw []byte) string { - return redactJSONLimit(raw, 32768) -} - -func redactJSONLimit(raw []byte, limit int) string { +func redactJSON(raw []byte, contentType string, limit int) string { if len(raw) == 0 { return "" } if limit <= 0 { - limit = 32768 + limit = 1024 } - if len(raw) > limit { - raw = raw[:limit] + text := string(raw) + if !strings.Contains(strings.ToLower(contentType), "json") { + if len(text) > limit { + return "[超出记录长度]" + } + return text } var value any if json.Unmarshal(raw, &value) != nil { - return string(raw) - } - var clean func(any) - clean = func(current any) { - switch v := current.(type) { - case map[string]any: - for key, item := range v { - lower := strings.ToLower(key) - if strings.Contains(lower, "password") || strings.Contains(lower, "token") || strings.Contains(lower, "secret") { - v[key] = "******" - } else { - clean(item) - } - } - case []any: - for _, item := range v { - clean(item) - } + if len(text) > limit { + return "[超出记录长度]" } + return text } - clean(value) + maskOperationBody(value) encoded, _ := json.Marshal(value) + if len(encoded) > limit { + return "[超出记录长度]" + } return string(encoded) } diff --git a/internal/server/middleware/cors.go b/internal/server/middleware/cors.go new file mode 100644 index 0000000..52c6e50 --- /dev/null +++ b/internal/server/middleware/cors.go @@ -0,0 +1,62 @@ +package middleware + +import ( + "net/http" + "strings" + + "kra/internal/conf" + + "github.com/gin-gonic/gin" +) + +const ( + defaultCORSHeaders = "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id" + defaultCORSMethods = "POST, GET, OPTIONS,DELETE,PUT" + defaultCORSExpose = "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type, New-Token, New-Expires-At" +) + +// CORS applies the current administration CORS rules on every request so a +// configuration reload takes effect without rebuilding the Gin engine. +func CORS(runtime *conf.Runtime) gin.HandlerFunc { + return func(c *gin.Context) { + config := runtime.Admin() + if config == nil || config.Cors == nil { + c.Next() + return + } + mode := strings.TrimSpace(config.Cors.Mode) + origin := c.GetHeader("Origin") + if mode == "allow-all" { + setCORSHeaders(c, origin, defaultCORSHeaders, defaultCORSMethods, defaultCORSExpose, true) + } else if rule := matchingCORSRule(config.Cors.Whitelist, origin); rule != nil { + setCORSHeaders(c, rule.AllowOrigin, rule.AllowHeaders, rule.AllowMethods, rule.ExposeHeaders, rule.AllowCredentials) + } else if mode == "strict-whitelist" && !(c.Request.Method == http.MethodGet && c.Request.URL.Path == "/health") { + c.AbortWithStatus(http.StatusForbidden) + return + } + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} + +func matchingCORSRule(rules []*conf.AdminBackend_CORSRule, origin string) *conf.AdminBackend_CORSRule { + for _, rule := range rules { + if rule != nil && origin == rule.AllowOrigin { + return rule + } + } + return nil +} + +func setCORSHeaders(c *gin.Context, origin, headers, methods, expose string, credentials bool) { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Headers", headers) + c.Header("Access-Control-Allow-Methods", methods) + c.Header("Access-Control-Expose-Headers", expose) + if credentials { + c.Header("Access-Control-Allow-Credentials", "true") + } +} diff --git a/internal/server/middleware/error_audit.go b/internal/server/middleware/error_audit.go index cbbd16b..b51080b 100644 --- a/internal/server/middleware/error_audit.go +++ b/internal/server/middleware/error_audit.go @@ -1,6 +1,7 @@ package middleware import ( + "bytes" "encoding/json" "strings" @@ -16,14 +17,18 @@ import ( // system errors and therefore are not inserted into sys_error. func ErrorAudit(audit *service.AuditRecorder) gin.HandlerFunc { return func(c *gin.Context) { - writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: 1 << 20} - c.Writer = writer c.Next() if strings.Contains(c.Request.URL.Path, "/sysError/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 { return } var response httpx.Response - if json.Unmarshal(writer.body.Bytes(), &response) != nil || response.Code == httpx.CodeSuccess || expectedClientFailure(response.Msg) { + var body []byte + if value, ok := c.Get(ctxRespBufferKey); ok { + if buffer, valid := value.(*bytes.Buffer); valid { + body = buffer.Bytes() + } + } + if json.Unmarshal(body, &response) != nil || response.Code == httpx.CodeSuccess || expectedClientFailure(response.Msg) { return } requestID, _ := c.Get("request_id") diff --git a/internal/server/middleware/recovery.go b/internal/server/middleware/recovery.go index ad7010a..fa5d953 100644 --- a/internal/server/middleware/recovery.go +++ b/internal/server/middleware/recovery.go @@ -33,7 +33,7 @@ func Recovery(audit *service.AuditRecorder, logger *slog.Logger) gin.HandlerFunc request, _ := httputil.DumpRequest(c.Request, false) info := fmt.Sprintf("error=%v request=%s stack=%s", panicValue, request, debug.Stack()) if logger != nil { - logger.ErrorContext(c.Request.Context(), "recovery from panic", "error", panicValue, "request", string(request), "stack", string(debug.Stack())) + logger.ErrorContext(c.Request.Context(), "recovery from panic", "mod", "error", "error", panicValue, "request", string(request), "stack", string(debug.Stack())) } requestID, _ := c.Get("request_id") _ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: info, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")}) diff --git a/internal/server/middleware/request.go b/internal/server/middleware/request.go index a8bc613..dbc90c2 100644 --- a/internal/server/middleware/request.go +++ b/internal/server/middleware/request.go @@ -3,15 +3,14 @@ package middleware import ( "crypto/rand" "encoding/hex" - "regexp" "strings" + "kra/pkg/logging" + "github.com/gin-gonic/gin" "github.com/google/uuid" ) -var traceParentPattern = regexp.MustCompile(`^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$`) - func randomHex(bytes int) string { value := make([]byte, bytes) _, _ = rand.Read(value) @@ -21,13 +20,13 @@ func randomHex(bytes int) string { func RequestMeta() gin.HandlerFunc { return func(c *gin.Context) { requestID := c.GetHeader("X-Request-Id") - if requestID == "" || len(requestID) > 128 || strings.ContainsAny(requestID, "\r\n") { + if !saneHeaderID(requestID) { requestID = uuid.NewString() } traceID, parentSpanID := "", "" - if match := traceParentPattern.FindStringSubmatch(strings.ToLower(c.GetHeader("traceparent"))); len(match) == 3 { - traceID, parentSpanID = match[1], match[2] - } else if candidate := c.GetHeader("X-Trace-Id"); len(candidate) <= 128 && !strings.ContainsAny(candidate, "\r\n") { + if upstreamTraceID, upstreamSpanID, ok := parseTraceparent(c.GetHeader("traceparent")); ok { + traceID, parentSpanID = upstreamTraceID, upstreamSpanID + } else if candidate := c.GetHeader("X-Trace-Id"); saneHeaderID(candidate) { traceID = candidate } if traceID == "" { @@ -36,13 +35,68 @@ func RequestMeta() gin.HandlerFunc { spanID := randomHex(8) c.Header("X-Request-Id", requestID) c.Header("X-Trace-Id", traceID) - if len(traceID) == 32 { + if validTraceID(traceID) { c.Header("traceparent", "00-"+traceID+"-"+spanID+"-01") } c.Set("request_id", requestID) c.Set("trace_id", traceID) c.Set("span_id", spanID) c.Set("parent_span_id", parentSpanID) + c.Request = c.Request.WithContext(logging.WithContextFields(c.Request.Context(), &logging.ContextFields{ + RequestID: requestID, TraceID: traceID, SpanID: spanID, ParentSpanID: parentSpanID, + DeviceID: c.GetHeader("X-Device-Id"), ClientIP: c.ClientIP(), + HTTPMethod: c.Request.Method, HTTPPath: c.Request.URL.Path, + })) c.Next() } } + +func parseTraceparent(value string) (traceID, parentSpanID string, ok bool) { + parts := strings.Split(value, "-") + if len(parts) < 4 { + return "", "", false + } + version, traceID, parentSpanID, flags := parts[0], parts[1], parts[2], parts[3] + if !lowerHex(version, 2) || version == "ff" || version == "00" && len(parts) != 4 || + !validTraceID(traceID) || !lowerHex(parentSpanID, 16) || allZero(parentSpanID) || !lowerHex(flags, 2) { + return "", "", false + } + return traceID, parentSpanID, true +} + +func saneHeaderID(value string) bool { + if value == "" || len(value) > 64 { + return false + } + for index := 0; index < len(value); index++ { + if value[index] <= 0x20 || value[index] > 0x7e { + return false + } + } + return true +} + +func validTraceID(value string) bool { return lowerHex(value, 32) && !allZero(value) } + +func lowerHex(value string, length int) bool { + if len(value) != length { + return false + } + for index := 0; index < len(value); index++ { + if current := value[index]; current < '0' || current > '9' { + if current < 'a' || current > 'f' { + return false + } + } + } + return true +} + +func allZero(value string) bool { + for index := 0; index < len(value); index++ { + if value[index] != '0' { + return false + } + } + return true +} diff --git a/internal/service/dictionary_import.go b/internal/service/dictionary_import.go index 9cc3a39..57ecb7b 100644 --- a/internal/service/dictionary_import.go +++ b/internal/service/dictionary_import.go @@ -17,8 +17,14 @@ func (s *DictionaryService) ImportDictionaryJSON(ctx context.Context, raw string Description string `json:"desc"` Details []dto.DictionaryDetailRequest `json:"sysDictionaryDetails"` } - if json.Unmarshal([]byte(raw), &payload) != nil || payload.Name == "" || payload.Type == "" { - return errors.New("JSON 格式错误") + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return errors.New("JSON 格式错误: " + err.Error()) + } + if payload.Name == "" { + return errors.New("字典名称不能为空") + } + if payload.Type == "" { + return errors.New("字典类型不能为空") } dictionary := dictionaryDomain(&dto.DictionaryRequest{Name: payload.Name, Type: payload.Type, Status: payload.Status, Description: payload.Description}) details := make([]*biz.DictionaryDetail, 0, len(payload.Details)) diff --git a/internal/service/dto/organization.go b/internal/service/dto/organization.go index c9fdd52..7bf0b5a 100644 --- a/internal/service/dto/organization.go +++ b/internal/service/dto/organization.go @@ -8,7 +8,7 @@ type DepartmentRequest struct { ParentID uint `json:"parentId"` Sort int `json:"sort"` LeaderID uint `json:"leaderId"` - Status bool `json:"status"` + Status *bool `json:"status"` } type DepartmentListRequest struct { @@ -38,7 +38,7 @@ type DepartmentResponse struct { Sort int `json:"sort"` LeaderID uint `json:"leaderId"` Leader any `json:"leader"` - Status bool `json:"status"` + Status *bool `json:"status"` Children []*DepartmentResponse `json:"children"` NamePath string `json:"namePath"` } @@ -48,7 +48,7 @@ type PositionRequest struct { Name string `json:"name"` Code string `json:"code"` Sort int `json:"sort"` - Status bool `json:"status"` + Status *bool `json:"status"` Remark string `json:"remark"` } @@ -79,6 +79,6 @@ type PositionResponse struct { Name string `json:"name"` Code string `json:"code"` Sort int `json:"sort"` - Status bool `json:"status"` + Status *bool `json:"status"` Remark string `json:"remark"` } diff --git a/internal/worker/task_scheduler.go b/internal/worker/task_scheduler.go index 5b5d065..b1bebc7 100644 --- a/internal/worker/task_scheduler.go +++ b/internal/worker/task_scheduler.go @@ -34,7 +34,7 @@ type scheduledEntry struct { } func NewTaskScheduler(tasks *biz.TaskUsecase, authorities *biz.AuthorityUsecase, executor *TaskExecutor, logger *slog.Logger) *TaskScheduler { - return &TaskScheduler{tasks: tasks, authorities: authorities, executor: executor, logger: logger, standard: cron.New(), seconds: cron.New(cron.WithSeconds()), entries: map[uint]scheduledEntry{}, subscribers: map[uint]map[chan []byte]struct{}{}} + return &TaskScheduler{tasks: tasks, authorities: authorities, executor: executor, logger: logger.With("mod", "timedTask"), standard: cron.New(), seconds: cron.New(cron.WithSeconds()), entries: map[uint]scheduledEntry{}, subscribers: map[uint]map[chan []byte]struct{}{}} } func NewTaskRuntime(scheduler *TaskScheduler) biz.TaskRuntime { return scheduler } @@ -48,13 +48,17 @@ func (s *TaskScheduler) Start(ctx context.Context) error { s.seconds.Start() items, _, err := s.tasks.ListTasks(ctx, 0, 0, nil) if err == nil { + loaded := 0 for _, task := range items { if task.Enabled { if scheduleErr := s.Schedule(task); scheduleErr != nil { s.logger.ErrorContext(ctx, "restore timed task failed", "id", task.ID, "error", scheduleErr) + } else { + loaded++ } } } + s.logger.InfoContext(ctx, "定时任务加载完成", "task_count", loaded) } else { s.logger.WarnContext(ctx, "timed task table is not ready", "error", err) } @@ -117,6 +121,7 @@ func (s *TaskScheduler) Reload(ctx context.Context) error { } } } + s.logger.InfoContext(ctx, "定时任务重载完成", "task_count", len(items)) return nil } @@ -131,6 +136,15 @@ func (s *TaskScheduler) executionContext() context.Context { func (s *TaskScheduler) run(task *biz.TimedTask, trigger string) { log := s.executor.Run(s.executionContext(), task, trigger) + attributes := []any{"task_id", log.TaskID, "task_name", log.TaskName, "trigger_type", log.TriggerType, "status", log.Status, "duration_ms", log.DurationMS, "started_at", log.StartedAt, "finished_at", log.FinishedAt} + if log.ErrorMsg != "" { + attributes = append(attributes, "error", log.ErrorMsg) + } + if log.Status == "success" { + s.logger.Info("timed task finished", attributes...) + } else { + s.logger.Error("timed task finished", attributes...) + } if log.Status != "success" { ids, err := s.authorities.AuthorityUserIDs(context.Background(), 888) if err != nil { diff --git a/pkg/logging/context.go b/pkg/logging/context.go new file mode 100644 index 0000000..783de8e --- /dev/null +++ b/pkg/logging/context.go @@ -0,0 +1,67 @@ +package logging + +import ( + "context" + "log/slog" +) + +type contextFieldsKey struct{} + +// ContextFields contains request metadata attached to logs emitted with a +// request context. +type ContextFields struct { + RequestID string + TraceID string + SpanID string + ParentSpanID string + DeviceID string + ClientIP string + HTTPMethod string + HTTPPath string +} + +func WithContextFields(ctx context.Context, fields *ContextFields) context.Context { + return context.WithValue(ctx, contextFieldsKey{}, fields) +} + +func ContextFieldsFrom(ctx context.Context) *ContextFields { + if ctx == nil { + return nil + } + fields, _ := ctx.Value(contextFieldsKey{}).(*ContextFields) + return fields +} + +type contextHandler struct{ handler slog.Handler } + +func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.handler.Enabled(ctx, level) +} + +func (h *contextHandler) Handle(ctx context.Context, record slog.Record) error { + if fields := ContextFieldsFrom(ctx); fields != nil { + record.AddAttrs( + slog.String("request_id", fields.RequestID), + slog.String("trace_id", fields.TraceID), + slog.String("device_id", fields.DeviceID), + slog.String("client_ip", fields.ClientIP), + slog.String("http_method", fields.HTTPMethod), + slog.String("http_path", fields.HTTPPath), + ) + if fields.SpanID != "" { + record.AddAttrs(slog.String("span_id", fields.SpanID)) + } + if fields.ParentSpanID != "" { + record.AddAttrs(slog.String("parent_span_id", fields.ParentSpanID)) + } + } + return h.handler.Handle(ctx, record) +} + +func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &contextHandler{handler: h.handler.WithAttrs(attrs)} +} + +func (h *contextHandler) WithGroup(name string) slog.Handler { + return &contextHandler{handler: h.handler.WithGroup(name)} +} diff --git a/pkg/logging/daily.go b/pkg/logging/daily.go index fb70ff1..36498ff 100644 --- a/pkg/logging/daily.go +++ b/pkg/logging/daily.go @@ -53,11 +53,12 @@ func (w *DailyWriter) Write(value []byte) (int, error) { if w.file != nil { _ = w.file.Close() } - directory := filepath.Join(w.root, date) + filePath := filepath.Join(w.root, date, filepath.Clean(w.name)) + directory := filepath.Dir(filePath) if err := os.MkdirAll(directory, 0o755); err != nil { return 0, err } - file, err := os.OpenFile(filepath.Join(directory, w.name), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return 0, err } @@ -66,6 +67,15 @@ func (w *DailyWriter) Write(value []byte) (int, error) { return w.file.Write(value) } +func (w *DailyWriter) Sync() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + return w.file.Sync() +} + func (w *DailyWriter) Close() error { w.mu.Lock() defer w.mu.Unlock() diff --git a/pkg/logging/zap.go b/pkg/logging/zap.go index b7d1aae..04fa92e 100644 --- a/pkg/logging/zap.go +++ b/pkg/logging/zap.go @@ -2,8 +2,10 @@ package logging import ( "context" + "fmt" "log/slog" "os" + "path/filepath" "strings" "sync" "time" @@ -136,12 +138,140 @@ func (c *moduleFilterCore) Write(entry zapcore.Entry, fields []zapcore.Field) er func moduleField(fields []zapcore.Field) string { for _, field := range fields { if field.Key == "mod" { - return field.String + if field.String != "" { + return field.String + } + if field.Interface != nil { + return fmt.Sprint(field.Interface) + } } } return "" } +type routedFileState struct { + mu sync.Mutex + writers map[string]*DailyWriter +} + +type routedFileCore struct { + base zapcore.Core + encoder zapcore.Encoder + level zapcore.LevelEnabler + root string + retentionDay int + state *routedFileState + fields []zapcore.Field +} + +func (c *routedFileCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) } + +func (c *routedFileCore) With(fields []zapcore.Field) zapcore.Core { + inherited := append([]zapcore.Field(nil), c.fields...) + inherited = append(inherited, fields...) + return &routedFileCore{base: c.base.With(fields), encoder: c.encoder, level: c.level, root: c.root, retentionDay: c.retentionDay, state: c.state, fields: inherited} +} + +func (c *routedFileCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(entry.Level) { + return checked.AddCore(entry, c) + } + return checked +} + +func (c *routedFileCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { + baseErr := c.base.Write(entry, fields) + allFields := append([]zapcore.Field(nil), c.fields...) + allFields = append(allFields, fields...) + paths := routedLogPaths(moduleField(allFields), entry.Level) + if len(paths) == 0 { + return baseErr + } + buffer, err := c.encoder.Clone().EncodeEntry(entry, allFields) + if err != nil { + if baseErr != nil { + return baseErr + } + return err + } + defer buffer.Free() + for _, name := range paths { + writer := c.writer(name) + if _, writeErr := writer.Write(buffer.Bytes()); writeErr != nil && baseErr == nil { + baseErr = writeErr + } + } + return baseErr +} + +func (c *routedFileCore) Sync() error { + result := c.base.Sync() + c.state.mu.Lock() + defer c.state.mu.Unlock() + for _, writer := range c.state.writers { + if err := writer.Sync(); err != nil && result == nil { + result = err + } + } + return result +} + +func (c *routedFileCore) writer(name string) *DailyWriter { + c.state.mu.Lock() + defer c.state.mu.Unlock() + writer := c.state.writers[name] + if writer == nil { + writer = NewDailyWriter(c.root, name, c.retentionDay) + c.state.writers[name] = writer + } + return writer +} + +func (c *routedFileCore) Close() { + c.state.mu.Lock() + defer c.state.mu.Unlock() + for name, writer := range c.state.writers { + _ = writer.Close() + delete(c.state.writers, name) + } +} + +func routedLogPaths(module string, level zapcore.Level) []string { + paths := make([]string, 0, 2) + if module = safeModuleName(module); module != "" { + switch module { + case "http": + paths = append(paths, filepath.Join("http", "access.log")) + case "timedTask": + paths = append(paths, filepath.Join("timedTask", "task.log")) + case "error": + paths = append(paths, filepath.Join("error", "error.log")) + default: + paths = append(paths, filepath.Join(module, "application.log")) + } + } + if level >= zapcore.ErrorLevel { + errorPath := filepath.Join("error", "error.log") + if len(paths) == 0 || paths[len(paths)-1] != errorPath { + paths = append(paths, errorPath) + } + } + return paths +} + +func safeModuleName(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' { + return r + } + return -1 + }, value) +} + // NewZapLogger adapts a Zap core to the slog logger used by Kratos v3. // The file layout remains compatible with the administration log viewer. func newZapHandler(root, filename string, options Options) (slog.Handler, func()) { @@ -170,9 +300,14 @@ func newZapHandler(root, filename string, options Options) (slog.Handler, func() } outputEncoder = zapcore.NewConsoleEncoder(encoder) } - level := zap.InfoLevel - if parsed := level.Set(strings.ToLower(options.Level)); parsed != nil { - level = zap.DebugLevel + level := zap.DebugLevel + // zapcore.Level.Set reports an error but does not make the intended + // configuration handling obvious here. Parse the configured value directly + // so debug/warn/error are actually applied after startup and hot reload. + if value := strings.TrimSpace(strings.ToLower(options.Level)); value != "" { + if err := level.UnmarshalText([]byte(value)); err != nil { + level = zap.DebugLevel + } } levelEnabler := zap.NewAtomicLevelAt(level) fileCore := zapcore.NewCore( @@ -189,7 +324,8 @@ func newZapHandler(root, filename string, options Options) (slog.Handler, func() consoleCore := zapcore.NewCore(outputEncoder.Clone(), zapcore.AddSync(os.Stdout), levelEnabler) core = zapcore.NewTee(fileCore, &moduleFilterCore{Core: consoleCore, fileOnly: fileOnly}) } - zapLogger := zap.New(core) + routed := &routedFileCore{base: core, encoder: outputEncoder.Clone(), level: levelEnabler, root: root, retentionDay: options.RetentionDay, state: &routedFileState{writers: map[string]*DailyWriter{}}} + zapLogger := zap.New(routed) handlerOptions := []zapslog.HandlerOption{zapslog.AddStacktraceAt(slog.LevelError)} if options.ShowLine { handlerOptions = append(handlerOptions, zapslog.WithCaller(true)) @@ -197,6 +333,7 @@ func newZapHandler(root, filename string, options Options) (slog.Handler, func() handler := zapslog.NewHandler(zapLogger.Core(), handlerOptions...) cleanup := func() { _ = zapLogger.Sync() + routed.Close() _ = file.Close() } return handler, cleanup @@ -205,10 +342,11 @@ func newZapHandler(root, filename string, options Options) (slog.Handler, func() // NewReloadableZapLogger keeps the slog/Kratos adapter stable while replacing // the underlying Zap core when the runtime configuration changes. func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) { - handler, cleanup := newZapHandler(root, filename, options) - state := &reloadableHandlerState{handler: handler, cleanup: cleanup} + baseHandler, cleanup := newZapHandler(root, filename, options) + state := &reloadableHandlerState{handler: baseHandler, cleanup: cleanup} control := &ReloadableLogger{state: state, filename: filename} - logger := kratoslog.NewLogger(&reloadableHandler{state: state}, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...) + handler := &contextHandler{handler: &reloadableHandler{state: state}} + logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...) return logger, control } diff --git a/pkg/logging/zap_test.go b/pkg/logging/zap_test.go new file mode 100644 index 0000000..60edacf --- /dev/null +++ b/pkg/logging/zap_test.go @@ -0,0 +1,58 @@ +package logging + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" +) + +func TestZapHandlerHonorsConfiguredLevel(t *testing.T) { + root := t.TempDir() + handler, cleanup := newZapHandler(root, "application.log", Options{Level: "error", Format: "json"}) + defer cleanup() + + if handler.Enabled(context.Background(), slog.LevelInfo) { + t.Fatal("info should be disabled when level is error") + } + if !handler.Enabled(context.Background(), slog.LevelError) { + t.Fatal("error should be enabled when level is error") + } +} + +func TestZapHandlerUsesDebugFallbackForInvalidLevel(t *testing.T) { + root := t.TempDir() + handler, cleanup := newZapHandler(root, "application.log", Options{Level: "not-a-level", Format: "json"}) + defer cleanup() + + if !handler.Enabled(context.Background(), slog.LevelDebug) { + t.Fatal("invalid levels should use the reference debug fallback") + } +} + +func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) { + root := t.TempDir() + handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"}) + logger := slog.New(handler) + logger.Info("request", "mod", "http", "request_id", "req-1") + logger.Error("failed", "mod", "users") + cleanup() + + dateEntries, err := os.ReadDir(root) + if err != nil || len(dateEntries) != 1 { + t.Fatalf("expected one daily log directory, entries=%v err=%v", len(dateEntries), err) + } + date := dateEntries[0].Name() + paths := []string{ + filepath.Join(root, date, "application.log"), + filepath.Join(root, date, "http", "access.log"), + filepath.Join(root, date, "users", "application.log"), + filepath.Join(root, date, "error", "error.log"), + } + for _, path := range paths { + if info, err := os.Stat(path); err != nil || info.Size() == 0 { + t.Fatalf("expected non-empty routed log %s: info=%v err=%v", path, info, err) + } + } +} diff --git a/web/src/api/api.js b/web/src/api/api.js index 664648f..697385f 100644 --- a/web/src/api/api.js +++ b/web/src/api/api.js @@ -68,14 +68,6 @@ export const updateApi = (data) => { }) } -// @Tags Api -// @Summary 更新api -// @Security ApiKeyAuth -// @accept application/json -// @Produce application/json -// @Param data body api.CreateApiParams true "更新api" -// @Success 200 {string} json "{"success":true,"data":{},"msg":"更新成功"}" -// @Router /api/setAuthApi [post] // @Tags Api // @Summary 获取所有的Api 不分页 // @Security ApiKeyAuth diff --git a/web/src/api/system/sysError.js b/web/src/api/system/sysError.js index 0d31820..0acec24 100644 --- a/web/src/api/system/sysError.js +++ b/web/src/api/system/sysError.js @@ -94,11 +94,3 @@ export const getSysErrorList = (params) => { params }) } - -// @Tags SysError -// @Summary 不需要鉴权的错误日志接口 -// @Accept application/json -// @Produce application/json -// @Param data query systemReq.SysErrorSearch true "分页获取错误日志列表" -// @Success 200 {object} response.Response{data=object,msg=string} "获取成功" -// @Router /sysError/getSysErrorPublic [get] diff --git a/web/src/view/systemTools/logViewer/index.vue b/web/src/view/systemTools/logViewer/index.vue index 25b785f..b6fee52 100644 --- a/web/src/view/systemTools/logViewer/index.vue +++ b/web/src/view/systemTools/logViewer/index.vue @@ -416,7 +416,7 @@ async function selectDate(date) { const result = await getLogFiles({ date }) if (requestId !== fileRequestId || selectedDate.value !== date || result.code !== 0) return files.value = result.data.files || [] - const defaultFile = files.value.find(file => file.path === 'info.log') || files.value[0] + const defaultFile = files.value.find(file => file.path === 'application.log') || files.value.find(file => file.path === 'info.log') || files.value[0] if (defaultFile) await openFile(defaultFile.path) } finally { if (requestId === fileRequestId) loadingFiles.value = false diff --git a/web/src/view/systemTools/system/system.vue b/web/src/view/systemTools/system/system.vue index 9b02d45..f7f7a81 100644 --- a/web/src/view/systemTools/system/system.vue +++ b/web/src/view/systemTools/system/system.vue @@ -230,7 +230,7 @@ jwt: { signingKey: '******', expiresTime: '168h', bufferTime: '24h', issuer: 'kra' }, captcha: { keyLong: 6, imgWidth: 240, imgHeight: 80, storeExpiration: '3m' }, local: { storePath: 'uploads/file', pathPrefix: 'uploads/file' }, media: { sessionTtl: 24, maxFileSize: 0 }, - zap: { level: 'info', prefix: '[kra] ', format: 'json', director: 'logs', encode_level: 'LowercaseLevelEncoder', stacktrace_key: 'stacktrace', show_line: true, log_in_console: true, retention_day: 7, access_req_body: true, access_resp_data: true, access_req_headers: false, access_log_max_bytes: 32768, file_only_modules: [] }, + zap: { level: 'info', prefix: '[kra] ', format: 'json', director: 'logs', encode_level: 'LowercaseLevelEncoder', stacktrace_key: 'stacktrace', show_line: true, log_in_console: true, retention_day: 7, access_req_body: true, access_resp_data: true, access_req_headers: true, access_log_max_bytes: 1024, file_only_modules: [] }, cors: { mode: 'whitelist', whitelist: [] }, app: { node: '', app_id: 'kra', env: 'development' }, system: { useRedis: false, useMultipoint: false, useStrictAuth: false, disableAutoMigrate: false, useMongo: false }, storage: { type: 'local', qiniu: {}, aliyun_oss: {}, huawei_obs: {}, tencent_cos: {}, aws_s3: {}, cloudflare_r2: {}, minio: {} }