This commit is contained in:
parent
f7f6db8695
commit
a85477be80
|
|
@ -102,7 +102,7 @@ func main() {
|
||||||
})
|
})
|
||||||
defer unsubscribeLogger()
|
defer unsubscribeLogger()
|
||||||
|
|
||||||
app, cleanup, err := wireApp(bc.Server, runtime, logger)
|
app, cleanup, err := wireApp(bc.Server, runtime, logger, Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,6 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// wireApp init kratos application.
|
// 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))
|
panic(wire.Build(server.ProviderSet, worker.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import (
|
||||||
// Injectors from wire.go:
|
// Injectors from wire.go:
|
||||||
|
|
||||||
// wireApp init kratos application.
|
// 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)
|
dataData, cleanup, err := data.NewData(runtime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|
@ -90,7 +90,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
|
||||||
logFileRepo := data.NewLogFileRepo(dataData)
|
logFileRepo := data.NewLogFileRepo(dataData)
|
||||||
logViewerUsecase := biz.NewLogViewerUsecase(logFileRepo)
|
logViewerUsecase := biz.NewLogViewerUsecase(logFileRepo)
|
||||||
logViewerService := service.NewLogViewerService(logViewerUsecase)
|
logViewerService := service.NewLogViewerService(logViewerUsecase)
|
||||||
audit := handler.NewAudit(auditService, auditRecorder, logViewerService)
|
audit := handler.NewAudit(auditService, auditRecorder, logViewerService, logger)
|
||||||
exportRepo := data.NewExportRepo(dataData)
|
exportRepo := data.NewExportRepo(dataData)
|
||||||
exportUsecase := biz.NewExportUsecase(exportRepo)
|
exportUsecase := biz.NewExportUsecase(exportRepo)
|
||||||
cache := data.NewCache(dataData)
|
cache := data.NewCache(dataData)
|
||||||
|
|
@ -130,7 +130,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
|
||||||
navigation := handler.NewNavigation(userService)
|
navigation := handler.NewNavigation(userService)
|
||||||
session := handler.NewSession(tokenService)
|
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)
|
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)
|
httpServer := server.NewGinServer(confServer, engine)
|
||||||
app := newApp(logger, httpServer, taskScheduler)
|
app := newApp(logger, httpServer, taskScheduler)
|
||||||
return app, func() {
|
return app, func() {
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,8 @@ admin:
|
||||||
retention_day: 7
|
retention_day: 7
|
||||||
access_req_body: true
|
access_req_body: true
|
||||||
access_resp_data: true
|
access_resp_data: true
|
||||||
access_req_headers: false
|
access_req_headers: true
|
||||||
access_log_max_bytes: 32768
|
access_log_max_bytes: 1024
|
||||||
file_only_modules: []
|
file_only_modules: []
|
||||||
cors:
|
cors:
|
||||||
mode: whitelist
|
mode: whitelist
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,10 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrTokenIssue, err)
|
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() {
|
if uc.security.UseMultipoint() {
|
||||||
oldToken, cacheErr := uc.security.ActiveToken(ctx, user.Username)
|
oldToken, cacheErr := uc.security.ActiveToken(ctx, user.Username)
|
||||||
if cacheErr != nil {
|
if cacheErr != nil {
|
||||||
|
|
@ -123,7 +127,6 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
|
||||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, cacheErr)
|
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
|
return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: user.MustChangePassword}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ type Department struct {
|
||||||
Sort int
|
Sort int
|
||||||
LeaderID uint
|
LeaderID uint
|
||||||
Leader *User
|
Leader *User
|
||||||
Status bool
|
Status *bool
|
||||||
Children []*Department
|
Children []*Department
|
||||||
NamePath string
|
NamePath string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,11 +76,16 @@ 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}
|
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 save {
|
||||||
|
count, countErr := uc.MediaKeyReferences(ctx, key)
|
||||||
|
if countErr != nil {
|
||||||
|
return nil, countErr
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
if err = uc.CreateMedia(ctx, media); err != nil {
|
if err = uc.CreateMedia(ctx, media); err != nil {
|
||||||
_ = uc.files.Delete(ctx, key)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return media, nil
|
return media, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrUploadSessionNotFound = errors.New("upload session not found")
|
||||||
|
|
||||||
func (uc *MediaUsecase) chunkPrefix(uploadID uint) string {
|
func (uc *MediaUsecase) chunkPrefix(uploadID uint) string {
|
||||||
directory := "uploads/chunks"
|
directory := "uploads/chunks"
|
||||||
if uc.settings != nil {
|
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)
|
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"}
|
session = &UploadSession{UserID: userID, FileName: name, FileHash: hash, FileSize: size, ChunkSize: chunkSize, ChunkTotal: total, Status: "uploading"}
|
||||||
if err = uc.CreateUploadSession(ctx, session); err != nil {
|
if err = uc.CreateUploadSession(ctx, session); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
chunks, err := uc.ListChunks(ctx, session.ID)
|
chunks, err := uc.ListChunks(ctx, session.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -146,7 +150,6 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin
|
||||||
// byte count.
|
// 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}
|
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 {
|
if err = uc.CreateMedia(ctx, media); err != nil {
|
||||||
_ = uc.files.Delete(ctx, key)
|
|
||||||
return fail(err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
_ = uc.CompleteUploadSession(ctx, uploadID, key, media.ID)
|
_ = uc.CompleteUploadSession(ctx, uploadID, key, media.ID)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ type Position struct {
|
||||||
Name string
|
Name string
|
||||||
Code string
|
Code string
|
||||||
Sort int
|
Sort int
|
||||||
Status bool
|
Status *bool
|
||||||
Remark string
|
Remark string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ type departmentPO struct {
|
||||||
Ancestors string
|
Ancestors string
|
||||||
Sort int
|
Sort int
|
||||||
LeaderID uint
|
LeaderID uint
|
||||||
Status bool `gorm:"default:true"`
|
Status *bool `gorm:"default:true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (departmentPO) TableName() string { return "sys_departments" }
|
func (departmentPO) TableName() string { return "sys_departments" }
|
||||||
|
|
|
||||||
|
|
@ -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
|
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 {
|
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 {
|
func (r *auditQueryRepo) DeleteErrors(ctx context.Context, ids []uint) error {
|
||||||
return r.data.gormDB.WithContext(ctx).Delete(&errorRecordPO{}, ids).Error
|
return r.data.gormDB.WithContext(ctx).Delete(&errorRecordPO{}, ids).Error
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"kra/internal/biz"
|
"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) {
|
func (r *mediaRepo) FindCompletedSession(ctx context.Context, userID uint, hash string) (*biz.UploadSession, error) {
|
||||||
var po uploadSessionPO
|
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 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 nil, err
|
||||||
}
|
}
|
||||||
return uploadFromPO(po), nil
|
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) {
|
func (r *mediaRepo) FindUploadingSession(ctx context.Context, userID uint, hash string) (*biz.UploadSession, error) {
|
||||||
var po uploadSessionPO
|
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 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 nil, err
|
||||||
}
|
}
|
||||||
return uploadFromPO(po), nil
|
return uploadFromPO(po), nil
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,8 @@ func migrateAll(db *gorm.DB) error {
|
||||||
return reconcileReferenceIndexes(db)
|
return reconcileReferenceIndexes(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Older Kra builds used a status label that is not part of the GVA error-log
|
// Older builds used a status label outside the administration page's supported
|
||||||
// contract and is therefore rendered as an unknown state by the compatible
|
// state set, so normalize existing rows during migration.
|
||||||
// administration page.
|
|
||||||
func normalizeErrorRecordStatuses(db *gorm.DB) error {
|
func normalizeErrorRecordStatuses(db *gorm.DB) error {
|
||||||
return db.Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error
|
return db.Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ type positionPO struct {
|
||||||
Name string `gorm:"index"`
|
Name string `gorm:"index"`
|
||||||
Code string
|
Code string
|
||||||
Sort int
|
Sort int
|
||||||
Status bool `gorm:"default:true"`
|
Status *bool `gorm:"default:true"`
|
||||||
Remark string
|
Remark string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,11 +174,12 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||||
return err
|
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 {
|
if err := tx.Where("name = ?", department.Name).FirstOrCreate(&department).Error; err != nil {
|
||||||
return err
|
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 {
|
if err := tx.Where("code = ?", position.Code).FirstOrCreate(&position).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestDictionaryImportKeepsHierarchyInOneTransaction(t *testing.T) {
|
||||||
data := newTransactionTestData(t)
|
data := newTransactionTestData(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
func (r *userRepo) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error {
|
||||||
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) 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}
|
access := &authorityAccessRepo{data: r.data}
|
||||||
for _, authorityID := range authorityIDs {
|
for _, authorityID := range authorityIDs {
|
||||||
if err := access.checkAuthorityIDAuth(ctx, authorityID); err != nil {
|
if err := access.checkAuthorityIDAuth(ctx, authorityID); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -20,10 +21,10 @@ import (
|
||||||
kratoshttp "github.com/go-kratos/kratos/v3/transport/http"
|
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)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
engine := gin.New()
|
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 := ""
|
prefix := ""
|
||||||
config := runtime.Admin()
|
config := runtime.Admin()
|
||||||
|
|
@ -66,9 +67,28 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h
|
||||||
}
|
}
|
||||||
httpx.Fail(c, "请求的接口不存在")
|
httpx.Fail(c, "请求的接口不存在")
|
||||||
})
|
})
|
||||||
|
logRegisteredRoutes(engine, logger)
|
||||||
return engine
|
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 {
|
func NewGinServer(c *conf.Server, engine *gin.Engine) *kratoshttp.Server {
|
||||||
network, address := "tcp", ":8000"
|
network, address := "tcp", ":8000"
|
||||||
if c != nil && c.Http != nil {
|
if c != nil && c.Http != nil {
|
||||||
|
|
|
||||||
|
|
@ -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{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package handler
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -18,10 +19,11 @@ type Audit struct {
|
||||||
service *service.AuditService
|
service *service.AuditService
|
||||||
recorder *service.AuditRecorder
|
recorder *service.AuditRecorder
|
||||||
logs *service.LogViewerService
|
logs *service.LogViewerService
|
||||||
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAudit(service *service.AuditService, recorder *service.AuditRecorder, logs *service.LogViewerService) *Audit {
|
func NewAudit(service *service.AuditService, recorder *service.AuditRecorder, logs *service.LogViewerService, logger *slog.Logger) *Audit {
|
||||||
return &Audit{service: service, recorder: recorder, logs: logs}
|
return &Audit{service: service, recorder: recorder, logs: logs, logger: logger}
|
||||||
}
|
}
|
||||||
|
|
||||||
func page(c *gin.Context) (int, int) {
|
func page(c *gin.Context) (int, int) {
|
||||||
|
|
@ -53,8 +55,8 @@ func IDsFromQuery(c *gin.Context) []uint {
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
// auditID accepts both forms used by the GVA web client over time: DELETE
|
// auditID accepts both DELETE encodings supported by the administration API:
|
||||||
// requests may carry the identifier in the query string or in a JSON body.
|
// the identifier may be carried in the query string or in a JSON body.
|
||||||
func auditID(c *gin.Context) (uint, error) {
|
func auditID(c *gin.Context) (uint, error) {
|
||||||
if raw := c.Query("ID"); raw != "" {
|
if raw := c.Query("ID"); raw != "" {
|
||||||
value, err := strconv.ParseUint(raw, 10, 64)
|
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
|
// 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) {
|
func auditIDs(c *gin.Context) ([]uint, error) {
|
||||||
if ids := IDsFromQuery(c); len(ids) > 0 {
|
if ids := IDsFromQuery(c); len(ids) > 0 {
|
||||||
return ids, nil
|
return ids, nil
|
||||||
|
|
@ -230,7 +232,7 @@ func (h *Audit) LogDates(c *gin.Context) {
|
||||||
}
|
}
|
||||||
data, err := h.logs.LogDates(c.Request.Context(), month)
|
data, err := h.logs.LogDates(c.Request.Context(), month)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failLogViewer(c, err)
|
failLogViewer(c, err, h.logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.Write(c, httpx.CodeSuccess, data, "获取成功")
|
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)
|
data, err := h.logs.LogFiles(c.Request.Context(), date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failLogViewer(c, err)
|
failLogViewer(c, err, h.logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.Write(c, httpx.CodeSuccess, data, "获取成功")
|
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.
|
// "日志文件路径不合法" error rather than rejecting them as binding errors.
|
||||||
data, err := h.logs.LogContent(c.Request.Context(), date, path, cursor)
|
data, err := h.logs.LogContent(c.Request.Context(), date, path, cursor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failLogViewer(c, err)
|
failLogViewer(c, err, h.logger)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.Write(c, httpx.CodeSuccess, data, "获取成功")
|
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 := "读取日志失败"
|
message := "读取日志失败"
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, biz.ErrInvalidLogMonth):
|
case errors.Is(err, biz.ErrInvalidLogMonth):
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,10 @@ func (h *Dictionary) FindDetail(c *gin.Context) {
|
||||||
httpx.Fail(c, err.Error())
|
httpx.Fail(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.ID == 0 {
|
||||||
|
httpx.Fail(c, "ID值不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
item, err := h.service.DictionaryDetail(c.Request.Context(), req.ID)
|
item, err := h.service.DictionaryDetail(c.Request.Context(), req.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.Fail(c, "查询失败")
|
httpx.Fail(c, "查询失败")
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -14,38 +15,42 @@ import (
|
||||||
|
|
||||||
// AccessLog is the single global request/response capture point, matching
|
// AccessLog is the single global request/response capture point, matching
|
||||||
// the reference middleware ordering and making every HTTP request observable.
|
// 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) {
|
return func(c *gin.Context) {
|
||||||
|
started := time.Now()
|
||||||
var requestBody []byte
|
var requestBody []byte
|
||||||
multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data")
|
multipart := strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data")
|
||||||
|
bytesIn := c.Request.ContentLength
|
||||||
if c.Request.Body != nil && !multipart {
|
if c.Request.Body != nil && !multipart {
|
||||||
requestBody, _ = io.ReadAll(c.Request.Body)
|
requestBody, _ = io.ReadAll(c.Request.Body)
|
||||||
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
|
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||||
|
bytesIn = int64(len(requestBody))
|
||||||
|
}
|
||||||
|
if bytesIn < 0 {
|
||||||
|
bytesIn = 0
|
||||||
}
|
}
|
||||||
maxBytes := 1 << 20
|
maxBytes := 1 << 20
|
||||||
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes}
|
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes}
|
||||||
c.Writer = writer
|
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()
|
c.Next()
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requestText, responseText := "", ""
|
responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit)
|
||||||
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)
|
|
||||||
}
|
|
||||||
userID, authorityID := uint(0), uint(0)
|
userID, authorityID := uint(0), uint(0)
|
||||||
if claims := Claims(c); claims != nil {
|
if claims := Claims(c); claims != nil {
|
||||||
userID, authorityID = claims.ID, claims.AuthorityID
|
userID, authorityID = claims.ID, claims.AuthorityID
|
||||||
|
|
@ -54,27 +59,41 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger) gin.HandlerFunc {
|
||||||
if route == "" {
|
if route == "" {
|
||||||
route = "unmatched"
|
route = "unmatched"
|
||||||
}
|
}
|
||||||
attributes := []any{
|
bytesOut := int64(c.Writer.Size())
|
||||||
"ip", c.ClientIP(), "method", c.Request.Method, "path", c.Request.URL.Path, "http_route", route,
|
if bytesOut < 0 {
|
||||||
"status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
bytesOut = 0
|
||||||
"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))
|
|
||||||
}
|
}
|
||||||
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 {
|
func redactHeaders(headers map[string][]string) map[string]string {
|
||||||
out := make(map[string][]string, len(headers))
|
out := make(map[string]string, len(headers))
|
||||||
for key, values := range headers {
|
for key, values := range headers {
|
||||||
lower := strings.ToLower(key)
|
lower := strings.ToLower(key)
|
||||||
if strings.Contains(lower, "token") || lower == "authorization" || lower == "cookie" {
|
if lower == "authorization" || lower == "cookie" || lower == "set-cookie" || lower == "x-token" {
|
||||||
out[key] = []string{"******"}
|
out[key] = "***"
|
||||||
} else {
|
} else {
|
||||||
out[key] = values
|
out[key] = strings.Join(values, ",")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
|
||||||
}
|
}
|
||||||
if c.Request.Method == http.MethodGet {
|
if c.Request.Method == http.MethodGet {
|
||||||
requestBody = operationQueryBody(c.Request.URL.RawQuery)
|
requestBody = operationQueryBody(c.Request.URL.RawQuery)
|
||||||
|
} else if value, ok := c.Get(ctxReqBodyKey); ok {
|
||||||
|
requestBody = []byte(stringValue(value))
|
||||||
} else if c.Request.Body != nil {
|
} 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") {
|
if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") {
|
||||||
requestBody = []byte("[文件]")
|
requestBody = []byte("[文件]")
|
||||||
} else {
|
} 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
|
// safety limit and only applies the configured operation-log limit when a
|
||||||
// download response is recorded. Using maxBytes here would silently
|
// download response is recorded. Using maxBytes here would silently
|
||||||
// truncate ordinary JSON responses before that decision is possible.
|
// truncate ordinary JSON responses before that decision is possible.
|
||||||
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: 1 << 20}
|
|
||||||
c.Writer = writer
|
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
c.Next()
|
c.Next()
|
||||||
userID := uint(0)
|
userID := uint(0)
|
||||||
|
|
@ -55,7 +56,12 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H
|
||||||
}
|
}
|
||||||
requestID, _ := c.Get("request_id")
|
requestID, _ := c.Get("request_id")
|
||||||
status := c.Writer.Status()
|
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 {
|
if isDownloadResponse(c) && len(responseBody) > maxBytes {
|
||||||
responseBody = "[超出记录长度]"
|
responseBody = "[超出记录长度]"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"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 {
|
type captureWriter struct {
|
||||||
gin.ResponseWriter
|
gin.ResponseWriter
|
||||||
body bytes.Buffer
|
body bytes.Buffer
|
||||||
|
|
@ -17,7 +25,7 @@ type captureWriter struct {
|
||||||
func (w *captureWriter) Write(data []byte) (int, error) {
|
func (w *captureWriter) Write(data []byte) (int, error) {
|
||||||
limit := w.maxBytes
|
limit := w.maxBytes
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 32768
|
limit = 1024
|
||||||
}
|
}
|
||||||
if w.body.Len() < limit {
|
if w.body.Len() < limit {
|
||||||
remaining := limit - w.body.Len()
|
remaining := limit - w.body.Len()
|
||||||
|
|
@ -30,44 +38,32 @@ func (w *captureWriter) Write(data []byte) (int, error) {
|
||||||
return w.ResponseWriter.Write(data)
|
return w.ResponseWriter.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactJSON(raw []byte) string {
|
func redactJSON(raw []byte, contentType string, limit int) string {
|
||||||
return redactJSONLimit(raw, 32768)
|
|
||||||
}
|
|
||||||
|
|
||||||
func redactJSONLimit(raw []byte, limit int) string {
|
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 32768
|
limit = 1024
|
||||||
}
|
}
|
||||||
if len(raw) > limit {
|
text := string(raw)
|
||||||
raw = raw[:limit]
|
if !strings.Contains(strings.ToLower(contentType), "json") {
|
||||||
|
if len(text) > limit {
|
||||||
|
return "[超出记录长度]"
|
||||||
|
}
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
var value any
|
var value any
|
||||||
if json.Unmarshal(raw, &value) != nil {
|
if json.Unmarshal(raw, &value) != nil {
|
||||||
return string(raw)
|
if len(text) > limit {
|
||||||
|
return "[超出记录长度]"
|
||||||
}
|
}
|
||||||
var clean func(any)
|
return text
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
maskOperationBody(value)
|
||||||
case []any:
|
|
||||||
for _, item := range v {
|
|
||||||
clean(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clean(value)
|
|
||||||
encoded, _ := json.Marshal(value)
|
encoded, _ := json.Marshal(value)
|
||||||
|
if len(encoded) > limit {
|
||||||
|
return "[超出记录长度]"
|
||||||
|
}
|
||||||
return string(encoded)
|
return string(encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -16,14 +17,18 @@ import (
|
||||||
// system errors and therefore are not inserted into sys_error.
|
// system errors and therefore are not inserted into sys_error.
|
||||||
func ErrorAudit(audit *service.AuditRecorder) gin.HandlerFunc {
|
func ErrorAudit(audit *service.AuditRecorder) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: 1 << 20}
|
|
||||||
c.Writer = writer
|
|
||||||
c.Next()
|
c.Next()
|
||||||
if strings.Contains(c.Request.URL.Path, "/sysError/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
|
if strings.Contains(c.Request.URL.Path, "/sysError/") || c.Writer.Status() >= 300 && c.Writer.Status() < 500 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var response httpx.Response
|
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
|
return
|
||||||
}
|
}
|
||||||
requestID, _ := c.Get("request_id")
|
requestID, _ := c.Get("request_id")
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ func Recovery(audit *service.AuditRecorder, logger *slog.Logger) gin.HandlerFunc
|
||||||
request, _ := httputil.DumpRequest(c.Request, false)
|
request, _ := httputil.DumpRequest(c.Request, false)
|
||||||
info := fmt.Sprintf("error=%v request=%s stack=%s", panicValue, request, debug.Stack())
|
info := fmt.Sprintf("error=%v request=%s stack=%s", panicValue, request, debug.Stack())
|
||||||
if logger != nil {
|
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")
|
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")})
|
_ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: info, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")})
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,14 @@ package middleware
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"kra/pkg/logging"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"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 {
|
func randomHex(bytes int) string {
|
||||||
value := make([]byte, bytes)
|
value := make([]byte, bytes)
|
||||||
_, _ = rand.Read(value)
|
_, _ = rand.Read(value)
|
||||||
|
|
@ -21,13 +20,13 @@ func randomHex(bytes int) string {
|
||||||
func RequestMeta() gin.HandlerFunc {
|
func RequestMeta() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
requestID := c.GetHeader("X-Request-Id")
|
requestID := c.GetHeader("X-Request-Id")
|
||||||
if requestID == "" || len(requestID) > 128 || strings.ContainsAny(requestID, "\r\n") {
|
if !saneHeaderID(requestID) {
|
||||||
requestID = uuid.NewString()
|
requestID = uuid.NewString()
|
||||||
}
|
}
|
||||||
traceID, parentSpanID := "", ""
|
traceID, parentSpanID := "", ""
|
||||||
if match := traceParentPattern.FindStringSubmatch(strings.ToLower(c.GetHeader("traceparent"))); len(match) == 3 {
|
if upstreamTraceID, upstreamSpanID, ok := parseTraceparent(c.GetHeader("traceparent")); ok {
|
||||||
traceID, parentSpanID = match[1], match[2]
|
traceID, parentSpanID = upstreamTraceID, upstreamSpanID
|
||||||
} else if candidate := c.GetHeader("X-Trace-Id"); len(candidate) <= 128 && !strings.ContainsAny(candidate, "\r\n") {
|
} else if candidate := c.GetHeader("X-Trace-Id"); saneHeaderID(candidate) {
|
||||||
traceID = candidate
|
traceID = candidate
|
||||||
}
|
}
|
||||||
if traceID == "" {
|
if traceID == "" {
|
||||||
|
|
@ -36,13 +35,68 @@ func RequestMeta() gin.HandlerFunc {
|
||||||
spanID := randomHex(8)
|
spanID := randomHex(8)
|
||||||
c.Header("X-Request-Id", requestID)
|
c.Header("X-Request-Id", requestID)
|
||||||
c.Header("X-Trace-Id", traceID)
|
c.Header("X-Trace-Id", traceID)
|
||||||
if len(traceID) == 32 {
|
if validTraceID(traceID) {
|
||||||
c.Header("traceparent", "00-"+traceID+"-"+spanID+"-01")
|
c.Header("traceparent", "00-"+traceID+"-"+spanID+"-01")
|
||||||
}
|
}
|
||||||
c.Set("request_id", requestID)
|
c.Set("request_id", requestID)
|
||||||
c.Set("trace_id", traceID)
|
c.Set("trace_id", traceID)
|
||||||
c.Set("span_id", spanID)
|
c.Set("span_id", spanID)
|
||||||
c.Set("parent_span_id", parentSpanID)
|
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()
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,14 @@ func (s *DictionaryService) ImportDictionaryJSON(ctx context.Context, raw string
|
||||||
Description string `json:"desc"`
|
Description string `json:"desc"`
|
||||||
Details []dto.DictionaryDetailRequest `json:"sysDictionaryDetails"`
|
Details []dto.DictionaryDetailRequest `json:"sysDictionaryDetails"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal([]byte(raw), &payload) != nil || payload.Name == "" || payload.Type == "" {
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||||
return errors.New("JSON 格式错误")
|
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})
|
dictionary := dictionaryDomain(&dto.DictionaryRequest{Name: payload.Name, Type: payload.Type, Status: payload.Status, Description: payload.Description})
|
||||||
details := make([]*biz.DictionaryDetail, 0, len(payload.Details))
|
details := make([]*biz.DictionaryDetail, 0, len(payload.Details))
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ type DepartmentRequest struct {
|
||||||
ParentID uint `json:"parentId"`
|
ParentID uint `json:"parentId"`
|
||||||
Sort int `json:"sort"`
|
Sort int `json:"sort"`
|
||||||
LeaderID uint `json:"leaderId"`
|
LeaderID uint `json:"leaderId"`
|
||||||
Status bool `json:"status"`
|
Status *bool `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DepartmentListRequest struct {
|
type DepartmentListRequest struct {
|
||||||
|
|
@ -38,7 +38,7 @@ type DepartmentResponse struct {
|
||||||
Sort int `json:"sort"`
|
Sort int `json:"sort"`
|
||||||
LeaderID uint `json:"leaderId"`
|
LeaderID uint `json:"leaderId"`
|
||||||
Leader any `json:"leader"`
|
Leader any `json:"leader"`
|
||||||
Status bool `json:"status"`
|
Status *bool `json:"status"`
|
||||||
Children []*DepartmentResponse `json:"children"`
|
Children []*DepartmentResponse `json:"children"`
|
||||||
NamePath string `json:"namePath"`
|
NamePath string `json:"namePath"`
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +48,7 @@ type PositionRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Sort int `json:"sort"`
|
Sort int `json:"sort"`
|
||||||
Status bool `json:"status"`
|
Status *bool `json:"status"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +79,6 @@ type PositionResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Sort int `json:"sort"`
|
Sort int `json:"sort"`
|
||||||
Status bool `json:"status"`
|
Status *bool `json:"status"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ type scheduledEntry struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskScheduler(tasks *biz.TaskUsecase, authorities *biz.AuthorityUsecase, executor *TaskExecutor, logger *slog.Logger) *TaskScheduler {
|
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 }
|
func NewTaskRuntime(scheduler *TaskScheduler) biz.TaskRuntime { return scheduler }
|
||||||
|
|
@ -48,13 +48,17 @@ func (s *TaskScheduler) Start(ctx context.Context) error {
|
||||||
s.seconds.Start()
|
s.seconds.Start()
|
||||||
items, _, err := s.tasks.ListTasks(ctx, 0, 0, nil)
|
items, _, err := s.tasks.ListTasks(ctx, 0, 0, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
loaded := 0
|
||||||
for _, task := range items {
|
for _, task := range items {
|
||||||
if task.Enabled {
|
if task.Enabled {
|
||||||
if scheduleErr := s.Schedule(task); scheduleErr != nil {
|
if scheduleErr := s.Schedule(task); scheduleErr != nil {
|
||||||
s.logger.ErrorContext(ctx, "restore timed task failed", "id", task.ID, "error", scheduleErr)
|
s.logger.ErrorContext(ctx, "restore timed task failed", "id", task.ID, "error", scheduleErr)
|
||||||
|
} else {
|
||||||
|
loaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.logger.InfoContext(ctx, "定时任务加载完成", "task_count", loaded)
|
||||||
} else {
|
} else {
|
||||||
s.logger.WarnContext(ctx, "timed task table is not ready", "error", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +136,15 @@ func (s *TaskScheduler) executionContext() context.Context {
|
||||||
|
|
||||||
func (s *TaskScheduler) run(task *biz.TimedTask, trigger string) {
|
func (s *TaskScheduler) run(task *biz.TimedTask, trigger string) {
|
||||||
log := s.executor.Run(s.executionContext(), task, trigger)
|
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" {
|
if log.Status != "success" {
|
||||||
ids, err := s.authorities.AuthorityUserIDs(context.Background(), 888)
|
ids, err := s.authorities.AuthorityUserIDs(context.Background(), 888)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
}
|
||||||
|
|
@ -53,11 +53,12 @@ func (w *DailyWriter) Write(value []byte) (int, error) {
|
||||||
if w.file != nil {
|
if w.file != nil {
|
||||||
_ = w.file.Close()
|
_ = 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 {
|
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||||
return 0, err
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +67,15 @@ func (w *DailyWriter) Write(value []byte) (int, error) {
|
||||||
return w.file.Write(value)
|
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 {
|
func (w *DailyWriter) Close() error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package logging
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -136,12 +138,140 @@ func (c *moduleFilterCore) Write(entry zapcore.Entry, fields []zapcore.Field) er
|
||||||
func moduleField(fields []zapcore.Field) string {
|
func moduleField(fields []zapcore.Field) string {
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
if field.Key == "mod" {
|
if field.Key == "mod" {
|
||||||
|
if field.String != "" {
|
||||||
return field.String
|
return field.String
|
||||||
}
|
}
|
||||||
|
if field.Interface != nil {
|
||||||
|
return fmt.Sprint(field.Interface)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ""
|
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.
|
// NewZapLogger adapts a Zap core to the slog logger used by Kratos v3.
|
||||||
// The file layout remains compatible with the administration log viewer.
|
// The file layout remains compatible with the administration log viewer.
|
||||||
func newZapHandler(root, filename string, options Options) (slog.Handler, func()) {
|
func newZapHandler(root, filename string, options Options) (slog.Handler, func()) {
|
||||||
|
|
@ -170,10 +300,15 @@ func newZapHandler(root, filename string, options Options) (slog.Handler, func()
|
||||||
}
|
}
|
||||||
outputEncoder = zapcore.NewConsoleEncoder(encoder)
|
outputEncoder = zapcore.NewConsoleEncoder(encoder)
|
||||||
}
|
}
|
||||||
level := zap.InfoLevel
|
level := zap.DebugLevel
|
||||||
if parsed := level.Set(strings.ToLower(options.Level)); parsed != nil {
|
// 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
|
level = zap.DebugLevel
|
||||||
}
|
}
|
||||||
|
}
|
||||||
levelEnabler := zap.NewAtomicLevelAt(level)
|
levelEnabler := zap.NewAtomicLevelAt(level)
|
||||||
fileCore := zapcore.NewCore(
|
fileCore := zapcore.NewCore(
|
||||||
outputEncoder.Clone(),
|
outputEncoder.Clone(),
|
||||||
|
|
@ -189,7 +324,8 @@ func newZapHandler(root, filename string, options Options) (slog.Handler, func()
|
||||||
consoleCore := zapcore.NewCore(outputEncoder.Clone(), zapcore.AddSync(os.Stdout), levelEnabler)
|
consoleCore := zapcore.NewCore(outputEncoder.Clone(), zapcore.AddSync(os.Stdout), levelEnabler)
|
||||||
core = zapcore.NewTee(fileCore, &moduleFilterCore{Core: consoleCore, fileOnly: fileOnly})
|
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)}
|
handlerOptions := []zapslog.HandlerOption{zapslog.AddStacktraceAt(slog.LevelError)}
|
||||||
if options.ShowLine {
|
if options.ShowLine {
|
||||||
handlerOptions = append(handlerOptions, zapslog.WithCaller(true))
|
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...)
|
handler := zapslog.NewHandler(zapLogger.Core(), handlerOptions...)
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
_ = zapLogger.Sync()
|
_ = zapLogger.Sync()
|
||||||
|
routed.Close()
|
||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
}
|
}
|
||||||
return handler, cleanup
|
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
|
// NewReloadableZapLogger keeps the slog/Kratos adapter stable while replacing
|
||||||
// the underlying Zap core when the runtime configuration changes.
|
// the underlying Zap core when the runtime configuration changes.
|
||||||
func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) {
|
func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) {
|
||||||
handler, cleanup := newZapHandler(root, filename, options)
|
baseHandler, cleanup := newZapHandler(root, filename, options)
|
||||||
state := &reloadableHandlerState{handler: handler, cleanup: cleanup}
|
state := &reloadableHandlerState{handler: baseHandler, cleanup: cleanup}
|
||||||
control := &ReloadableLogger{state: state, filename: filename}
|
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
|
return logger, control
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
// @Tags Api
|
||||||
// @Summary 获取所有的Api 不分页
|
// @Summary 获取所有的Api 不分页
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
|
|
|
||||||
|
|
@ -94,11 +94,3 @@ export const getSysErrorList = (params) => {
|
||||||
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]
|
|
||||||
|
|
|
||||||
|
|
@ -416,7 +416,7 @@ async function selectDate(date) {
|
||||||
const result = await getLogFiles({ date })
|
const result = await getLogFiles({ date })
|
||||||
if (requestId !== fileRequestId || selectedDate.value !== date || result.code !== 0) return
|
if (requestId !== fileRequestId || selectedDate.value !== date || result.code !== 0) return
|
||||||
files.value = result.data.files || []
|
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)
|
if (defaultFile) await openFile(defaultFile.path)
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === fileRequestId) loadingFiles.value = false
|
if (requestId === fileRequestId) loadingFiles.value = false
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,7 @@
|
||||||
jwt: { signingKey: '******', expiresTime: '168h', bufferTime: '24h', issuer: 'kra' },
|
jwt: { signingKey: '******', expiresTime: '168h', bufferTime: '24h', issuer: 'kra' },
|
||||||
captcha: { keyLong: 6, imgWidth: 240, imgHeight: 80, storeExpiration: '3m' },
|
captcha: { keyLong: 6, imgWidth: 240, imgHeight: 80, storeExpiration: '3m' },
|
||||||
local: { storePath: 'uploads/file', pathPrefix: 'uploads/file' }, media: { sessionTtl: 24, maxFileSize: 0 },
|
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' },
|
cors: { mode: 'whitelist', whitelist: [] }, app: { node: '', app_id: 'kra', env: 'development' },
|
||||||
system: { useRedis: false, useMultipoint: false, useStrictAuth: false, disableAutoMigrate: false, useMongo: false },
|
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: {} }
|
storage: { type: 'local', qiniu: {}, aliyun_oss: {}, huawei_obs: {}, tencent_cos: {}, aws_s3: {}, cloudflare_r2: {}, minio: {} }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue