From f7f6db8695666c9f8ad87a00439c8d40bc299491 Mon Sep 17 00:00:00 2001 From: yvan <8574526@qq.com> Date: Sun, 16 Aug 2026 09:02:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/config.yaml | 4 +- internal/biz/access_control.go | 3 - internal/biz/api.go | 25 +- internal/biz/audit.go | 5 +- internal/biz/authentication.go | 52 ++-- internal/biz/authority.go | 24 +- internal/biz/dictionary.go | 4 +- internal/biz/email.go | 14 +- internal/biz/export.go | 15 +- internal/biz/media.go | 78 +++++- internal/biz/media_upload.go | 51 +++- internal/biz/menu.go | 31 ++- internal/biz/security.go | 26 +- internal/biz/system_init.go | 4 +- internal/biz/task.go | 27 +- internal/biz/task_registry.go | 66 +++++ internal/biz/user.go | 21 +- internal/biz/version.go | 18 ++ ...onfig_compat.go => admin_config_compat.go} | 50 ++-- internal/data/announcement.go | 8 +- internal/data/api.go | 158 +++++++----- internal/data/api_token.go | 52 ++-- internal/data/authority.go | 92 ++++--- internal/data/casbin.go | 60 +++++ internal/data/config_management.go | 230 ++++++++++++------ internal/data/data_access_log.go | 7 +- internal/data/department.go | 12 + internal/data/dictionary.go | 95 ++++++-- internal/data/error_record.go | 2 +- internal/data/export.go | 66 +++-- internal/data/login_log.go | 4 +- internal/data/media.go | 16 +- internal/data/media_upload.go | 10 +- internal/data/menu.go | 51 ++-- internal/data/migrations.go | 137 ++++++++++- internal/data/operation_log.go | 4 +- internal/data/parameter.go | 4 +- internal/data/permission.go | 6 +- internal/data/security.go | 9 +- internal/data/storage/aliyun_storage.go | 2 +- internal/data/storage/aws_storage.go | 2 +- internal/data/storage/huawei_storage.go | 2 +- internal/data/storage/local.go | 12 +- internal/data/storage/qiniu_storage.go | 2 +- internal/data/storage/s3_storage.go | 2 +- internal/data/storage/tencent_storage.go | 2 +- internal/data/system.go | 13 +- internal/data/system_init.go | 51 +++- internal/data/task.go | 2 +- internal/data/transactions_test.go | 5 +- internal/data/user.go | 29 ++- internal/data/version.go | 165 +++++++------ internal/server/gin.go | 8 +- internal/server/handler/announcement.go | 33 ++- internal/server/handler/api.go | 58 ++++- internal/server/handler/audit.go | 165 ++++++++++--- internal/server/handler/authority.go | 84 +++++-- internal/server/handler/media.go | 44 ++-- internal/server/handler/menu.go | 110 +++++++-- internal/server/handler/organization.go | 24 +- internal/server/handler/parameter.go | 12 +- internal/server/handler/permission.go | 10 +- internal/server/handler/public.go | 16 +- internal/server/handler/task.go | 47 ++-- internal/server/handler/user.go | 98 ++++++-- internal/server/handler/version.go | 99 ++++++-- internal/server/middleware/access.go | 6 +- internal/server/middleware/audit.go | 94 ++++++- internal/server/middleware/error_audit.go | 2 +- internal/server/middleware/recovery.go | 2 +- internal/service/announcement.go | 6 +- internal/service/api.go | 20 +- internal/service/audit.go | 8 +- internal/service/audit_error.go | 2 +- internal/service/authentication.go | 4 +- internal/service/authority.go | 2 +- internal/service/dictionary.go | 42 ++-- internal/service/dto/announcement.go | 25 +- internal/service/dto/api.go | 2 +- internal/service/dto/audit.go | 35 ++- internal/service/dto/authority.go | 9 +- internal/service/dto/export.go | 49 ++-- internal/service/dto/media.go | 39 +-- internal/service/dto/menu.go | 39 +-- internal/service/dto/organization.go | 4 +- internal/service/dto/settings.go | 74 +++--- internal/service/dto/system.go | 2 +- internal/service/dto/task.go | 24 +- internal/service/dto/version.go | 22 +- internal/service/export.go | 8 +- internal/service/media.go | 9 +- internal/service/media_upload.go | 4 +- internal/service/menu.go | 8 +- internal/service/security.go | 2 +- internal/service/system_init.go | 16 +- internal/service/task.go | 2 +- internal/service/user.go | 4 +- internal/service/user_conversion.go | 2 +- internal/service/version.go | 171 +++++++++++-- internal/worker/task_executor.go | 26 +- internal/worker/task_registry.go | 32 +++ pkg/adminauth/token.go | 2 +- web/src/utils/asyncRouter.js | 16 +- web/src/view/dashboard/components/notice.vue | 2 +- web/src/view/systemTools/timedTask/index.vue | 29 ++- 105 files changed, 2535 insertions(+), 948 deletions(-) create mode 100644 internal/biz/task_registry.go rename internal/data/{gva_config_compat.go => admin_config_compat.go} (92%) create mode 100644 internal/data/casbin.go create mode 100644 internal/worker/task_registry.go diff --git a/configs/config.yaml b/configs/config.yaml index e6f15f1..04c266a 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -84,7 +84,7 @@ data: - host: "" port: "" admin: - # GVA system.router-prefix maps to this transport prefix. + # The administration system router prefix maps to this transport prefix. router_prefix: "" jwt: # Production deployments must override this value with a private secret. @@ -104,7 +104,7 @@ admin: # Upload chunks are stored below .chunks in the selected storage backend. session_ttl: 24 max_file_size: 0 - chunk_dir: .chunks + chunk_dir: uploads/chunks system: use_redis: false use_multipoint: false diff --git a/internal/biz/access_control.go b/internal/biz/access_control.go index 52b994b..fc88b06 100644 --- a/internal/biz/access_control.go +++ b/internal/biz/access_control.go @@ -12,9 +12,6 @@ func NewAccessControlUsecase(authorities AuthorityAccessRepo, apis APIRepo) *Acc } func (uc *AccessControlUsecase) Authorize(ctx context.Context, authorityID uint, path, method string) (bool, error) { - if authorityID == 888 { - return true, nil - } return uc.apis.Authorize(ctx, authorityID, path, method) } diff --git a/internal/biz/api.go b/internal/biz/api.go index 79e0d9c..f5db27c 100644 --- a/internal/biz/api.go +++ b/internal/biz/api.go @@ -2,7 +2,6 @@ package biz import ( "context" - "strings" "time" ) @@ -39,6 +38,17 @@ type APIUsecase struct{ APIRepo } func NewAPIUsecase(repo APIRepo) *APIUsecase { return &APIUsecase{APIRepo: repo} } +// DeleteAPI preserves the single-delete contract used by the legacy admin: +// the target is looked up first, so deleting a missing API returns the +// repository's not-found error instead of silently succeeding on an empty +// UPDATE/DELETE. Bulk deletion intentionally keeps separate semantics. +func (uc *APIUsecase) DeleteAPI(ctx context.Context, id uint) error { + if _, err := uc.FindAPI(ctx, id); err != nil { + return err + } + return uc.DeleteAPIs(ctx, []uint{id}) +} + type APISyncDiff struct { Added []*API Deleted []*API @@ -54,7 +64,7 @@ func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff if err != nil { return nil, err } - key := func(value *API) string { return strings.ToUpper(value.Method) + " " + value.Path } + key := func(value *API) string { return value.Method + " " + value.Path } ignoreSet := make(map[string]bool, len(ignored)) for _, item := range ignored { ignoreSet[key(item)] = true @@ -70,12 +80,19 @@ func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff storedSet[key(item)] = item } diff := &APISyncDiff{Ignored: ignored} - for routeKey, item := range routeSet { + added := make(map[string]bool) + for _, item := range routes { + routeKey := key(item) + if ignoreSet[routeKey] || added[routeKey] { + continue + } if storedSet[routeKey] == nil { diff.Added = append(diff.Added, item) + added[routeKey] = true } } - for storedKey, item := range storedSet { + for _, item := range stored { + storedKey := key(item) if routeSet[storedKey] == nil && !ignoreSet[storedKey] { diff.Deleted = append(diff.Deleted, item) } diff --git a/internal/biz/audit.go b/internal/biz/audit.go index eb8b1fb..cf2eb65 100644 --- a/internal/biz/audit.go +++ b/internal/biz/audit.go @@ -18,6 +18,7 @@ var ( type OperationRecord struct { ID uint CreatedAt time.Time + UpdatedAt time.Time IP, Method, Path string Status int LatencyMS int64 @@ -30,6 +31,7 @@ type OperationRecord struct { type LoginLog struct { ID uint CreatedAt time.Time + UpdatedAt time.Time Username, IP string Status bool ErrorMessage, Agent string @@ -41,6 +43,7 @@ type LoginLog struct { type DataAccessLog struct { ID uint CreatedAt time.Time + UpdatedAt time.Time EventType, TargetTable, Operation string UserID, AuthorityID uint Scope int @@ -79,7 +82,7 @@ func NewLogViewerUsecase(repo LogFileRepo) *LogViewerUsecase { type ErrorRecord struct { ID uint - CreatedAt time.Time + CreatedAt, UpdatedAt time.Time Form, Info, Level, RequestID, TraceID, Solution, Status string CreatedAtRange []time.Time } diff --git a/internal/biz/authentication.go b/internal/biz/authentication.go index 4a48450..b296990 100644 --- a/internal/biz/authentication.go +++ b/internal/biz/authentication.go @@ -104,7 +104,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp } uc.security.ClearLoginState(ctx, attempt.Username) - if config != nil && config.PwdExpireEnable && user.PasswordUpdatedAt != nil && time.Since(*user.PasswordUpdatedAt) > time.Duration(config.PwdExpireDays)*24*time.Hour { + if config != nil && config.PwdExpireEnable && config.PwdExpireDays > 0 && user.PasswordUpdatedAt != nil && time.Now().After((*user.PasswordUpdatedAt).AddDate(0, 0, config.PwdExpireDays)) { user.MustChangePassword = true } issued, err := uc.issuer.IssueToken(user, user.AuthorityID, user.MustChangePassword, 0) @@ -127,42 +127,45 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: user.MustChangePassword}, nil } -func (uc *AuthenticationUsecase) SwitchAuthority(ctx context.Context, userID, authorityID uint) (*AuthenticationResult, error) { - if err := uc.users.SetUserAuthority(ctx, userID, authorityID); err != nil { +// SwitchAuthority changes the current user's active role and re-signs the +// current claims. Role switching keeps the original JWT expiry; it +// does not treat a role switch as a fresh login or rotate the multipoint +// session. Keeping that distinction is important to the admin UI, which +// replaces the token returned in the response but expects its original TTL. +func (uc *AuthenticationUsecase) SwitchAuthority(ctx context.Context, claims *AuthClaims, authorityID uint) (*AuthenticationResult, error) { + if claims == nil { + return nil, ErrInvalidCredentials + } + if err := uc.users.SetUserAuthority(ctx, claims.ID, authorityID); err != nil { return nil, err } - user, err := uc.users.User(ctx, userID) + user, err := uc.users.User(ctx, claims.ID) if err != nil { return nil, err } - issued, err := uc.issuer.IssueToken(user, authorityID, user.MustChangePassword, 0) + // IssueToken receives the remaining lifetime rather than zero so the new + // token expires at the same instant as the token it replaced. + remaining := time.Until(claims.ExpiresAt) + issued, err := uc.issuer.IssueToken(user, authorityID, claims.MustChangePwd, remaining) if err != nil { return nil, err } - if uc.security.UseMultipoint() { - oldToken, cacheErr := uc.security.ActiveToken(ctx, user.Username) - if cacheErr != nil { - return nil, fmt.Errorf("%w: %v", ErrLoginState, cacheErr) - } - if cacheErr = uc.security.RotateActiveToken(ctx, user.Username, oldToken, issued.Value, issued.TTL); cacheErr != nil { - if errors.Is(cacheErr, ErrTokenRevoke) { - return nil, cacheErr - } - return nil, fmt.Errorf("%w: %v", ErrLoginState, cacheErr) - } - } - 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: claims.MustChangePwd}, nil } func (uc *AuthenticationUsecase) AuthenticateToken(ctx context.Context, token string) (*TokenAuthentication, error) { - claims, err := uc.issuer.ParseToken(token) - if err != nil { - return nil, err - } + // The blacklist is checked before parsing the JWT. Besides avoiding work, + // this makes a revoked-but-expired token report the revocation reason (and + // not the generic expiry message), which the frontend uses to decide whether + // to clear a session. disabled, err := uc.security.tokens.IsTokenDisabled(ctx, token) if err != nil || disabled { return nil, ErrTokenDisabled } + claims, err := uc.issuer.ParseToken(token) + if err != nil { + return nil, err + } result := &TokenAuthentication{Claims: claims} if claims.BufferTime <= 0 || time.Until(claims.ExpiresAt) >= claims.BufferTime { return result, nil @@ -172,7 +175,10 @@ func (uc *AuthenticationUsecase) AuthenticateToken(ctx context.Context, token st if err != nil { return result, nil } - if err = uc.security.RotateActiveToken(ctx, claims.Username, token, issued.Value, issued.TTL); err != nil { + // Refreshing updates the active Redis token but does not blacklist the old JWT; + // both tokens remain valid until their own expiry. Passing an empty old token + // preserves that behavior while still updating the multipoint session. + if err = uc.security.RotateActiveToken(ctx, claims.Username, "", issued.Value, issued.TTL); err != nil { return result, nil } result.Refreshed = issued diff --git a/internal/biz/authority.go b/internal/biz/authority.go index 2806eaf..7e143be 100644 --- a/internal/biz/authority.go +++ b/internal/biz/authority.go @@ -1,6 +1,9 @@ package biz -import "context" +import ( + "context" + "time" +) type AuthorityAccessRepo interface { CreateAuthority(context.Context, *Authority) error @@ -15,6 +18,21 @@ type AuthorityAccessRepo interface { ResolveDataScope(context.Context, uint, uint) (DataScope, error) } +// Authority is the domain representation of an administration role. Keep the +// audit timestamps because exports and API clients use them even though the +// administration screens do not display them. +type Authority struct { + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time + AuthorityID uint + AuthorityName string + ParentID *uint + DataScope int + DefaultRouter string + Children []*Authority +} + type AuthorityUsecase struct{ AuthorityAccessRepo } func NewAuthorityUsecase(repo AuthorityAccessRepo) *AuthorityUsecase { @@ -36,11 +54,11 @@ func (uc *AuthorityUsecase) AuthorityTree(ctx context.Context) ([]*Authority, er if item.ParentID != nil && *item.ParentID != 0 && byID[*item.ParentID] != nil { parent := byID[*item.ParentID] parent.Children = append(parent.Children, item) - } else if item.ParentID == nil || *item.ParentID == 0 { + } else if item.ParentID != nil && *item.ParentID == 0 { roots = append(roots, item) } } - // In GVA strict-authority mode a non-root actor receives its direct + // In strict-authority mode a non-root actor receives its direct // children as the top-level result even though their parent is omitted. if len(roots) == 0 { if actor, ok := ActorFromContext(ctx); ok { diff --git a/internal/biz/dictionary.go b/internal/biz/dictionary.go index 8ec4591..3415666 100644 --- a/internal/biz/dictionary.go +++ b/internal/biz/dictionary.go @@ -12,7 +12,7 @@ type DictionaryDetail struct { Label string Value string Extend string - Status bool + Status *bool Sort int DictionaryID uint ParentID *uint @@ -45,7 +45,7 @@ type Dictionary struct { UpdatedAt time.Time Name string Type string - Status bool + Status *bool Desc string ParentID *uint Children []*Dictionary diff --git a/internal/biz/email.go b/internal/biz/email.go index 5d394cd..e65dd95 100644 --- a/internal/biz/email.go +++ b/internal/biz/email.go @@ -32,9 +32,17 @@ func (uc *EmailUsecase) Alert(ctx context.Context, subject, body string) error { if !uc.repo.Enabled() { return nil } - recipients := uc.repo.DefaultRecipients() - if len(recipients) == 0 { - return nil + // ErrorToEmail splits the configured comma-separated recipient + // string, while its explicit test endpoint deliberately sends the whole + // value as one recipient. Keep those two contracts separate. + configured := uc.repo.DefaultRecipients() + recipients := make([]string, 0, len(configured)) + for _, value := range configured { + parts := strings.Split(value, ",") + if len(parts) > 1 && parts[len(parts)-1] == "" { + parts = parts[:len(parts)-1] + } + recipients = append(recipients, parts...) } return uc.repo.Send(ctx, recipients, subject, body) } diff --git a/internal/biz/export.go b/internal/biz/export.go index 9457b47..dddf868 100644 --- a/internal/biz/export.go +++ b/internal/biz/export.go @@ -5,8 +5,19 @@ import ( "time" ) -type ExportCondition struct{ From, Column, Operator string } -type ExportJoin struct{ Join, Table, On string } +type ExportCondition struct { + ID uint + CreatedAt, UpdatedAt time.Time + TemplateID string + From, Column string + Operator string +} +type ExportJoin struct { + ID uint + CreatedAt, UpdatedAt time.Time + TemplateID string + Join, Table, On string +} type ExportTemplate struct { ID uint CreatedAt time.Time diff --git a/internal/biz/media.go b/internal/biz/media.go index 90bfc74..0f443e9 100644 --- a/internal/biz/media.go +++ b/internal/biz/media.go @@ -48,14 +48,25 @@ func validateMediaName(name string) error { } return nil } -func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime string, categoryID int, reader io.Reader, save bool) (*MediaFile, error) { +func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedMIME string, categoryID int, reader io.Reader, save bool) (*MediaFile, error) { if err := validateMediaName(name); err != nil { return nil, err } ext := strings.ToLower(filepath.Ext(name)) buffered := bufio.NewReader(reader) - if header, _ := buffered.Peek(512); len(header) > 0 { - mime = http.DetectContentType(header) + // Keep the multipart Content-Type when the client supplied one. + // Only infer it when the header is empty: first by extension, then by + // sniffing the first 512 bytes. Overwriting a valid client MIME with + // http.DetectContentType changes the media record for common uploads + // (for example SVG/JSON), and diverges from the compatible DetectMIME helper. + if strings.TrimSpace(suppliedMIME) == "" { + if detected := mediaMIMEByExtension(ext); detected != "" { + suppliedMIME = detected + } else if header, _ := buffered.Peek(512); len(header) > 0 { + suppliedMIME = http.DetectContentType(header) + } else { + suppliedMIME = "application/octet-stream" + } } key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext hash := md5.New() @@ -63,7 +74,7 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime stri if err != nil { return nil, err } - media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, 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 err = uc.CreateMedia(ctx, media); err != nil { _ = uc.files.Delete(ctx, key) @@ -72,6 +83,30 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime stri } return media, nil } + +// mediaMIMEByExtension mirrors the small extension fallback table. It is +// intentionally narrower than mime.TypeByExtension: all other types are sniffed. +// types from the first 512 bytes. +func mediaMIMEByExtension(ext string) string { + switch ext { + case ".json": + return "application/json" + case ".xml": + return "application/xml" + case ".pdf": + return "application/pdf" + case ".zip": + return "application/zip" + case ".mp4": + return "video/mp4" + case ".mp3": + return "audio/mpeg" + case ".csv": + return "text/csv" + default: + return "" + } +} func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error { media, err := uc.FindMedia(ctx, id) if err != nil { @@ -89,5 +124,38 @@ func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error { return uc.DeleteMedia(ctx, id) } func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, limit int) ([]*StoredFile, string, bool, error) { - return uc.files.List(ctx, prefix, cursor, limit) + if limit <= 0 { + limit = 100 + } + // Multipart chunks are staged on the local filesystem, so listOssFiles + // never exposes them. Kra's storage abstraction may stage them through + // the selected backend; hide the internal prefix to preserve the API. + chunkRoot := ".chunks" + if uc.settings != nil { + if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" { + chunkRoot = configured + } + } + chunkRoot = strings.Trim(chunkRoot, "/") + "/" + result := make([]*StoredFile, 0, limit) + nextCursor := cursor + for { + items, next, more, err := uc.files.List(ctx, prefix, nextCursor, limit) + if err != nil { + return nil, "", false, err + } + for _, item := range items { + if item == nil || strings.HasPrefix(strings.TrimPrefix(item.Path, "/"), chunkRoot) { + continue + } + result = append(result, item) + if len(result) == limit { + return result, next, more, nil + } + } + if !more || next == "" || next == nextCursor { + return result, next, more, nil + } + nextCursor = next + } } diff --git a/internal/biz/media_upload.go b/internal/biz/media_upload.go index 1275ff4..7b02348 100644 --- a/internal/biz/media_upload.go +++ b/internal/biz/media_upload.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "os" "path" "path/filepath" "sort" @@ -17,7 +18,7 @@ import ( ) func (uc *MediaUsecase) chunkPrefix(uploadID uint) string { - directory := ".chunks" + directory := "uploads/chunks" if uc.settings != nil { if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" { directory = configured @@ -34,9 +35,6 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash if err := validateMediaName(name); err != nil { return nil, nil, nil, err } - if size <= 0 || chunkSize <= 0 || total <= 0 { - return nil, nil, nil, errors.New("上传参数不合法") - } if completed, err := uc.FindCompletedSession(ctx, userID, hash); err == nil && completed.MediaID != 0 { if media, findErr := uc.FindMedia(ctx, completed.MediaID); findErr == nil { copy := &MediaFile{Name: name, URL: media.URL, Tag: media.Tag, Key: media.Key} @@ -65,29 +63,50 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash } func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, index int, expected string, reader io.Reader) error { session, err := uc.FindUploadSession(ctx, uploadID) - if err != nil || session.UserID != userID { - return errors.New("上传会话不存在或无权操作") + if err != nil { + return errors.New("上传会话不存在") + } + if session.UserID != userID { + return errors.New("无权操作该上传") } if session.Status != "uploading" { return errors.New("上传会话状态不允许收片") } hash := md5.New() key := uc.chunkKey(uploadID, index) - stored, err := uc.files.Put(ctx, key, io.TeeReader(reader, hash)) + temporary, err := os.CreateTemp("", "kra-upload-chunk-*") if err != nil { return err } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + defer temporary.Close() + if _, err = io.Copy(io.MultiWriter(temporary, hash), reader); err != nil { + return err + } actual := hex.EncodeToString(hash.Sum(nil)) if actual != expected { - _ = uc.files.Delete(ctx, key) return fmt.Errorf("分片 %d 校验失败", index) } + if _, err = temporary.Seek(0, io.SeekStart); err != nil { + return err + } + stored, err := uc.files.Put(ctx, key, temporary) + if err != nil { + return err + } return uc.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: stored.Size}) } func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uint, mime string) (*MediaFile, error) { session, err := uc.FindUploadSession(ctx, uploadID) - if err != nil || session.UserID != userID { - return nil, errors.New("上传会话不存在或无权操作") + if err != nil { + return nil, errors.New("上传会话不存在") + } + if session.UserID != userID { + return nil, errors.New("无权操作该上传") + } + if err = validateMediaName(session.FileName); err != nil { + return nil, err } claimed, err := uc.ClaimUploadSession(ctx, uploadID) if err != nil { @@ -122,7 +141,10 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin _ = uc.files.Delete(ctx, key) return fail(errors.New("整文件校验失败")) } - media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, MD5: hash, UserID: userID} + // Keep the size declared when the upload session was created. The full-file + // MD5 above remains the integrity check even if storage reports another + // 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) @@ -134,8 +156,11 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin } func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) error { session, err := uc.FindUploadSession(ctx, uploadID) - if err != nil || session.UserID != userID { - return errors.New("上传会话不存在或无权操作") + if err != nil { + return errors.New("上传会话不存在") + } + if session.UserID != userID { + return errors.New("无权操作该上传") } _ = uc.DeleteChunks(ctx, uploadID) _ = uc.files.DeletePrefix(ctx, uc.chunkPrefix(uploadID)) diff --git a/internal/biz/menu.go b/internal/biz/menu.go index bdc3d00..122d1bf 100644 --- a/internal/biz/menu.go +++ b/internal/biz/menu.go @@ -1,8 +1,14 @@ package biz -import "context" +import ( + "context" + "time" +) type MenuButton struct { + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time ID uint Name string Description string @@ -11,15 +17,22 @@ type MenuButton struct { } type MenuParameter struct { - ID uint - MenuID uint - Type string - Key string - Value string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time + ID uint + MenuID uint + Type string + Key string + Value string } type Menu struct { + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time ID uint + MenuID uint ParentID uint Path string Name string @@ -73,7 +86,7 @@ func (uc *MenuUsecase) Tree(ctx context.Context) ([]*Menu, error) { for _, item := range items { if parent := byID[item.ParentID]; parent != nil { parent.Children = append(parent.Children, item) - } else { + } else if item.ParentID == 0 { roots = append(roots, item) } } @@ -118,8 +131,10 @@ func (uc *MenuUsecase) AuthorityMenus(ctx context.Context, authorityID uint) ([] if _, ok := selected[item.ID]; ok { // The authority-menu endpoint returns menu relations only. Keep its // existing contract: button details are loaded by the dedicated - // authority-button endpoints. + // authority-button endpoints. The compatible query does not preload either + // relation here, so parameters must remain absent as well. item.Buttons = nil + item.Parameters = nil result = append(result, item) } } diff --git a/internal/biz/security.go b/internal/biz/security.go index 278e831..e46f341 100644 --- a/internal/biz/security.go +++ b/internal/biz/security.go @@ -11,6 +11,8 @@ import ( type SecurityConfig struct { ID uint + CreatedAt time.Time + UpdatedAt time.Time CaptchaOpen int CaptchaTimeout int KeyLong int @@ -128,21 +130,21 @@ func (uc *SecurityUsecase) ValidateCurrentPassword(ctx context.Context, password } func (uc *SecurityUsecase) LoginLocked(ctx context.Context, username string) (bool, error) { - _, locked, err := uc.cache.Get(ctx, "login:lock:"+username) + _, locked, err := uc.cache.Get(ctx, loginLockKey(username)) return locked, err } func (uc *SecurityUsecase) IncrementLoginFailure(ctx context.Context, username string, expiration time.Duration) (int64, error) { - return uc.cache.Increment(ctx, "login:fail:"+username, expiration) + return uc.cache.Increment(ctx, loginFailureKey(username), expiration) } func (uc *SecurityUsecase) LockLogin(ctx context.Context, username string, expiration time.Duration) error { - return uc.cache.Set(ctx, "login:lock:"+username, "1", expiration) + return uc.cache.Set(ctx, loginLockKey(username), "1", expiration) } func (uc *SecurityUsecase) ClearLoginState(ctx context.Context, username string) { - _ = uc.cache.Delete(ctx, "login:fail:"+username) - _ = uc.cache.Delete(ctx, "login:lock:"+username) + _ = uc.cache.Delete(ctx, loginFailureKey(username)) + _ = uc.cache.Delete(ctx, loginLockKey(username)) } func (uc *SecurityUsecase) EnsureLoginIPCounter(ctx context.Context, ip string, expiration time.Duration) (int, error) { @@ -171,15 +173,15 @@ func (uc *SecurityUsecase) IncrementRateLimit(ctx context.Context, key string, e } func (uc *SecurityUsecase) SetCaptcha(ctx context.Context, id, value string, expiration time.Duration) error { - return uc.cache.Set(ctx, "captcha:"+id, value, expiration) + return uc.cache.Set(ctx, "CAPTCHA_"+id, value, expiration) } func (uc *SecurityUsecase) GetCaptcha(ctx context.Context, id string) (string, bool, error) { - return uc.cache.Get(ctx, "captcha:"+id) + return uc.cache.Get(ctx, "CAPTCHA_"+id) } func (uc *SecurityUsecase) DeleteCaptcha(ctx context.Context, id string) error { - return uc.cache.Delete(ctx, "captcha:"+id) + return uc.cache.Delete(ctx, "CAPTCHA_"+id) } func (uc *SecurityUsecase) VerifyCaptcha(ctx context.Context, id, answer string, clear bool) bool { @@ -196,7 +198,13 @@ func (uc *SecurityUsecase) VerifyCaptcha(ctx context.Context, id, answer string, return strings.EqualFold(value, answer) } -func activeTokenKey(username string) string { return "jwt:active:" + username } +// The currently active multipoint token is stored under the username itself. +// Keep the same key so a rolling deployment can revoke sessions issued by the +// other implementation instead of silently creating a second active session. +func activeTokenKey(username string) string { return username } + +func loginFailureKey(username string) string { return "login_fail:" + username } +func loginLockKey(username string) string { return "login_lock:" + username } func (uc *SecurityUsecase) ActiveToken(ctx context.Context, username string) (string, error) { value, _, err := uc.cache.Get(ctx, activeTokenKey(username)) diff --git a/internal/biz/system_init.go b/internal/biz/system_init.go index 62eff42..7dde125 100644 --- a/internal/biz/system_init.go +++ b/internal/biz/system_init.go @@ -43,8 +43,8 @@ func (uc *SystemConfigUsecase) IsInitialized(ctx context.Context) (bool, error) return uc.repo.IsInitialized(ctx) } func (uc *SystemConfigUsecase) Initialize(ctx context.Context, config *DatabaseConfig) error { - if config == nil || len(config.AdminPassword) < 6 { - return ErrInvalidCredentials + if config == nil { + return errors.New("数据库初始化参数无效") } if err := uc.repo.Initialize(ctx, config); err != nil { return err diff --git a/internal/biz/task.go b/internal/biz/task.go index eab02c7..59772e4 100644 --- a/internal/biz/task.go +++ b/internal/biz/task.go @@ -27,7 +27,7 @@ type TimedTask struct { } type TimedTaskLog struct { ID uint - CreatedAt time.Time + CreatedAt, UpdatedAt time.Time TaskID uint TaskName, TriggerType string StartedAt, FinishedAt time.Time @@ -49,29 +49,6 @@ type TaskScheduleError struct{ Err error } func (e *TaskScheduleError) Error() string { return e.Err.Error() } func (e *TaskScheduleError) Is(target error) bool { return target == ErrTaskSchedule } -type TaskMethod struct { - Name string - Description string -} - -var registeredTaskMethods = []TaskMethod{ - {Name: TaskMethodClearDB, Description: "清理数据库过期日志(操作记录、JWT黑名单、定时任务日志)"}, - {Name: TaskMethodUploads, Description: "清理过期大文件上传会话"}, -} - -func RegisteredTaskMethods() []TaskMethod { - return append([]TaskMethod(nil), registeredTaskMethods...) -} - -func registeredTaskMethod(name string) bool { - for _, method := range registeredTaskMethods { - if method.Name == name { - return true - } - } - return false -} - type TaskRepo interface { CreateTask(context.Context, *TimedTask) error UpdateTask(context.Context, *TimedTask) error @@ -122,7 +99,7 @@ func (uc *TaskUsecase) Validate(value *TimedTask) error { } case TaskExecutorHTTP: parsed, parseErr := url.Parse(value.HTTPURL) - if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" { + if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { return errors.New("httpUrl 必须是合法的 http/https 地址") } if len(value.HTTPHeader) > 0 { diff --git a/internal/biz/task_registry.go b/internal/biz/task_registry.go new file mode 100644 index 0000000..ac9f7c1 --- /dev/null +++ b/internal/biz/task_registry.go @@ -0,0 +1,66 @@ +package biz + +import ( + "context" + "encoding/json" + "sort" + "sync" +) + +// TaskMethodFunc is the execution contract for a registered in-process task. +// Implementations are responsible for decoding params and honoring ctx cancellation. +type TaskMethodFunc func(ctx context.Context, params json.RawMessage) error + +type TaskMethod struct { + Name string + Description string +} + +type taskMethodEntry struct { + meta TaskMethod + fn TaskMethodFunc +} + +var taskMethodRegistry = struct { + sync.RWMutex + methods map[string]taskMethodEntry +}{methods: make(map[string]taskMethodEntry)} + +// RegisterTaskMethod registers an in-process task. Re-registering the same name +// replaces the previous implementation so startup registration remains idempotent. +func RegisterTaskMethod(name, description string, fn TaskMethodFunc) { + taskMethodRegistry.Lock() + defer taskMethodRegistry.Unlock() + taskMethodRegistry.methods[name] = taskMethodEntry{ + meta: TaskMethod{Name: name, Description: description}, + fn: fn, + } +} + +// TaskMethodByName returns the registered function for name. +func TaskMethodByName(name string) (TaskMethodFunc, bool) { + taskMethodRegistry.RLock() + defer taskMethodRegistry.RUnlock() + method, ok := taskMethodRegistry.methods[name] + if !ok { + return nil, false + } + return method.fn, true +} + +// RegisteredTaskMethods returns task metadata in a stable name order. +func RegisteredTaskMethods() []TaskMethod { + taskMethodRegistry.RLock() + defer taskMethodRegistry.RUnlock() + methods := make([]TaskMethod, 0, len(taskMethodRegistry.methods)) + for _, method := range taskMethodRegistry.methods { + methods = append(methods, method.meta) + } + sort.Slice(methods, func(i, j int) bool { return methods[i].Name < methods[j].Name }) + return methods +} + +func registeredTaskMethod(name string) bool { + _, ok := TaskMethodByName(name) + return ok +} diff --git a/internal/biz/user.go b/internal/biz/user.go index cbe3ef7..3a96b0a 100644 --- a/internal/biz/user.go +++ b/internal/biz/user.go @@ -35,15 +35,6 @@ type UserListFilter struct { Desc bool } -type Authority struct { - AuthorityID uint - AuthorityName string - ParentID *uint - DataScope int - DefaultRouter string - Children []*Authority -} - type UserRepo interface { FindUserByUsername(context.Context, string) (*User, error) FindUserByID(context.Context, uint) (*User, error) @@ -125,8 +116,16 @@ func (uc *UserUsecase) CreateUser(ctx context.Context, user *User, authorityIDs return uc.repo.CreateUserWithAuthorities(ctx, user, authorityIDs) } func (uc *UserUsecase) UpdateUser(ctx context.Context, user *User, authorityIDs []uint) error { - authorityIDs = includeAuthority(authorityIDs, user.AuthorityID) - return uc.repo.UpdateUserWithAuthorities(ctx, user, authorityIDs) + // Role associations are updated first only when authorityIds was supplied, + // and then updates the basic user fields as a separate operation. Preserve + // that ordering and partial-failure behavior instead of coupling both writes + // into the data-layer transaction used by lower-level callers. + if len(authorityIDs) > 0 { + if err := uc.repo.SetUserAuthorities(ctx, user.ID, authorityIDs); err != nil { + return err + } + } + return uc.repo.UpdateUser(ctx, user) } func (uc *UserUsecase) UpdateSelfUser(ctx context.Context, user *User) error { diff --git a/internal/biz/version.go b/internal/biz/version.go index ed23d24..9b4354d 100644 --- a/internal/biz/version.go +++ b/internal/biz/version.go @@ -8,6 +8,24 @@ import ( var ErrInvalidVersion = errors.New("版本信息格式错误") +type VersionStage string + +const ( + VersionStageMenus VersionStage = "menus" + VersionStageAPIs VersionStage = "apis" + VersionStageDictionaries VersionStage = "dictionaries" + VersionStageJSON VersionStage = "json" + VersionStageSave VersionStage = "save" +) + +type VersionStageError struct { + Stage VersionStage + Err error +} + +func (e *VersionStageError) Error() string { return e.Err.Error() } +func (e *VersionStageError) Unwrap() error { return e.Err } + type Version struct { ID uint CreatedAt time.Time diff --git a/internal/data/gva_config_compat.go b/internal/data/admin_config_compat.go similarity index 92% rename from internal/data/gva_config_compat.go rename to internal/data/admin_config_compat.go index bf8ebf3..36db040 100644 --- a/internal/data/gva_config_compat.go +++ b/internal/data/admin_config_compat.go @@ -11,7 +11,7 @@ import ( "google.golang.org/protobuf/types/known/durationpb" ) -func gvaDatabase(value *conf.Data_Database) map[string]any { +func compatibleDatabase(value *conf.Data_Database) map[string]any { if value == nil { return map[string]any{} } @@ -28,14 +28,14 @@ func gvaDatabase(value *conf.Data_Database) map[string]any { } } -func gvaRedis(value *conf.Data_Redis) map[string]any { +func compatibleRedis(value *conf.Data_Redis) map[string]any { if value == nil { return map[string]any{} } return map[string]any{"name": value.Name, "addr": value.Addr, "password": value.Password, "db": value.Db, "useCluster": value.UseCluster, "clusterAddrs": value.ClusterAddrs} } -func gvaObjectStore(value *conf.AdminBackend_ObjectStore) map[string]any { +func compatibleObjectStore(value *conf.AdminBackend_ObjectStore) map[string]any { if value == nil { return map[string]any{} } @@ -47,7 +47,7 @@ func gvaObjectStore(value *conf.AdminBackend_ObjectStore) map[string]any { } } -func gvaObjectStores(storage *conf.AdminBackend_Storage) map[string]map[string]any { +func compatibleObjectStores(storage *conf.AdminBackend_Storage) map[string]map[string]any { if storage == nil { storage = &conf.AdminBackend_Storage{} } @@ -82,7 +82,7 @@ func gvaObjectStores(storage *conf.AdminBackend_Storage) map[string]map[string]a } } -func gvaConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any { +func compatibleConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any { result := map[string]any{} if data == nil { data = &conf.Data{} @@ -107,10 +107,10 @@ func gvaConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any "access-req-headers": zap.AccessReqHeaders, "access-log-max-bytes": zap.AccessLogMaxBytes, "file-only-modules": zap.FileOnlyModules, } - result["redis"] = gvaRedis(data.Redis) + result["redis"] = compatibleRedis(data.Redis) redisList := make([]map[string]any, 0, len(data.RedisList)) for _, item := range data.RedisList { - redisList = append(redisList, gvaRedis(item)) + redisList = append(redisList, compatibleRedis(item)) } result["redis-list"] = redisList mongo := data.Mongo @@ -146,11 +146,11 @@ func gvaConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any result[name] = map[string]any{} } if driver != "" { - result[driver] = gvaDatabase(data.Database) + result[driver] = compatibleDatabase(data.Database) } dbList := make([]map[string]any, 0, len(data.DatabaseList)) for _, item := range data.DatabaseList { - row := gvaDatabase(item) + row := compatibleDatabase(item) if item != nil { row["type"], row["alias-name"], row["disable"] = item.Driver, item.AliasName, item.Disable } @@ -166,7 +166,11 @@ func gvaConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any if media == nil { media = &conf.AdminBackend_Media{} } - result["media"] = map[string]any{"session-ttl": media.SessionTtl, "max-file-size": media.MaxFileSize, "chunk-dir": media.ChunkDir} + chunkDir := media.ChunkDir + if chunkDir == "" { + chunkDir = "uploads/chunks" + } + result["media"] = map[string]any{"session-ttl": media.SessionTtl, "max-file-size": media.MaxFileSize, "chunk-dir": chunkDir} if admin.Storage == nil { admin.Storage = &conf.AdminBackend_Storage{} } @@ -175,7 +179,7 @@ func gvaConfiguration(data *conf.Data, admin *conf.AdminBackend) map[string]any qiniu = &conf.AdminBackend_Qiniu{} } result["qiniu"] = map[string]any{"zone": qiniu.Zone, "bucket": qiniu.Bucket, "img-path": qiniu.BaseUrl, "access-key": qiniu.AccessKey, "secret-key": qiniu.SecretKey, "use-https": qiniu.UseHttps, "use-cdn-domains": qiniu.UseCdnDomains} - for key, value := range gvaObjectStores(admin.Storage) { + for key, value := range compatibleObjectStores(admin.Storage) { result[key] = value } disks := make([]map[string]any, 0, len(admin.DiskList)) @@ -240,7 +244,7 @@ func stringSliceValue(values map[string]any, key string, fallback []string) []st return result } -func applyGVARedis(target *conf.Data_Redis, values map[string]any) { +func applyCompatibleRedis(target *conf.Data_Redis, values map[string]any) { if target == nil || values == nil { return } @@ -257,14 +261,14 @@ func durationValue(raw string, fallback *durationpb.Duration) *durationpb.Durati if raw == "" { return fallback } - value, err := parseGVADuration(raw) + value, err := parseCompatibleDuration(raw) if err != nil { return fallback } return durationpb.New(value) } -func parseGVADuration(raw string) (time.Duration, error) { +func parseCompatibleDuration(raw string) (time.Duration, error) { raw = strings.TrimSpace(raw) if value, err := time.ParseDuration(raw); err == nil { return value, nil @@ -282,7 +286,7 @@ func parseGVADuration(raw string) (time.Duration, error) { return time.Duration(nanoseconds), err } -func applyGVADatabase(target *conf.Data_Database, values map[string]any, driver string) { +func applyCompatibleDatabase(target *conf.Data_Database, values map[string]any, driver string) { if target == nil || values == nil { return } @@ -304,7 +308,7 @@ func applyGVADatabase(target *conf.Data_Database, values map[string]any, driver target.Singular = boolValue(values, "singular", target.Singular) } -func applyGVAObjectStore(target *conf.AdminBackend_ObjectStore, values map[string]any) { +func applyCompatibleObjectStore(target *conf.AdminBackend_ObjectStore, values map[string]any) { if target == nil || values == nil { return } @@ -345,12 +349,12 @@ func applyGVAObjectStore(target *conf.AdminBackend_ObjectStore, values map[strin target.AccountId = stringValue(values, "account-id") } -func applyGVAConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.AdminBackend) error { +func applyCompatibleConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.AdminBackend) error { var values map[string]any if err := json.Unmarshal(raw, &values); err != nil { return err } - if _, isGVA := values["system"]; !isGVA { + if _, compatibleShape := values["system"]; !compatibleShape { return nil } if data.Database == nil { @@ -367,7 +371,7 @@ func applyGVAConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.Adm if driver == "" { driver = data.Database.Driver } - applyGVADatabase(data.Database, objectMap(values[driver]), driver) + applyCompatibleDatabase(data.Database, objectMap(values[driver]), driver) admin.Storage.Type = stringValue(system, "oss-type") admin.RouterPrefix = stringValue(system, "router-prefix") admin.System.UseMultipoint = boolValue(system, "use-multipoint", admin.System.UseMultipoint) @@ -411,7 +415,7 @@ func applyGVAConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.Adm if data.Redis == nil { data.Redis = &conf.Data_Redis{} } - applyGVARedis(data.Redis, objectMap(values["redis"])) + applyCompatibleRedis(data.Redis, objectMap(values["redis"])) if items, ok := values["redis-list"].([]any); ok { current := make(map[string]*conf.Data_Redis, len(data.RedisList)) for _, item := range data.RedisList { @@ -426,7 +430,7 @@ func applyGVAConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.Adm if target == nil { target = &conf.Data_Redis{} } - applyGVARedis(target, values) + applyCompatibleRedis(target, values) data.RedisList = append(data.RedisList, target) } } @@ -467,7 +471,7 @@ func applyGVAConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.Adm if target == nil { target = &conf.Data_Database{} } - applyGVADatabase(target, row, stringValue(row, "type")) + applyCompatibleDatabase(target, row, stringValue(row, "type")) target.AliasName, target.Disable = alias, boolValue(row, "disable", target.Disable) data.DatabaseList = append(data.DatabaseList, target) } @@ -513,7 +517,7 @@ func applyGVAConfiguration(raw json.RawMessage, data *conf.Data, admin *conf.Adm if *store.target == nil { *store.target = &conf.AdminBackend_ObjectStore{} } - applyGVAObjectStore(*store.target, objectMap(values[store.key])) + applyCompatibleObjectStore(*store.target, objectMap(values[store.key])) } if disks, ok := values["disk-list"].([]any); ok { admin.DiskList = make([]*conf.AdminBackend_Disk, 0, len(disks)) diff --git a/internal/data/announcement.go b/internal/data/announcement.go index 315dc49..cbb35a0 100644 --- a/internal/data/announcement.go +++ b/internal/data/announcement.go @@ -3,7 +3,6 @@ package data import ( "context" "encoding/json" - "errors" "time" "kra/internal/biz" @@ -94,10 +93,9 @@ func (r *announcementRepo) UserOptions(ctx context.Context) ([]biz.UserOption, e Label string Value uint } - err := r.data.gormDB.WithContext(ctx).Table("sys_users").Select("nick_name AS label, id AS value").Scan(&rows).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } + // The generated data-source endpoint is best effort: return collected + // options even when the underlying scan reports an error. + _ = r.data.gormDB.WithContext(ctx).Table("sys_users").Select("nick_name AS label, id AS value").Scan(&rows).Error items := make([]biz.UserOption, 0, len(rows)) for _, row := range rows { items = append(items, biz.UserOption{Label: row.Label, Value: row.Value}) diff --git a/internal/data/api.go b/internal/data/api.go index 50db0c0..05e0067 100644 --- a/internal/data/api.go +++ b/internal/data/api.go @@ -4,7 +4,6 @@ import ( "context" "errors" "strconv" - "strings" "time" "kra/internal/biz" @@ -32,8 +31,12 @@ type apiPO struct { func (apiPO) TableName() string { return "sys_apis" } type ignoredAPIPO struct { - Path string `gorm:"primaryKey"` - Method string `gorm:"primaryKey"` + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Path string + Method string `gorm:"default:POST"` } func (ignoredAPIPO) TableName() string { return "sys_ignore_apis" } @@ -49,7 +52,7 @@ func apiFromPO(po apiPO) *biz.API { return &biz.API{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Path: po.Path, Description: po.Description, APIGroup: po.APIGroup, Method: po.Method} } func (r *apiRepo) CreateAPI(ctx context.Context, v *biz.API) error { - po := apiPO{Path: v.Path, Description: v.Description, APIGroup: v.APIGroup, Method: strings.ToUpper(v.Method)} + po := apiPO{Path: v.Path, Description: v.Description, APIGroup: v.APIGroup, Method: v.Method} var count int64 if err := r.data.gormDB.WithContext(ctx).Model(&apiPO{}).Where("path = ? AND method = ?", po.Path, po.Method).Count(&count).Error; err != nil { return err @@ -69,7 +72,7 @@ func (r *apiRepo) UpdateAPI(ctx context.Context, v *biz.API) error { if err := db.First(&old, v.ID).Error; err != nil { return err } - method := strings.ToUpper(v.Method) + method := v.Method if old.Path != v.Path || old.Method != method { var count int64 if err := db.Model(&apiPO{}).Where("id <> ? AND path = ? AND method = ?", v.ID, v.Path, method).Count(&count).Error; err != nil { @@ -79,10 +82,28 @@ func (r *apiRepo) UpdateAPI(ctx context.Context, v *biz.API) error { return errors.New("存在相同api路径") } } + // Authorization is stored independently in casbin_rule and updates every + // matching policy when an API path or method changes. + if err := db.Model(&casbinRulePO{}). + Where("ptype = ? AND v1 = ? AND v2 = ?", "p", old.Path, old.Method). + Updates(map[string]any{"v1": v.Path, "v2": method}).Error; err != nil { + return err + } return db.Model(&old).Updates(map[string]any{"path": v.Path, "description": v.Description, "api_group": v.APIGroup, "method": method}).Error } func (r *apiRepo) DeleteAPIs(ctx context.Context, ids []uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var apis []apiPO + if err := tx.Where("id IN ?", ids).Find(&apis).Error; err != nil { + return err + } + for _, api := range apis { + if err := deletePoliciesForPath(tx, api.Path, api.Method); err != nil { + return err + } + } + // Keep the legacy relation table clean for upgraded installations. It is + // no longer the authorization source. if err := tx.Where("api_id IN ?", ids).Delete(&authorityAPIPO{}).Error; err != nil { return err } @@ -106,7 +127,13 @@ func (r *apiRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]* return nil, 0, err } if authority.ParentID != nil && *authority.ParentID != 0 { - db = db.Where("id IN (?)", r.data.gormDB.WithContext(ctx).Model(&authorityAPIPO{}).Select("api_id").Where("authority_id = ?", actor.AuthorityID)) + // Strict mode filters API metadata by the actor's persisted + // Casbin policies, matching on path/method rather than an API FK. + db = db.Where(`EXISTS ( + SELECT 1 FROM casbin_rule cr + WHERE cr.ptype = 'p' AND cr.v0 = ? + AND cr.v1 = sys_apis.path AND cr.v2 = sys_apis.method + )`, strconv.FormatUint(uint64(actor.AuthorityID), 10)) } } } @@ -121,7 +148,7 @@ func (r *apiRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]* db = db.Where("api_group = ?", q.APIGroup) } if q.Method != "" { - db = db.Where("method = ?", strings.ToUpper(q.Method)) + db = db.Where("method = ?", q.Method) } } var total int64 @@ -154,34 +181,42 @@ func (r *apiRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]* return out, total, nil } func (r *apiRepo) APIRoleIDs(ctx context.Context, path, method string) ([]uint, error) { - var ids []uint - err := r.data.gormDB.WithContext(ctx).Table("sys_authority_apis"). - Joins("JOIN sys_apis ON sys_apis.id = sys_authority_apis.api_id"). - Where("sys_apis.path = ? AND sys_apis.method = ? AND sys_apis.deleted_at IS NULL", path, strings.ToUpper(method)). - Pluck("sys_authority_apis.authority_id", &ids).Error - return ids, err + rows := make([]casbinRulePO, 0) + err := policyScope(r.data.gormDB.WithContext(ctx)). + Where("v1 = ? AND v2 = ?", path, method). + Find(&rows).Error + if err != nil { + return nil, err + } + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + id, err := strconv.ParseUint(row.V0, 10, 64) + if err != nil { + continue + } + ids = append(ids, uint(id)) + } + return ids, nil } func (r *apiRepo) SetAPIRoles(ctx context.Context, path, method string, ids []uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var api apiPO - if err := tx.Where("path = ? AND method = ?", path, strings.ToUpper(method)).First(&api).Error; err != nil { + // A path/method need not be present in sys_apis when replacing + // all matching policy rows. Do not validate or deduplicate authority IDs. + if err := deletePoliciesForPath(tx, path, method); err != nil { return err } - if err := tx.Where("api_id = ?", api.ID).Delete(&authorityAPIPO{}).Error; err != nil { - return err + if len(ids) == 0 { + return nil } - links := make([]authorityAPIPO, 0, len(ids)) + rules := make([]casbinRulePO, 0, len(ids)) for _, aid := range ids { - links = append(links, authorityAPIPO{AuthorityID: aid, APIID: api.ID}) + rules = append(rules, newPolicyRule(aid, path, method)) } - if len(links) > 0 { - return tx.Create(&links).Error - } - return nil + return tx.Create(&rules).Error }) } func (r *apiRepo) Authorize(ctx context.Context, aid uint, path, method string) (bool, error) { - policies, err := r.PolicyPaths(ctx, aid) + rows, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), aid) if err != nil { return false, err } @@ -192,7 +227,7 @@ p = sub, obj, act [policy_effect] e = some(where (p.eft == allow)) [matchers] -m = r.sub == p.sub && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act)`) +m = r.sub == p.sub && keyMatch2(r.obj, p.obj) && r.act == p.act`) if err != nil { return false, err } @@ -201,22 +236,21 @@ m = r.sub == p.sub && keyMatch2(r.obj, p.obj) && regexMatch(r.act, p.act)`) return false, err } subject := strconv.FormatUint(uint64(aid), 10) - for _, policy := range policies { - if _, err := enforcer.AddPolicy(subject, policy.Path, strings.ToUpper(policy.Method)); err != nil { + for _, policy := range rows { + if _, err := enforcer.AddPolicy(subject, policy.V1, policy.V2); err != nil { return false, err } } - return enforcer.Enforce(subject, path, strings.ToUpper(method)) + return enforcer.Enforce(subject, path, method) } func (r *apiRepo) PolicyPaths(ctx context.Context, aid uint) ([]*biz.API, error) { - var pos []apiPO - err := r.data.gormDB.WithContext(ctx).Table("sys_apis").Joins("JOIN sys_authority_apis ON sys_authority_apis.api_id = sys_apis.id").Where("sys_authority_apis.authority_id = ? AND sys_apis.deleted_at IS NULL", aid).Order("sys_apis.path,sys_apis.method").Find(&pos).Error + rows, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), aid) if err != nil { return nil, err } - out := make([]*biz.API, 0, len(pos)) - for _, po := range pos { - out = append(out, apiFromPO(po)) + out := make([]*biz.API, 0, len(rows)) + for _, row := range rows { + out = append(out, &biz.API{Path: row.V1, Method: row.V2}) } return out, nil } @@ -237,35 +271,36 @@ func (r *apiRepo) SetPolicyPaths(ctx context.Context, aid uint, paths []*biz.API } allowedSet := make(map[string]bool, len(allowed)) for _, item := range allowed { - allowedSet[item.Path+"\x00"+strings.ToUpper(item.Method)] = true + allowedSet[item.Path+"\x00"+item.Method] = true } for _, item := range paths { - if !allowedSet[item.Path+"\x00"+strings.ToUpper(item.Method)] { + if !allowedSet[item.Path+"\x00"+item.Method] { return errors.New("存在api不在权限列表中") } } } } return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := deletePoliciesForAuthority(tx, aid); err != nil { + return err + } + // Keep the legacy relation table clean for upgraded installations. It + // is not consulted for authorization anymore. if err := tx.Where("authority_id = ?", aid).Delete(&authorityAPIPO{}).Error; err != nil { return err } - links := make([]authorityAPIPO, 0, len(paths)) + rules := make([]casbinRulePO, 0, len(paths)) + seen := make(map[string]struct{}, len(paths)) for _, path := range paths { - var po apiPO - err := tx.Where("path = ? AND method = ?", path.Path, strings.ToUpper(path.Method)).First(&po).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - po = apiPO{Path: path.Path, Method: strings.ToUpper(path.Method), Description: path.Description, APIGroup: path.APIGroup} - if err = tx.Create(&po).Error; err != nil { - return err - } - } else if err != nil { - return err + key := path.Path + "\x00" + path.Method + if _, ok := seen[key]; ok { + continue } - links = append(links, authorityAPIPO{AuthorityID: aid, APIID: po.ID}) + seen[key] = struct{}{} + rules = append(rules, newPolicyRule(aid, path.Path, path.Method)) } - if len(links) > 0 { - return tx.Create(&links).Error + if len(rules) > 0 { + return tx.Create(&rules).Error } return nil }) @@ -273,7 +308,7 @@ func (r *apiRepo) SetPolicyPaths(ctx context.Context, aid uint, paths []*biz.API func (r *apiRepo) IgnoredAPIs(ctx context.Context) ([]*biz.API, error) { var pos []ignoredAPIPO - if err := r.data.gormDB.WithContext(ctx).Order("path,method").Find(&pos).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil { return nil, err } out := make([]*biz.API, 0, len(pos)) @@ -283,26 +318,37 @@ func (r *apiRepo) IgnoredAPIs(ctx context.Context) ([]*biz.API, error) { return out, nil } func (r *apiRepo) SetAPIIgnored(ctx context.Context, path, method string, ignored bool) error { - po := ignoredAPIPO{Path: path, Method: strings.ToUpper(method)} + po := ignoredAPIPO{Path: path, Method: method} if ignored { - return r.data.gormDB.WithContext(ctx).FirstOrCreate(&po, po).Error + // The compatible endpoint creates an ignore row on every request (the table has no + // path/method uniqueness constraint); retain that observable behavior. + return r.data.gormDB.WithContext(ctx).Create(&po).Error } - return r.data.gormDB.WithContext(ctx).Where("path = ? AND method = ?", po.Path, po.Method).Delete(&ignoredAPIPO{}).Error + return r.data.gormDB.WithContext(ctx).Unscoped().Where("path = ? AND method = ?", po.Path, po.Method).Delete(&ignoredAPIPO{}).Error } func (r *apiRepo) ApplyAPISync(ctx context.Context, added, deleted []*biz.API) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - for _, item := range added { - po := apiPO{Path: item.Path, Method: strings.ToUpper(item.Method), Description: item.Description, APIGroup: item.APIGroup} - if err := tx.Where("path = ? AND method = ?", po.Path, po.Method).FirstOrCreate(&po).Error; err != nil { + if len(added) > 0 { + pos := make([]apiPO, 0, len(added)) + for _, item := range added { + pos = append(pos, apiPO{Path: item.Path, Method: item.Method, Description: item.Description, APIGroup: item.APIGroup}) + } + // Insert the submitted newApis slice as-is. Do not silently + // collapse repeated/existing path+method pairs here; sys_apis itself + // intentionally has no uniqueness constraint. + if err := tx.Create(&pos).Error; err != nil { return err } } for _, item := range deleted { var ids []uint - if err := tx.Model(&apiPO{}).Where("path = ? AND method = ?", item.Path, strings.ToUpper(item.Method)).Pluck("id", &ids).Error; err != nil { + if err := tx.Model(&apiPO{}).Where("path = ? AND method = ?", item.Path, item.Method).Pluck("id", &ids).Error; err != nil { return err } if len(ids) > 0 { + if err := deletePoliciesForPath(tx, item.Path, item.Method); err != nil { + return err + } if err := tx.Where("api_id IN ?", ids).Delete(&authorityAPIPO{}).Error; err != nil { return err } diff --git a/internal/data/api_token.go b/internal/data/api_token.go index c22a4d5..696de04 100644 --- a/internal/data/api_token.go +++ b/internal/data/api_token.go @@ -42,10 +42,16 @@ func (jwtBlacklistPO) TableName() string { return "jwt_blacklists" } func (r *apiTokenRepo) UserHasAuthority(ctx context.Context, userID, authorityID uint) (*biz.User, bool, error) { var po userPO if err := r.data.gormDB.WithContext(ctx).First(&po, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, errors.New("用户不存在") + } return nil, false, err } var count int64 - err := r.data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_user_id = ? AND sys_authority_authority_id = ?", userID, authorityID).Count(&count).Error + err := r.data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}). + Joins("JOIN sys_authorities ON sys_authorities.authority_id = sys_user_authority.sys_authority_authority_id"). + Where("sys_user_authority.sys_user_id = ? AND sys_user_authority.sys_authority_authority_id = ? AND sys_authorities.deleted_at IS NULL", userID, authorityID). + Count(&count).Error if err != nil { return nil, false, err } @@ -106,22 +112,20 @@ func (r *apiTokenRepo) DisableAPIToken(ctx context.Context, id uint) (string, er return po.Token, r.data.gormDB.WithContext(ctx).Model(&po).Update("status", false).Error } func (r *apiTokenRepo) DisableAndBlacklistAPIToken(ctx context.Context, id uint) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var po apiTokenPO - if err := tx.First(&po, id).Error; err != nil { - return err - } - // Blacklist the token before disabling it while making both writes atomic. - if err := tx.Create(&jwtBlacklistPO{JWT: po.Token}).Error; err != nil { - return err - } - return tx.Model(&po).Update("status", false).Error - }) + // Persist the blacklist row first, then mark the + // API-token record disabled. The two writes are intentionally separate in + // the reference implementation, so a storage error has the same observable + // result here. + var po apiTokenPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return err + } + if err := r.BlacklistToken(ctx, po.Token); err != nil { + return err + } + return r.data.gormDB.WithContext(ctx).Model(&po).Update("status", false).Error } func (r *apiTokenRepo) BlacklistToken(ctx context.Context, token string) error { - if token == "" { - return errors.New("token is empty") - } return r.data.gormDB.WithContext(ctx).Create(&jwtBlacklistPO{JWT: token}).Error } func (r *apiTokenRepo) IsTokenDisabled(ctx context.Context, token string) (bool, error) { @@ -129,16 +133,10 @@ func (r *apiTokenRepo) IsTokenDisabled(ctx context.Context, token string) (bool, if err := r.data.gormDB.WithContext(ctx).Model(&jwtBlacklistPO{}).Where("jwt = ?", token).Count(&blacklistCount).Error; err != nil { return false, err } - if blacklistCount > 0 { - return true, nil - } - var po apiTokenPO - result := r.data.gormDB.WithContext(ctx).Where("token = ?", token).Limit(1).Find(&po) - if result.Error != nil { - return false, result.Error - } - if result.RowsAffected == 0 { - return false, nil - } - return !po.Status || time.Now().After(po.ExpiresAt), nil + // The JWT middleware consults only jwt_blacklists and lets JWT parsing + // report expiry. API-token deletion writes this row before changing the + // token status, so consulting status/ExpiresAt here would produce a + // different error message and would make manually-disabled tokens behave + // unlike tokens issued through the regular login flow. + return blacklistCount > 0, nil } diff --git a/internal/data/authority.go b/internal/data/authority.go index 23dd3da..96332f1 100644 --- a/internal/data/authority.go +++ b/internal/data/authority.go @@ -3,8 +3,6 @@ package data import ( "context" "errors" - "strconv" - "strings" "kra/internal/biz" @@ -89,9 +87,11 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au if value.DefaultRouter == "" { value.DefaultRouter = "dashboard" } - if err := tx.Create(&authorityPO{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}).Error; err != nil { + po := authorityPO{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} + if err := tx.Create(&po).Error; err != nil { return err } + value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt var dashboard menuPO if err := tx.Where("name = ?", "dashboard").First(&dashboard).Error; err == nil { if err = tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: value.AuthorityID, SysBaseMenuID: dashboard.ID}).Error; err != nil { @@ -99,13 +99,12 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au } } defaults := []struct{ path, method string }{{"/menu/getMenu", "POST"}, {"/jwt/jsonInBlacklist", "POST"}, {"/base/login", "POST"}, {"/user/changePassword", "POST"}, {"/user/setUserAuthority", "POST"}, {"/user/getUserInfo", "GET"}, {"/user/setSelfInfo", "PUT"}, {"/fileUploadAndDownload/upload", "POST"}, {"/sysDictionary/findSysDictionary", "GET"}} + rules := make([]casbinRulePO, 0, len(defaults)) for _, item := range defaults { - var api apiPO - if err := tx.Where("path = ? AND method = ?", item.path, item.method).First(&api).Error; err == nil { - if err = tx.Create(&authorityAPIPO{AuthorityID: value.AuthorityID, APIID: api.ID}).Error; err != nil { - return err - } - } + rules = append(rules, newPolicyRule(value.AuthorityID, item.path, item.method)) + } + if err := tx.Create(&rules).Error; err != nil { + return err } return nil }) @@ -114,13 +113,18 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { config := r.data.runtime.Admin() actor, hasActor := biz.ActorFromContext(ctx) - strict := hasActor && actor.AuthorityID != 888 && config != nil && config.System != nil && config.System.UseStrictAuth + // Strict-authority checks apply to every actor. A root actor is + // allowed to create/copy a role directly below itself; non-root actors + // may target themselves' descendants. Do not special-case 888 here: + // the configured strict-auth switch is the source of truth. + strict := hasActor && config != nil && config.System != nil && config.System.UseStrictAuth if strict { allowed, err := r.strictAuthorityIDs(ctx, actor.AuthorityID) if err != nil { return err } - if value.ParentID == nil || !allowed[*value.ParentID] { + parentAllowed := value.ParentID != nil && (allowed[*value.ParentID] || *value.ParentID == actor.AuthorityID) + if !parentAllowed { return errors.New("严格角色模式下只能在自己的角色树中复制角色") } } @@ -131,9 +135,11 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, if count != 0 { return errors.New("存在相同角色id") } - if err := tx.Create(&authorityPO{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}).Error; err != nil { + po := authorityPO{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} + if err := tx.Create(&po).Error; err != nil { return err } + value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt copyLinks := func(table string, destination any, columns map[string]any) error { return tx.Table(table).Where(columns).Find(destination).Error } @@ -149,24 +155,31 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, return err } } - var apis []authorityAPIPO - if err := copyLinks("sys_authority_apis", &apis, map[string]any{"authority_id": sourceID}); err != nil { + apis, err := policyRowsForAuthority(tx, sourceID) + if err != nil { return err } - for i := range apis { + copiedRules := make([]casbinRulePO, 0, len(apis)) + seenPolicies := make(map[string]struct{}, len(apis)) + for _, api := range apis { if strict { - var allowedCount int64 - if err := tx.Model(&authorityAPIPO{}).Where("authority_id = ? AND api_id = ?", actor.AuthorityID, apis[i].APIID).Count(&allowedCount).Error; err != nil { + allowed, err := policyExists(tx, actor.AuthorityID, api.V1, api.V2) + if err != nil { return err } - if allowedCount == 0 { + if !allowed { return errors.New("严格角色模式下不能复制当前角色未拥有的 API") } } - apis[i].AuthorityID = value.AuthorityID + key := api.V1 + "\x00" + api.V2 + if _, ok := seenPolicies[key]; ok { + continue + } + seenPolicies[key] = struct{}{} + copiedRules = append(copiedRules, newPolicyRule(value.AuthorityID, api.V1, api.V2)) } - if len(apis) > 0 { - if err := tx.Create(&apis).Error; err != nil { + if len(copiedRules) > 0 { + if err := tx.Create(&copiedRules).Error; err != nil { return err } } @@ -225,13 +238,18 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { return err } + if err := deletePoliciesForAuthority(tx, id); err != nil { + return err + } if err := tx.Where("authority_id = ?", id).Delete(&authorityAPIPO{}).Error; err != nil { return err } if err := tx.Where("authority_id = ?", id).Delete(&authorityButtonPO{}).Error; err != nil { return err } - return tx.Delete(&authorityPO{}, "authority_id = ?", id).Error + // The compatible delete removes the role row permanently with Unscoped().Delete, + // so reusing an authority ID after deletion behaves the same way. + return tx.Unscoped().Delete(&authorityPO{}, "authority_id = ?", id).Error }) } func (r *authorityAccessRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) { @@ -359,19 +377,35 @@ func (r *authorityAccessRepo) ResolveDataScope(ctx context.Context, authorityID, ids = append(ids, id) } if identity.Scope == 2 && len(ids) > 0 { + // Match the administration subtree-union semantics: derive the visible set from + // parent_id edges at read time, rather than trusting the denormalized + // ancestors string (which may be stale after a department move). var departments []departmentPO - _ = r.data.gormDB.WithContext(ctx).Find(&departments).Error + _ = r.data.gormDB.WithContext(ctx).Select("id", "parent_id").Find(&departments).Error + children := make(map[uint][]uint, len(departments)) for _, department := range departments { - for _, part := range strings.Split(department.Ancestors, ",") { - value, _ := strconv.ParseUint(part, 10, 64) - if selected[uint(value)] { - selected[department.ID] = true - break + children[department.ParentID] = append(children[department.ParentID], department.ID) + } + queue := make([]uint, 0, len(selected)) + for id := range selected { + queue = append(queue, id) + } + visited := make(map[uint]struct{}, len(selected)) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if _, ok := visited[current]; ok { + continue + } + visited[current] = struct{}{} + for _, child := range children[current] { + if _, ok := visited[child]; !ok { + queue = append(queue, child) } } } ids = ids[:0] - for id := range selected { + for id := range visited { ids = append(ids, id) } } else if identity.Scope == 5 { diff --git a/internal/data/casbin.go b/internal/data/casbin.go new file mode 100644 index 0000000..ab02c65 --- /dev/null +++ b/internal/data/casbin.go @@ -0,0 +1,60 @@ +package data + +import ( + "strconv" + + "gorm.io/gorm" +) + +// casbinRulePO mirrors the gorm-adapter CasbinRule storage shape. API +// metadata (sys_apis) and authorization policy are intentionally independent: +// a policy may refer to a route that has not been registered in sys_apis yet. +// Keep this type private so the storage representation does not leak above +// the data layer. +type casbinRulePO struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Ptype string `gorm:"size:100"` + V0 string `gorm:"size:100"` + V1 string `gorm:"size:100"` + V2 string `gorm:"size:100"` + V3 string `gorm:"size:100"` + V4 string `gorm:"size:100"` + V5 string `gorm:"size:100"` +} + +func (casbinRulePO) TableName() string { return "casbin_rule" } + +func newPolicyRule(authorityID uint, path, method string) casbinRulePO { + return casbinRulePO{ + Ptype: "p", + V0: strconv.FormatUint(uint64(authorityID), 10), + V1: path, + V2: method, + } +} + +func policyScope(db *gorm.DB) *gorm.DB { + return db.Where("ptype = ?", "p") +} + +func deletePoliciesForAuthority(db *gorm.DB, authorityID uint) error { + return policyScope(db).Where("v0 = ?", strconv.FormatUint(uint64(authorityID), 10)).Delete(&casbinRulePO{}).Error +} + +func deletePoliciesForPath(db *gorm.DB, path, method string) error { + return policyScope(db).Where("v1 = ? AND v2 = ?", path, method).Delete(&casbinRulePO{}).Error +} + +func policyRowsForAuthority(db *gorm.DB, authorityID uint) ([]casbinRulePO, error) { + var rows []casbinRulePO + err := policyScope(db).Where("v0 = ?", strconv.FormatUint(uint64(authorityID), 10)).Find(&rows).Error + return rows, err +} + +func policyExists(db *gorm.DB, authorityID uint, path, method string) (bool, error) { + var count int64 + err := policyScope(db). + Where("v0 = ? AND v1 = ? AND v2 = ?", strconv.FormatUint(uint64(authorityID), 10), path, method). + Count(&count).Error + return count > 0, err +} diff --git a/internal/data/config_management.go b/internal/data/config_management.go index d59aa7c..89f645c 100644 --- a/internal/data/config_management.go +++ b/internal/data/config_management.go @@ -3,6 +3,7 @@ package data import ( "context" "encoding/json" + "strings" "kra/internal/conf" @@ -33,7 +34,16 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { if adminConfig != nil { admin["routerPrefix"] = adminConfig.RouterPrefix if adminConfig.System != nil { - admin["system"] = map[string]any{"useRedis": adminConfig.System.UseRedis, "useMultipoint": adminConfig.System.UseMultipoint, "useStrictAuth": adminConfig.System.UseStrictAuth, "disableAutoMigrate": adminConfig.System.DisableAutoMigrate, "useMongo": adminConfig.System.UseMongo} + admin["system"] = map[string]any{ + "useRedis": adminConfig.System.UseRedis, + "useMultipoint": adminConfig.System.UseMultipoint, + "useStrictAuth": adminConfig.System.UseStrictAuth, + "disableAutoMigrate": adminConfig.System.DisableAutoMigrate, + "useMongo": adminConfig.System.UseMongo, + "addr": adminConfig.System.Addr, + "iplimitCount": adminConfig.System.IplimitCount, + "iplimitTime": adminConfig.System.IplimitTime, + } } if adminConfig.Jwt != nil { admin["jwt"] = map[string]any{"signingKey": "******", "expiresTime": durationString(adminConfig.Jwt.ExpiresTime), "bufferTime": durationString(adminConfig.Jwt.BufferTime), "issuer": adminConfig.Jwt.Issuer} @@ -45,7 +55,11 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { admin["local"] = map[string]any{"storePath": adminConfig.Local.StorePath, "pathPrefix": adminConfig.Local.PathPrefix} } if adminConfig.Media != nil { - admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize} + chunkDir := adminConfig.Media.ChunkDir + if chunkDir == "" { + chunkDir = "uploads/chunks" + } + admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize, "chunkDir": chunkDir} } if adminConfig.Email != nil { email = map[string]any{"to": adminConfig.Email.To, "from": adminConfig.Email.From, "host": adminConfig.Email.Host, "secret": "******", "nickname": adminConfig.Email.Nickname, "port": adminConfig.Email.Port, "is-ssl": adminConfig.Email.IsSsl, "is-loginauth": adminConfig.Email.IsLoginAuth} @@ -55,6 +69,13 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { maskStorageSecrets(storage) admin["storage"] = storage } + disks := make([]map[string]any, 0, len(adminConfig.DiskList)) + for _, disk := range adminConfig.DiskList { + if disk != nil { + disks = append(disks, map[string]any{"mount_point": disk.MountPoint}) + } + } + admin["disk_list"] = disks if adminConfig.Zap != nil { admin["zap"] = adminConfig.Zap } @@ -65,7 +86,12 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { admin["app"] = adminConfig.App } } - maskDataSecrets(dataConfig) + // Never mask secrets on the live runtime object. ConfigurationJSON is a + // read-only operation; mutating dataConfig here would replace the actual + // database/Redis/Mongo credentials with "******" and make the next reload + // fail. Work on a clone, just as we already do for adminConfig. + safeData := cloneDataConfig(dataConfig) + maskDataSecrets(safeData) safeAdmin := cloneAdminConfig(adminConfig) if safeAdmin != nil { if safeAdmin.Jwt != nil { @@ -77,8 +103,8 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { maskStorageSecrets(safeAdmin.Storage) } dataMap := map[string]any{} - if dataConfig != nil { - raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(dataConfig) + if safeData != nil { + raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(safeData) if err != nil { return nil, err } @@ -86,7 +112,7 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { return nil, err } } - config := gvaConfiguration(dataConfig, safeAdmin) + config := compatibleConfiguration(safeData, safeAdmin) config["admin"], config["email"], config["data"] = admin, email, dataMap return json.Marshal(map[string]any{"config": config}) } @@ -98,7 +124,7 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json } nextData := cloneDataConfig(currentData) nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend) - if err := applyGVAConfiguration(raw, nextData, nextAdmin); err != nil { + if err := applyCompatibleConfiguration(raw, nextData, nextAdmin); err != nil { return err } var value configurationEnvelope @@ -107,18 +133,14 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json } options := protojson.UnmarshalOptions{DiscardUnknown: true} if len(value.Data) > 0 && string(value.Data) != "null" { - patch := &conf.Data{} - if err := options.Unmarshal(value.Data, patch); err != nil { + if err := mergeConfigurationMessage(nextData, value.Data, options); err != nil { return err } - applyDataPatch(nextData, patch) } if len(value.Admin) > 0 && string(value.Admin) != "null" { - patch := &conf.AdminBackend{} - if err := options.Unmarshal(value.Admin, patch); err != nil { + if err := mergeConfigurationMessage(nextAdmin, value.Admin, options); err != nil { return err } - applyAdminPatch(nextAdmin, patch) } preserveDataSecrets(nextData, currentData) preserveAdminSecrets(nextAdmin, currentAdmin) @@ -216,6 +238,80 @@ func refreshDatabaseSource(database *conf.Data_Database) error { return nil } +// mergeConfigurationMessage applies a JSON object as a partial update while +// retaining fields that were not sent by the caller. protojson.Unmarshal is +// intentionally not used directly here because its reset semantics would +// clear unrelated configuration sections when the management page submits a +// partial object. +func mergeConfigurationMessage(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error { + currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target) + if err != nil { + return err + } + var current map[string]any + if err = json.Unmarshal(currentRaw, ¤t); err != nil { + return err + } + var incoming map[string]any + if err = json.Unmarshal(patch, &incoming); err != nil { + return err + } + incoming = normalizeJSONKeys(incoming).(map[string]any) + mergeJSONObjects(current, incoming) + merged, err := json.Marshal(current) + if err != nil { + return err + } + return options.Unmarshal(merged, target) +} + +func normalizeJSONKeys(value any) any { + switch item := value.(type) { + case map[string]any: + result := make(map[string]any, len(item)) + for key, nested := range item { + result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested) + } + return result + case []any: + result := make([]any, len(item)) + for index, nested := range item { + result[index] = normalizeJSONKeys(nested) + } + return result + default: + return value + } +} + +func snakeToLowerCamel(value string) string { + if !strings.Contains(value, "_") { + return value + } + parts := strings.Split(value, "_") + result := parts[0] + for _, part := range parts[1:] { + if part == "" { + continue + } + result += strings.ToUpper(part[:1]) + part[1:] + } + return result +} + +func mergeJSONObjects(target, patch map[string]any) { + for key, value := range patch { + incoming, isObject := value.(map[string]any) + if isObject { + if existing, ok := target[key].(map[string]any); ok { + mergeJSONObjects(existing, incoming) + continue + } + } + target[key] = value + } +} + func durationString(value *durationpb.Duration) string { if value == nil { return "0s" @@ -223,69 +319,17 @@ func durationString(value *durationpb.Duration) string { return value.AsDuration().String() } -func applyDataPatch(target, patch *conf.Data) { - if patch.Database != nil { - target.Database = patch.Database - } - if patch.Redis != nil { - target.Redis = patch.Redis - } - if patch.Mongo != nil { - target.Mongo = patch.Mongo - } - if patch.DatabaseList != nil { - target.DatabaseList = patch.DatabaseList - } - if patch.RedisList != nil { - target.RedisList = patch.RedisList - } -} - -func applyAdminPatch(target, patch *conf.AdminBackend) { - target.RouterPrefix = patch.RouterPrefix - if patch.System != nil { - if target.System != nil { - patch.System.Addr = target.System.Addr - patch.System.IplimitCount = target.System.IplimitCount - patch.System.IplimitTime = target.System.IplimitTime - } - target.System = patch.System - } - if patch.Jwt != nil { - target.Jwt = patch.Jwt - } - if patch.Captcha != nil { - target.Captcha = patch.Captcha - } - if patch.Local != nil { - target.Local = patch.Local - } - if patch.Media != nil { - if patch.Media.ChunkDir == "" && target.Media != nil { - patch.Media.ChunkDir = target.Media.ChunkDir - } - target.Media = patch.Media - } - if patch.Storage != nil { - target.Storage = patch.Storage - } - if patch.Zap != nil { - target.Zap = patch.Zap - } - if patch.Cors != nil { - target.Cors = patch.Cors - } - if patch.App != nil { - target.App = patch.App - } -} - func maskDataSecrets(value *conf.Data) { if value == nil { return } if value.Database != nil { value.Database.Password = "******" + // Source may contain an embedded password (for example a MySQL DSN). + // The management response must never expose it. Structured database + // fields are retained and refreshDatabaseSources rebuilds Source when + // the configuration is saved. + value.Database.Source = "" } if value.Redis != nil { value.Redis.Password = "******" @@ -296,6 +340,7 @@ func maskDataSecrets(value *conf.Data) { for _, item := range value.DatabaseList { if item != nil { item.Password = "******" + item.Source = "" } } for _, item := range value.RedisList { @@ -312,20 +357,54 @@ func preserveDataSecrets(next, current *conf.Data) { if next.Database != nil && current.Database != nil && maskedSecret(next.Database.Password) { next.Database.Password = current.Database.Password } + if next.Database != nil && current.Database != nil && next.Database.Source == "" { + next.Database.Source = current.Database.Source + } if next.Redis != nil && current.Redis != nil && maskedSecret(next.Redis.Password) { next.Redis.Password = current.Redis.Password } if next.Mongo != nil && current.Mongo != nil && maskedSecret(next.Mongo.Password) { next.Mongo.Password = current.Mongo.Password } + currentDatabases := make(map[string]*conf.Data_Database, len(current.DatabaseList)) + for _, item := range current.DatabaseList { + if item != nil && item.AliasName != "" { + currentDatabases[item.AliasName] = item + } + } for i, item := range next.DatabaseList { - if item != nil && i < len(current.DatabaseList) && current.DatabaseList[i] != nil && maskedSecret(item.Password) { - item.Password = current.DatabaseList[i].Password + var previous *conf.Data_Database + if item != nil && item.AliasName != "" { + previous = currentDatabases[item.AliasName] + } + if previous == nil && i < len(current.DatabaseList) { + previous = current.DatabaseList[i] + } + if item != nil && previous != nil { + if maskedSecret(item.Password) { + item.Password = previous.Password + } + if item.Source == "" { + item.Source = previous.Source + } + } + } + currentRedis := make(map[string]*conf.Data_Redis, len(current.RedisList)) + for _, item := range current.RedisList { + if item != nil && item.Name != "" { + currentRedis[item.Name] = item } } for i, item := range next.RedisList { - if item != nil && i < len(current.RedisList) && current.RedisList[i] != nil && maskedSecret(item.Password) { - item.Password = current.RedisList[i].Password + var previous *conf.Data_Redis + if item != nil && item.Name != "" { + previous = currentRedis[item.Name] + } + if previous == nil && i < len(current.RedisList) { + previous = current.RedisList[i] + } + if item != nil && previous != nil && maskedSecret(item.Password) { + item.Password = previous.Password } } } @@ -350,6 +429,9 @@ func objectStores(storage *conf.AdminBackend_Storage) []*conf.AdminBackend_Objec } func maskStorageSecrets(storage *conf.AdminBackend_Storage) { + if storage == nil { + return + } if storage.Qiniu != nil { storage.Qiniu.SecretKey = "******" } diff --git a/internal/data/data_access_log.go b/internal/data/data_access_log.go index ee48a21..2f7572d 100644 --- a/internal/data/data_access_log.go +++ b/internal/data/data_access_log.go @@ -26,7 +26,7 @@ func (r *auditRecorderRepo) RecordDataAccess(ctx context.Context, v *biz.DataAcc return r.data.gormDB.WithContext(ctx).Create(&dataAccessLogPO{EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail}).Error } func dataAccessFromPO(v dataAccessLogPO) *biz.DataAccessLog { - return &biz.DataAccessLog{ID: v.ID, CreatedAt: v.CreatedAt, EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail} + return &biz.DataAccessLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail} } func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *biz.DataAccessLog) ([]*biz.DataAccessLog, int64, error) { db := r.data.gormDB.WithContext(ctx).Model(&dataAccessLogPO{}) @@ -37,9 +37,6 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q * if q.TargetTable != "" { db = db.Where("target_table LIKE ?", "%"+q.TargetTable+"%") } - if q.UserID != 0 { - db = db.Where("user_id = ?", q.UserID) - } } var total int64 if err := db.Count(&total).Error; err != nil { @@ -56,5 +53,5 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q * return out, total, nil } func (r *auditQueryRepo) DeleteDataAccess(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Delete(&dataAccessLogPO{}, ids).Error + return r.data.gormDB.WithContext(ctx).Delete(&dataAccessLogPO{}, "id IN ?", ids).Error } diff --git a/internal/data/department.go b/internal/data/department.go index 6c22dc2..d8a8490 100644 --- a/internal/data/department.go +++ b/internal/data/department.go @@ -77,6 +77,9 @@ func (r *departmentRepo) CreateDepartment(ctx context.Context, v *biz.Department if v.ParentID != 0 { var parent departmentPO if err := r.data.gormDB.WithContext(ctx).First(&parent, v.ParentID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("父部门不存在") + } return err } v.Ancestors = strings.Trim(parent.Ancestors+","+strconv.FormatUint(uint64(parent.ID), 10), ",") @@ -91,6 +94,9 @@ func (r *departmentRepo) UpdateDepartment(ctx context.Context, v *biz.Department if v.ParentID != 0 { var parent departmentPO if err := r.data.gormDB.WithContext(ctx).First(&parent, v.ParentID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("父部门不存在") + } return err } v.Ancestors = strings.Trim(parent.Ancestors+","+strconv.FormatUint(uint64(parent.ID), 10), ",") @@ -108,6 +114,12 @@ func (r *departmentRepo) DeleteDepartment(ctx context.Context, id uint) error { if count > 0 { return errors.New("存在子部门,不允许删除") } + if err := r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("dept_id = ?", id).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("该部门下存在用户,不允许删除") + } if err := r.data.gormDB.WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_department_id = ?", id).Count(&count).Error; err != nil { return err } diff --git a/internal/data/dictionary.go b/internal/data/dictionary.go index 40c7a7f..cf824b7 100644 --- a/internal/data/dictionary.go +++ b/internal/data/dictionary.go @@ -23,7 +23,7 @@ type dictionaryPO struct { DeletedAt gorm.DeletedAt `gorm:"index"` Name string Type string - Status bool + Status *bool Desc string ParentID *uint } @@ -38,7 +38,7 @@ type dictionaryDetailPO struct { Label string Value string Extend string - Status bool + Status *bool Sort int DictionaryID uint `gorm:"column:sys_dictionary_id;index"` ParentID *uint @@ -59,11 +59,18 @@ func parameterFromPO(po parameterPO) *biz.SystemParameter { } func (r *dictionaryRepo) CreateDictionary(ctx context.Context, v *biz.Dictionary) error { + var existing dictionaryPO + if err := r.data.gormDB.WithContext(ctx).Where("type = ?", v.Type).First(&existing).Error; err == nil { + return errors.New("存在相同的type,不允许创建") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } po := dictionaryPO{Name: v.Name, Type: v.Type, Status: v.Status, Desc: v.Desc, ParentID: v.ParentID} if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID = po.ID + v.CreatedAt, v.UpdatedAt = po.CreatedAt, po.UpdatedAt return nil } @@ -108,7 +115,46 @@ func (r *dictionaryRepo) ImportDictionary(ctx context.Context, dictionary *biz.D }) } func (r *dictionaryRepo) UpdateDictionary(ctx context.Context, v *biz.Dictionary) error { - return r.data.gormDB.WithContext(ctx).Model(&dictionaryPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "type": v.Type, "status": v.Status, "desc": v.Desc, "parent_id": v.ParentID}).Error + db := r.data.gormDB.WithContext(ctx) + var current dictionaryPO + if err := db.Where("id = ?", v.ID).First(¤t).Error; err != nil { + return errors.New("查询字典数据失败") + } + if current.Type != v.Type { + var duplicate dictionaryPO + if err := db.Where("type = ?", v.Type).First(&duplicate).Error; err == nil { + return errors.New("存在相同的type,不允许创建") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + if v.ParentID != nil && *v.ParentID != 0 { + if err := r.checkDictionaryCircularReference(ctx, v.ID, *v.ParentID); err != nil { + return err + } + } + return db.Model(¤t).Updates(map[string]any{"name": v.Name, "type": v.Type, "status": v.Status, "desc": v.Desc, "parent_id": v.ParentID}).Error +} + +// checkDictionaryCircularReference mirrors the compatible recursive parent-chain +// check. A missing parent is allowed (the reference implementation permits a +// dangling parent ID); only a self-reference or an ancestor cycle is rejected. +func (r *dictionaryRepo) checkDictionaryCircularReference(ctx context.Context, currentID, parentID uint) error { + if currentID == parentID { + return errors.New("不能将字典设置为自己的父级") + } + var parent dictionaryPO + err := r.data.gormDB.WithContext(ctx).Where("id = ?", parentID).First(&parent).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + if parent.ParentID != nil && *parent.ParentID != 0 { + return r.checkDictionaryCircularReference(ctx, currentID, *parent.ParentID) + } + return nil } func (r *dictionaryRepo) DeleteDictionary(ctx context.Context, id uint) error { db := r.data.gormDB.WithContext(ctx) @@ -141,7 +187,7 @@ func (r *dictionaryRepo) FindDictionary(ctx context.Context, id uint, typ string if err == nil { filtered := out.Details[:0] for _, item := range out.Details { - if item.Status { + if item.Status != nil && *item.Status { filtered = append(filtered, item) } } @@ -226,7 +272,7 @@ func (r *dictionaryRepo) CreateDictionaryDetail(ctx context.Context, v *biz.Dict po := dictionaryDetailPO{Label: v.Label, Value: v.Value, Extend: v.Extend, Status: v.Status, Sort: v.Sort, DictionaryID: v.DictionaryID, ParentID: v.ParentID} po.Level = 0 po.Path = "" - if v.ParentID != nil && *v.ParentID != 0 { + if v.ParentID != nil { var parent dictionaryDetailPO if err := r.data.gormDB.WithContext(ctx).First(&parent, *v.ParentID).Error; err != nil { return err @@ -244,40 +290,59 @@ func (r *dictionaryRepo) CreateDictionaryDetail(ctx context.Context, v *biz.Dict } func (r *dictionaryRepo) UpdateDictionaryDetail(ctx context.Context, v *biz.DictionaryDetail) error { var po dictionaryDetailPO - if err := r.data.gormDB.WithContext(ctx).First(&po, v.ID).Error; err != nil { + db := r.data.gormDB.WithContext(ctx) + if err := db.First(&po, v.ID).Error; err != nil { return err } level, path := 0, "" - if v.ParentID != nil && *v.ParentID != 0 { + if v.ParentID != nil { if *v.ParentID == v.ID { - return errors.New("不能将自身设为父级") + return errors.New("不能将字典详情设置为自己或其子项的父级") } var parent dictionaryDetailPO - if err := r.data.gormDB.WithContext(ctx).First(&parent, *v.ParentID).Error; err != nil { + if err := db.First(&parent, *v.ParentID).Error; err != nil { return err } if strings.Contains(","+parent.Path+",", fmt.Sprintf(",%d,", v.ID)) { - return errors.New("不能移动到自己的子级") + return errors.New("不能将字典详情设置为自己或其子项的父级") } level = parent.Level + 1 path = strings.Trim(strings.Join([]string{parent.Path, fmt.Sprint(parent.ID)}, ","), ",") } - oldPrefix := strings.Trim(strings.Join([]string{po.Path, fmt.Sprint(po.ID)}, ","), ",") - newPrefix := strings.Trim(strings.Join([]string{path, fmt.Sprint(po.ID)}, ","), ",") - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&dictionaryDetailPO{}).Where("id = ?", v.ID).Updates(map[string]any{"label": v.Label, "value": v.Value, "extend": v.Extend, "status": v.Status, "sort": v.Sort, "sys_dictionary_id": v.DictionaryID, "parent_id": v.ParentID, "level": level, "path": path}).Error; err != nil { return err } - return tx.Model(&dictionaryDetailPO{}).Where("path = ? OR path LIKE ?", oldPrefix, oldPrefix+",%").Update("path", gorm.Expr("REPLACE(path, ?, ?)", oldPrefix, newPrefix)).Error + return r.updateDictionaryDetailChildren(tx, v.ID, level, path) }) } + +// updateDictionaryDetailChildren recomputes descendants after a move, as the +// The service does this recursively. Updating level as well as path matters when a +// subtree is moved beneath a different depth. +func (r *dictionaryRepo) updateDictionaryDetailChildren(tx *gorm.DB, parentID uint, parentLevel int, parentPath string) error { + var children []dictionaryDetailPO + if err := tx.Where("parent_id = ?", parentID).Order("sort,id").Find(&children).Error; err != nil { + return err + } + for _, child := range children { + childPath := strings.Trim(strings.Join([]string{parentPath, fmt.Sprint(parentID)}, ","), ",") + if err := tx.Model(&dictionaryDetailPO{}).Where("id = ?", child.ID).Updates(map[string]any{"level": parentLevel + 1, "path": childPath}).Error; err != nil { + return err + } + if err := r.updateDictionaryDetailChildren(tx, child.ID, parentLevel+1, childPath); err != nil { + return err + } + } + return nil +} func (r *dictionaryRepo) DeleteDictionaryDetail(ctx context.Context, id uint) error { var count int64 if err := r.data.gormDB.WithContext(ctx).Model(&dictionaryDetailPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { return err } if count > 0 { - return errors.New("存在子级不可删除") + return errors.New("该字典详情下还有子项,无法删除") } return r.data.gormDB.WithContext(ctx).Delete(&dictionaryDetailPO{}, id).Error } diff --git a/internal/data/error_record.go b/internal/data/error_record.go index 8a08f90..4c1316a 100644 --- a/internal/data/error_record.go +++ b/internal/data/error_record.go @@ -26,7 +26,7 @@ type errorRecordPO struct { func (errorRecordPO) TableName() string { return "sys_error" } func errorFromPO(v errorRecordPO) *biz.ErrorRecord { - return &biz.ErrorRecord{ID: v.ID, CreatedAt: v.CreatedAt, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status} + return &biz.ErrorRecord{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status} } func (r *auditRecorderRepo) CreateError(ctx context.Context, v *biz.ErrorRecord) error { if v.Status == "" { diff --git a/internal/data/export.go b/internal/data/export.go index b630f3a..acb5581 100644 --- a/internal/data/export.go +++ b/internal/data/export.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "gorm.io/gorm" + "gorm.io/gorm/clause" "kra/internal/biz" "strconv" "strings" @@ -31,8 +32,11 @@ type exportTemplatePO struct { func (exportTemplatePO) TableName() string { return "sys_export_templates" } type exportConditionPO struct { - ID uint `gorm:"primaryKey"` - TemplateID string `gorm:"index"` + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + TemplateID string `gorm:"index"` From string Column string Operator string @@ -41,9 +45,12 @@ type exportConditionPO struct { func (exportConditionPO) TableName() string { return "sys_export_template_condition" } type exportJoinPO struct { - ID uint `gorm:"primaryKey"` - TemplateID string `gorm:"index"` - Join string `gorm:"column:joins"` + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + TemplateID string `gorm:"index"` + Join string `gorm:"column:joins"` Table string On string `gorm:"column:on"` } @@ -56,17 +63,17 @@ func NewExportRepo(data *Data) biz.ExportRepo { return &exportRepo{data: data} } func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []exportJoinPO) *biz.ExportTemplate { v := &biz.ExportTemplate{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DBName: po.DBName, Name: po.Name, TableName: po.DBTableName, TemplateID: po.TemplateID, TemplateInfo: po.TemplateInfo, SQL: po.SQL, ImportSQL: po.ImportSQL, Limit: po.Limit, Order: po.Order} for _, x := range conditions { - v.Conditions = append(v.Conditions, biz.ExportCondition{From: x.From, Column: x.Column, Operator: x.Operator}) + v.Conditions = append(v.Conditions, biz.ExportCondition{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator}) } for _, x := range joins { - v.Joins = append(v.Joins, biz.ExportJoin{Join: x.Join, Table: x.Table, On: x.On}) + v.Joins = append(v.Joins, biz.ExportJoin{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, Join: x.Join, Table: x.Table, On: x.On}) } return v } func exportToPO(v *biz.ExportTemplate) exportTemplatePO { return exportTemplatePO{ID: v.ID, DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order} } -func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate) error { +func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs bool) error { if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil { return err } @@ -75,19 +82,45 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate) error { } conditions := make([]exportConditionPO, 0, len(v.Conditions)) for _, x := range v.Conditions { - conditions = append(conditions, exportConditionPO{TemplateID: v.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator}) + id := x.ID + if resetIDs { + id = 0 + } + templateID := x.TemplateID + if templateID == "" { + templateID = v.TemplateID + } + conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, From: x.From, Column: x.Column, Operator: x.Operator}) } if len(conditions) > 0 { - if err := tx.Create(&conditions).Error; err != nil { + create := tx + if !resetIDs { + // GORM saves Create associations with ON CONFLICT DO NOTHING. + // Preserve that behavior for copied templates carrying relation IDs. + create = create.Clauses(clause.OnConflict{DoNothing: true}) + } + if err := create.Create(&conditions).Error; err != nil { return err } } joins := make([]exportJoinPO, 0, len(v.Joins)) for _, x := range v.Joins { - joins = append(joins, exportJoinPO{TemplateID: v.TemplateID, Join: x.Join, Table: x.Table, On: x.On}) + id := x.ID + if resetIDs { + id = 0 + } + templateID := x.TemplateID + if templateID == "" { + templateID = v.TemplateID + } + joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, Join: x.Join, Table: x.Table, On: x.On}) } if len(joins) > 0 { - return tx.Create(&joins).Error + create := tx + if !resetIDs { + create = create.Clauses(clause.OnConflict{DoNothing: true}) + } + return create.Create(&joins).Error } return nil } @@ -98,7 +131,7 @@ func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *biz.ExportTemp return err } v.ID = po.ID - return r.saveRelations(tx, v) + return r.saveRelations(tx, v, false) }) } func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemplate) error { @@ -107,7 +140,7 @@ func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemp if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil { return err } - return r.saveRelations(tx, v) + return r.saveRelations(tx, v, true) }) } func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error { @@ -387,7 +420,10 @@ func parseTemplateColumns(raw string) map[string]string { return out } func (r *exportRepo) ImportExportRows(ctx context.Context, t *biz.ExportTemplate, rows []map[string]any) error { - sql := strings.TrimSpace(t.ImportSQL) + // ImportSQL is checked verbatim. In particular, a whitespace-only value + // is still treated as custom SQL and is allowed to return the driver's + // native error instead of silently falling back to GORM insertion. + sql := t.ImportSQL selected, err := r.data.database(t.DBName) if err != nil { return err diff --git a/internal/data/login_log.go b/internal/data/login_log.go index 0337101..9ab76f1 100644 --- a/internal/data/login_log.go +++ b/internal/data/login_log.go @@ -26,7 +26,7 @@ func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *biz.LoginLog) er return r.data.gormDB.WithContext(ctx).Create(&loginLogPO{Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID}).Error } func loginFromPO(v loginLogPO) *biz.LoginLog { - return &biz.LoginLog{ID: v.ID, CreatedAt: v.CreatedAt, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID} + return &biz.LoginLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID} } func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *biz.LoginLog) ([]*biz.LoginLog, int64, error) { db := r.data.gormDB.WithContext(ctx).Model(&loginLogPO{}) @@ -69,5 +69,5 @@ func (r *auditQueryRepo) FindLogin(ctx context.Context, id uint) (*biz.LoginLog, return loginFromPO(po), nil } func (r *auditQueryRepo) DeleteLogins(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Delete(&loginLogPO{}, ids).Error + return r.data.gormDB.WithContext(ctx).Delete(&loginLogPO{}, "id IN ?", ids).Error } diff --git a/internal/data/media.go b/internal/data/media.go index 772764c..db451f6 100644 --- a/internal/data/media.go +++ b/internal/data/media.go @@ -55,6 +55,7 @@ func (r *mediaRepo) CreateMedia(ctx context.Context, v *biz.MediaFile) error { } v.ID = po.ID v.CreatedAt = po.CreatedAt + v.UpdatedAt = po.UpdatedAt return nil } func (r *mediaRepo) FindMedia(ctx context.Context, id uint) (*biz.MediaFile, error) { @@ -119,7 +120,13 @@ func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*b return out, total, nil } func (r *mediaRepo) UpdateMediaName(ctx context.Context, id uint, name string) error { - return r.data.gormDB.WithContext(ctx).Model(&mediaPO{}).Where("id = ?", id).Update("name", name).Error + // The compatible flow performs a First before Update, so a missing record returns a + // not-found error instead of silently succeeding with RowsAffected=0. + var po mediaPO + if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { + return err + } + return r.data.gormDB.WithContext(ctx).Model(&po).Update("name", name).Error } func (r *mediaRepo) DeleteMedia(ctx context.Context, id uint) error { return r.data.gormDB.WithContext(ctx).Unscoped().Delete(&mediaPO{}, id).Error @@ -176,14 +183,17 @@ func (r *mediaRepo) ListCategories(ctx context.Context) ([]*biz.AttachmentCatego } byID := map[uint]*biz.AttachmentCategory{} for _, po := range pos { - byID[po.ID] = &biz.AttachmentCategory{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Name: po.Name, ParentID: po.ParentID, Children: []*biz.AttachmentCategory{}} + // Children remains nil for leaf nodes, which serializes as null. + byID[po.ID] = &biz.AttachmentCategory{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Name: po.Name, ParentID: po.ParentID} } roots := []*biz.AttachmentCategory{} for _, po := range pos { v := byID[po.ID] if parent := byID[po.ParentID]; parent != nil { parent.Children = append(parent.Children, v) - } else { + } else if po.ParentID == 0 { + // The tree starts at parent id 0; non-zero orphan nodes are + // intentionally omitted instead of being promoted to roots. roots = append(roots, v) } } diff --git a/internal/data/media_upload.go b/internal/data/media_upload.go index 3bac82f..6f84c7c 100644 --- a/internal/data/media_upload.go +++ b/internal/data/media_upload.go @@ -84,7 +84,9 @@ func (r *mediaRepo) CompleteUploadSession(ctx context.Context, id uint, key stri return r.data.gormDB.WithContext(ctx).Model(&uploadSessionPO{}).Where("id = ?", id).Updates(map[string]any{"status": "completed", "storage_key": key, "media_id": mediaID}).Error } func (r *mediaRepo) DeleteUploadSession(ctx context.Context, id uint) error { - return r.data.gormDB.WithContext(ctx).Unscoped().Delete(&uploadSessionPO{}, id).Error + // The compatible flow uses GORM's normal Delete here, retaining the soft-deleted session + // for audit/recovery rather than physically removing it. + return r.data.gormDB.WithContext(ctx).Delete(&uploadSessionPO{}, id).Error } func (r *mediaRepo) UpsertChunk(ctx context.Context, uploadID uint, v *biz.UploadChunk) error { po := uploadChunkPO{UploadID: uploadID, ChunkIndex: v.Index, ChunkHash: v.Hash, Size: v.Size} @@ -102,7 +104,7 @@ func (r *mediaRepo) ListChunks(ctx context.Context, uploadID uint) ([]*biz.Uploa return out, nil } func (r *mediaRepo) DeleteChunks(ctx context.Context, uploadID uint) error { - return r.data.gormDB.WithContext(ctx).Unscoped().Where("upload_id = ?", uploadID).Delete(&uploadChunkPO{}).Error + return r.data.gormDB.WithContext(ctx).Where("upload_id = ?", uploadID).Delete(&uploadChunkPO{}).Error } func (r *mediaRepo) StaleUploadSessionIDs(ctx context.Context, before time.Time) ([]uint, error) { var ids []uint @@ -111,9 +113,9 @@ func (r *mediaRepo) StaleUploadSessionIDs(ctx context.Context, before time.Time) } func (r *mediaRepo) DeleteUploadData(ctx context.Context, uploadID uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Unscoped().Where("upload_id = ?", uploadID).Delete(&uploadChunkPO{}).Error; err != nil { + if err := tx.Where("upload_id = ?", uploadID).Delete(&uploadChunkPO{}).Error; err != nil { return err } - return tx.Unscoped().Delete(&uploadSessionPO{}, uploadID).Error + return tx.Delete(&uploadSessionPO{}, uploadID).Error }) } diff --git a/internal/data/menu.go b/internal/data/menu.go index 0a671b2..3b97636 100644 --- a/internal/data/menu.go +++ b/internal/data/menu.go @@ -3,6 +3,7 @@ package data import ( "context" "errors" + "time" "kra/internal/biz" @@ -14,7 +15,12 @@ type menuRepo struct{ data *Data } func NewMenuRepo(data *Data) biz.MenuRepo { return &menuRepo{data: data} } func menuFromPO(po menuPO) *biz.Menu { - return &biz.Menu{ID: po.ID, ParentID: po.ParentID, Path: po.Path, Name: po.Name, Hidden: po.Hidden, Component: po.Component, Sort: po.Sort, ActiveName: po.ActiveName, KeepAlive: po.KeepAlive, DefaultMenu: po.DefaultMenu, Title: po.Title, Icon: po.Icon, CloseTab: po.CloseTab, TransitionType: po.TransitionType} + var deletedAt *time.Time + if po.DeletedAt.Valid { + value := po.DeletedAt.Time + deletedAt = &value + } + return &biz.Menu{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, ID: po.ID, ParentID: po.ParentID, Path: po.Path, Name: po.Name, Hidden: po.Hidden, Component: po.Component, Sort: po.Sort, ActiveName: po.ActiveName, KeepAlive: po.KeepAlive, DefaultMenu: po.DefaultMenu, Title: po.Title, Icon: po.Icon, CloseTab: po.CloseTab, TransitionType: po.TransitionType} } func menuToPO(v *biz.Menu) menuPO { @@ -22,19 +28,14 @@ func menuToPO(v *biz.Menu) menuPO { } func replaceMenuRelations(tx *gorm.DB, menu *biz.Menu) error { - var oldButtonIDs []uint - if err := tx.Model(&menuButtonPO{}).Where("sys_base_menu_id = ?", menu.ID).Pluck("id", &oldButtonIDs).Error; err != nil { + // The compatible update rebuilds menu-button rows but does not remove the + // existing authority-button rows that point at the old button IDs. Keep + // that observable behavior here; menu deletion itself still cleans those + // rows in Delete below, matching the administration endpoint behavior. + if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuButtonPO{}).Error; err != nil { return err } - if len(oldButtonIDs) > 0 { - if err := tx.Where("sys_base_menu_btn_id IN ?", oldButtonIDs).Delete(&authorityButtonPO{}).Error; err != nil { - return err - } - } - if err := tx.Where("sys_base_menu_id = ?", menu.ID).Delete(&menuButtonPO{}).Error; err != nil { - return err - } - if err := tx.Where("sys_base_menu_id = ?", menu.ID).Delete(&menuParameterPO{}).Error; err != nil { + if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuParameterPO{}).Error; err != nil { return err } buttons := make([]menuButtonPO, 0, len(menu.Buttons)) @@ -166,7 +167,7 @@ func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error { } menu.Parameters = make([]*biz.MenuParameter, 0, len(parameters)) for _, parameter := range parameters { - menu.Parameters = append(menu.Parameters, &biz.MenuParameter{ID: parameter.ID, MenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter)) } var buttons []menuButtonPO if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Order("id").Find(&buttons).Error; err != nil { @@ -174,7 +175,7 @@ func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error { } menu.Buttons = make([]*biz.MenuButton, 0, len(buttons)) for _, button := range buttons { - menu.Buttons = append(menu.Buttons, &biz.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: button.MenuID}) + menu.Buttons = append(menu.Buttons, menuButtonFromPO(button)) } return nil } @@ -228,7 +229,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { } for _, parameter := range parameters { menu := byID[parameter.MenuID] - menu.Parameters = append(menu.Parameters, &biz.MenuParameter{ID: parameter.ID, MenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter)) } var buttons []menuButtonPO if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Order("id").Find(&buttons).Error; err != nil { @@ -236,11 +237,29 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { } for _, button := range buttons { menu := byID[button.MenuID] - menu.Buttons = append(menu.Buttons, &biz.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: button.MenuID}) + menu.Buttons = append(menu.Buttons, menuButtonFromPO(button)) } return out, nil } +func menuParameterFromPO(po menuParameterPO) *biz.MenuParameter { + var deletedAt *time.Time + if po.DeletedAt.Valid { + value := po.DeletedAt.Time + deletedAt = &value + } + return &biz.MenuParameter{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, ID: po.ID, MenuID: po.MenuID, Type: po.Type, Key: po.Key, Value: po.Value} +} + +func menuButtonFromPO(po menuButtonPO) *biz.MenuButton { + var deletedAt *time.Time + if po.DeletedAt.Valid { + value := po.DeletedAt.Time + deletedAt = &value + } + return &biz.MenuButton{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, ID: po.ID, Name: po.Name, Description: po.Description, MenuID: po.MenuID} +} + func (r *menuRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) error { if err := (&authorityAccessRepo{data: r.data}).checkAuthorityIDAuth(ctx, id); err != nil { return err diff --git a/internal/data/migrations.go b/internal/data/migrations.go index 01e6c6b..b470ba5 100644 --- a/internal/data/migrations.go +++ b/internal/data/migrations.go @@ -2,14 +2,18 @@ package data import ( "fmt" + "time" "gorm.io/gorm" ) func migrateAll(db *gorm.DB) error { + if err := migrateLegacyIgnoreAPITable(db); err != nil { + return err + } if err := db.AutoMigrate( &userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{}, - &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &menuButtonPO{}, &authorityButtonPO{}, + &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{}, &departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{}, &dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &securityConfigPO{}, &versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{}, @@ -19,9 +23,140 @@ func migrateAll(db *gorm.DB) error { ); err != nil { return err } + if err := migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil { + return err + } + if err := normalizeErrorRecordStatuses(db); err != nil { + return err + } + if err := reconcileRootAuthorityAPIs(db); err != nil { + return err + } 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. +func normalizeErrorRecordStatuses(db *gorm.DB) error { + return db.Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error +} + +// migrateLegacyAuthorityAPIsToCasbinRules upgrades the early Kra join-table +// representation to the independent Casbin policy table. Keep the legacy +// table in place for backwards compatibility, but make casbin_rule the sole +// live policy source. Existing policy rows are not duplicated. +func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error { + if !db.Migrator().HasTable(&authorityAPIPO{}) || !db.Migrator().HasTable(&casbinRulePO{}) { + return nil + } + type legacyPolicy struct { + AuthorityID uint + Path string + Method string + } + var rows []legacyPolicy + if err := db.Table("sys_authority_apis sa"). + Select("sa.authority_id, a.path, a.method"). + Joins("JOIN sys_apis a ON a.id = sa.api_id"). + Where("a.deleted_at IS NULL").Find(&rows).Error; err != nil { + return err + } + for _, row := range rows { + exists, err := policyExists(db, row.AuthorityID, row.Path, row.Method) + if err != nil { + return err + } + if exists { + continue + } + if err := db.Create(&casbinRulePO{Ptype: "p", V0: fmt.Sprint(row.AuthorityID), V1: row.Path, V2: row.Method}).Error; err != nil { + return err + } + } + return nil +} + +// migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key +// shape (path, method) to the compatible model shape (ID/timestamps/soft +// delete). AutoMigrate can add columns but cannot replace an existing +// composite primary key portably, so rebuild the small table once while +// preserving every existing ignore rule. +func migrateLegacyIgnoreAPITable(db *gorm.DB) error { + if !db.Migrator().HasTable(&ignoredAPIPO{}) || db.Migrator().HasColumn(&ignoredAPIPO{}, "id") { + return nil + } + legacyTable := fmt.Sprintf("sys_ignore_apis_legacy_%d", time.Now().UnixNano()) + type legacyIgnoredAPI struct { + Path string + Method string + } + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Migrator().RenameTable(ignoredAPIPO{}.TableName(), legacyTable); err != nil { + return fmt.Errorf("rename legacy ignore API table: %w", err) + } + if err := tx.AutoMigrate(&ignoredAPIPO{}); err != nil { + return fmt.Errorf("create compatible ignore API table: %w", err) + } + var rows []legacyIgnoredAPI + if err := tx.Table(legacyTable).Find(&rows).Error; err != nil { + return fmt.Errorf("read legacy ignore API rows: %w", err) + } + if len(rows) > 0 { + items := make([]ignoredAPIPO, 0, len(rows)) + for _, row := range rows { + items = append(items, ignoredAPIPO{Path: row.Path, Method: row.Method}) + } + if err := tx.Create(&items).Error; err != nil { + return fmt.Errorf("copy legacy ignore API rows: %w", err) + } + } + if err := tx.Migrator().DropTable(legacyTable); err != nil { + return fmt.Errorf("drop legacy ignore API table: %w", err) + } + return nil + }) +} + +// reconcileRootAuthorityAPIs is a one-time upgrade path from the former Kra +// implementation where authority 888 bypassed policy storage entirely. The compatible behavior +// grants its root role through persisted Casbin policies, so when a legacy +// database has the root role but no stored API links, materialize the same +// policy set and let normal authorization read it thereafter. +func reconcileRootAuthorityAPIs(db *gorm.DB) error { + var authorityCount int64 + if err := db.Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil || authorityCount == 0 { + return err + } + var policyCount int64 + if err := policyScope(db).Where("v0 = ?", "888").Count(&policyCount).Error; err != nil || policyCount != 0 { + return err + } + var ignored []ignoredAPIPO + if err := db.Find(&ignored).Error; err != nil { + return err + } + ignoreSet := make(map[string]struct{}, len(ignored)) + for _, item := range ignored { + ignoreSet[item.Method+"\x00"+item.Path] = struct{}{} + } + var apis []apiPO + if err := db.Find(&apis).Error; err != nil { + return err + } + rules := make([]casbinRulePO, 0, len(apis)) + for _, api := range apis { + if _, ok := ignoreSet[api.Method+"\x00"+api.Path]; ok { + continue + } + rules = append(rules, newPolicyRule(888, api.Path, api.Method)) + } + if len(rules) == 0 { + return nil + } + return db.Create(&rules).Error +} + // reconcileReferenceIndexes removes constraints created by older Kra builds // that are not part of the administration data model. Business services own // duplicate checks and their user-facing error messages. diff --git a/internal/data/operation_log.go b/internal/data/operation_log.go index 36a49af..7f51890 100644 --- a/internal/data/operation_log.go +++ b/internal/data/operation_log.go @@ -33,7 +33,7 @@ func (r *auditRecorderRepo) RecordOperation(ctx context.Context, v *biz.Operatio return r.data.gormDB.WithContext(ctx).Create(&operationPO{IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID}).Error } func opFromPO(v operationPO) *biz.OperationRecord { - return &biz.OperationRecord{ID: v.ID, CreatedAt: v.CreatedAt, IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID} + return &biz.OperationRecord{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID} } func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q *biz.OperationRecord) ([]*biz.OperationRecord, int64, error) { db := r.data.gormDB.WithContext(ctx).Model(&operationPO{}) @@ -98,5 +98,5 @@ func (r *auditQueryRepo) FindOperation(ctx context.Context, id uint) (*biz.Opera return opFromPO(po), nil } func (r *auditQueryRepo) DeleteOperations(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Delete(&operationPO{}, ids).Error + return r.data.gormDB.WithContext(ctx).Delete(&operationPO{}, "id IN ?", ids).Error } diff --git a/internal/data/parameter.go b/internal/data/parameter.go index e7db1a1..bdcd350 100644 --- a/internal/data/parameter.go +++ b/internal/data/parameter.go @@ -36,7 +36,9 @@ func (r *parameterRepo) CreateParameter(ctx context.Context, v *biz.SystemParame return nil } func (r *parameterRepo) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error { - return r.data.gormDB.WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "key": v.Key, "value": v.Value, "desc": v.Desc}).Error + // The compatible update uses a struct, so zero-value optional fields (notably desc) + // are intentionally ignored rather than clearing an existing value. + return r.data.gormDB.WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(¶meterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}).Error } func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []uint) error { return r.data.gormDB.WithContext(ctx).Delete(¶meterPO{}, ids).Error diff --git a/internal/data/permission.go b/internal/data/permission.go index 8c9c096..7fc3504 100644 --- a/internal/data/permission.go +++ b/internal/data/permission.go @@ -2,6 +2,7 @@ package data import ( "context" + "time" "kra/internal/biz" @@ -14,6 +15,9 @@ func NewPermissionRepo(data *Data) biz.PermissionRepo { return &permissionRepo{d type menuButtonPO struct { ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` Name string Description string `gorm:"column:desc"` MenuID uint `gorm:"column:sys_base_menu_id"` @@ -36,7 +40,7 @@ func (r *permissionRepo) Buttons(ctx context.Context, menuID uint) ([]*biz.MenuB } out := make([]*biz.MenuButton, 0, len(pos)) for _, po := range pos { - out = append(out, &biz.MenuButton{ID: po.ID, Name: po.Name, Description: po.Description, MenuID: po.MenuID}) + out = append(out, menuButtonFromPO(po)) } return out, nil } diff --git a/internal/data/security.go b/internal/data/security.go index 88d716e..79e9586 100644 --- a/internal/data/security.go +++ b/internal/data/security.go @@ -16,6 +16,9 @@ func NewSecurityRepo(data *Data) biz.SecurityRepo { return &securityRepo{data: d type securityConfigPO struct { ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` CaptchaOpen int CaptchaTimeout int KeyLong int @@ -43,10 +46,10 @@ func defaultSecurityConfig() securityConfigPO { return securityConfigPO{ID: 1, CaptchaTimeout: 3600, KeyLong: 6, ImgWidth: 240, ImgHeight: 80, PwdMinLength: 8, LimitWindow: 60, LimitCount: 30, LockThreshold: 5, LockDuration: 30, PwdExpireDays: 90} } func securityFromPO(v securityConfigPO) *biz.SecurityConfig { - return &biz.SecurityConfig{ID: v.ID, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword} + return &biz.SecurityConfig{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword} } func securityToPO(v *biz.SecurityConfig) securityConfigPO { - return securityConfigPO{ID: 1, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword} + return securityConfigPO{ID: 1, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword} } func (r *securityRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig, error) { var po securityConfigPO @@ -66,6 +69,7 @@ func (r *securityRepo) SaveSecurityConfig(ctx context.Context, v *biz.SecurityCo return err } po := securityToPO(v) + po.CreatedAt, po.UpdatedAt = previous.CreatedAt, previous.UpdatedAt return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Save(&po).Error; err != nil { return err @@ -73,6 +77,7 @@ func (r *securityRepo) SaveSecurityConfig(ctx context.Context, v *biz.SecurityCo if v.PwdExpireEnable && !previous.PwdExpireEnable { return tx.Model(&userPO{}).Where("password_updated_at IS NULL").Update("password_updated_at", time.Now()).Error } + v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt return nil }) } diff --git a/internal/data/storage/aliyun_storage.go b/internal/data/storage/aliyun_storage.go index 7893967..cd8b61a 100644 --- a/internal/data/storage/aliyun_storage.go +++ b/internal/data/storage/aliyun_storage.go @@ -86,7 +86,7 @@ func (s *aliyunStorage) DeletePrefix(ctx context.Context, prefix string) error { } } func (s *aliyunStorage) List(_ context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { - if limit < 1 || limit > 1000 { + if limit <= 0 { limit = 100 } result, err := s.bucket.ListObjects(oss.Prefix(s.key(prefix)), oss.Marker(cursor), oss.MaxKeys(limit)) diff --git a/internal/data/storage/aws_storage.go b/internal/data/storage/aws_storage.go index aa3d7e0..d355274 100644 --- a/internal/data/storage/aws_storage.go +++ b/internal/data/storage/aws_storage.go @@ -121,7 +121,7 @@ func (s *awsStorage) DeletePrefix(ctx context.Context, prefix string) error { } } func (s *awsStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { - if limit < 1 || limit > 1000 { + if limit <= 0 { limit = 100 } input := &s3.ListObjectsV2Input{Bucket: aws.String(s.bucket), Prefix: aws.String(s.key(prefix)), MaxKeys: aws.Int32(int32(limit))} diff --git a/internal/data/storage/huawei_storage.go b/internal/data/storage/huawei_storage.go index ff86758..ebc1320 100644 --- a/internal/data/storage/huawei_storage.go +++ b/internal/data/storage/huawei_storage.go @@ -85,7 +85,7 @@ func (s *huaweiStorage) DeletePrefix(ctx context.Context, prefix string) error { } } func (s *huaweiStorage) List(_ context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { - if limit < 1 || limit > 1000 { + if limit <= 0 { limit = 100 } result, err := s.client.ListObjects(&obs.ListObjectsInput{ListObjsInput: obs.ListObjsInput{Prefix: s.key(prefix), MaxKeys: limit}, Bucket: s.bucket, Marker: cursor}) diff --git a/internal/data/storage/local.go b/internal/data/storage/local.go index 6c4ef34..0cf09bc 100644 --- a/internal/data/storage/local.go +++ b/internal/data/storage/local.go @@ -103,6 +103,9 @@ func composeFiles(ctx context.Context, storage biz.FileStorage, names []string, } func (s *fileStorage) resolve(name string) (string, error) { + if name == "" { + return "", fmt.Errorf("key不能为空") + } clean := filepath.Clean(strings.TrimPrefix(name, "/")) if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", fmt.Errorf("invalid storage path") @@ -146,7 +149,12 @@ func (s *fileStorage) Delete(ctx context.Context, name string) error { if err != nil { return err } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + // Match the compatible Local.DeleteFile behavior: a missing object is an error and the + // media DB record is retained when the physical file cannot be removed. + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("文件不存在") + } + if err := os.Remove(path); err != nil { return err } return nil @@ -170,7 +178,7 @@ func (s *fileStorage) DeletePrefix(ctx context.Context, prefix string) error { return nil } func (s *fileStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { - if limit < 1 || limit > 1000 { + if limit <= 0 { limit = 100 } root, err := s.resolve(strings.TrimSuffix(prefix, "/") + "/placeholder") diff --git a/internal/data/storage/qiniu_storage.go b/internal/data/storage/qiniu_storage.go index 5b4f69a..debed10 100644 --- a/internal/data/storage/qiniu_storage.go +++ b/internal/data/storage/qiniu_storage.go @@ -111,7 +111,7 @@ func (s *qiniuStorage) DeletePrefix(ctx context.Context, prefix string) error { } } func (s *qiniuStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { - if limit < 1 || limit > 1000 { + if limit <= 0 { limit = 100 } entries, _, marker, more, err := s.manager.ListFiles(s.config.Bucket, prefix, "", cursor, limit) diff --git a/internal/data/storage/s3_storage.go b/internal/data/storage/s3_storage.go index affc431..4003229 100644 --- a/internal/data/storage/s3_storage.go +++ b/internal/data/storage/s3_storage.go @@ -109,7 +109,7 @@ func (s *s3Storage) DeletePrefix(ctx context.Context, prefix string) error { return nil } func (s *s3Storage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { - if limit < 1 || limit > 1000 { + if limit <= 0 { limit = 100 } items := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{Prefix: s.key(prefix), Recursive: true, StartAfter: s.key(cursor)}) diff --git a/internal/data/storage/tencent_storage.go b/internal/data/storage/tencent_storage.go index f1284e9..f04602e 100644 --- a/internal/data/storage/tencent_storage.go +++ b/internal/data/storage/tencent_storage.go @@ -99,7 +99,7 @@ func (s *tencentStorage) DeletePrefix(ctx context.Context, prefix string) error } } func (s *tencentStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { - if limit < 1 || limit > 1000 { + if limit <= 0 { limit = 100 } result, _, err := s.client.Bucket.Get(ctx, &cos.BucketGetOptions{Prefix: s.key(prefix), Marker: cursor, MaxKeys: limit}) diff --git a/internal/data/system.go b/internal/data/system.go index 898b802..5bd4e9b 100644 --- a/internal/data/system.go +++ b/internal/data/system.go @@ -81,11 +81,14 @@ type authorityMenuPO struct { func (authorityMenuPO) TableName() string { return "sys_authority_menus" } type menuParameterPO struct { - ID uint `gorm:"primaryKey"` - MenuID uint `gorm:"column:sys_base_menu_id;index"` - Type string - Key string - Value string + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + MenuID uint `gorm:"column:sys_base_menu_id;index"` + Type string + Key string + Value string } func (menuParameterPO) TableName() string { return "sys_base_menu_parameters" } diff --git a/internal/data/system_init.go b/internal/data/system_init.go index e95a854..c65feb2 100644 --- a/internal/data/system_init.go +++ b/internal/data/system_init.go @@ -112,10 +112,16 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database return err } if err := db.Transaction(func(tx *gorm.DB) error { - authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", DataScope: 1, DefaultRouter: "dashboard"} + rootParentID := uint(0) + authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", ParentID: &rootParentID, DataScope: 1, DefaultRouter: "dashboard"} if err := tx.FirstOrCreate(&authority, authorityPO{AuthorityID: 888}).Error; err != nil { return err } + // The root role is seeded with parent_id=0. Older Kra databases used + // NULL, which makes the role disappear from the same root-only queries. + if err := tx.Model(&authorityPO{}).Where("authority_id = ? AND parent_id IS NULL", 888).Update("parent_id", 0).Error; err != nil { + return err + } menus := defaultMenus() for i := range menus { if err := tx.Where("name = ?", menus[i].Name).FirstOrCreate(&menus[i]).Error; err != nil { @@ -199,17 +205,36 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database return err } } - for _, ignored := range []ignoredAPIPO{ - {Method: "GET", Path: "/api/freshCasbin"}, {Method: "GET", Path: "/health"}, - {Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"}, - {Method: "POST", Path: "/base/captcha"}, {Method: "POST", Path: "/init/initdb"}, - {Method: "POST", Path: "/init/checkdb"}, {Method: "GET", Path: "/info/getInfoDataSource"}, - {Method: "GET", Path: "/info/getInfoPublic"}, - } { + ignoredAPIs := defaultIgnoredAPIs() + for _, ignored := range ignoredAPIs { if err := tx.FirstOrCreate(&ignored, ignored).Error; err != nil { return err } } + ignoreSet := make(map[string]struct{}, len(ignoredAPIs)) + for _, ignored := range ignoredAPIs { + ignoreSet[ignored.Method+"\x00"+ignored.Path] = struct{}{} + } + var apiRows []apiPO + if err := tx.Find(&apiRows).Error; err != nil { + return err + } + for _, api := range apiRows { + if _, ignored := ignoreSet[api.Method+"\x00"+api.Path]; ignored { + continue + } + exists, err := policyExists(tx, 888, api.Path, api.Method) + if err != nil { + return err + } + if exists { + continue + } + rule := newPolicyRule(888, api.Path, api.Method) + if err := tx.Create(&rule).Error; err != nil { + return err + } + } return nil }); err != nil { return err @@ -222,6 +247,16 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database return nil } +func defaultIgnoredAPIs() []ignoredAPIPO { + return []ignoredAPIPO{ + {Method: "GET", Path: "/api/freshCasbin"}, {Method: "GET", Path: "/health"}, + {Method: "POST", Path: "/system/reloadSystem"}, {Method: "POST", Path: "/base/login"}, + {Method: "POST", Path: "/base/captcha"}, {Method: "POST", Path: "/init/initdb"}, + {Method: "POST", Path: "/init/checkdb"}, {Method: "GET", Path: "/info/getInfoDataSource"}, + {Method: "GET", Path: "/info/getInfoPublic"}, + } +} + func defaultMenus() []menuPO { root := func(path, name, title, icon string, sort int) menuPO { return menuPO{Path: path, Name: name, Component: "view/routerHolder.vue", Title: title, Icon: icon, Sort: sort} diff --git a/internal/data/task.go b/internal/data/task.go index 395a563..c389158 100644 --- a/internal/data/task.go +++ b/internal/data/task.go @@ -116,7 +116,7 @@ func (r *taskRepo) RecordTaskLog(ctx context.Context, v *biz.TimedTaskLog) error return r.data.gormDB.WithContext(ctx).Create(&taskLogPO{TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output}).Error } func taskLogFromPO(v taskLogPO) *biz.TimedTaskLog { - return &biz.TimedTaskLog{ID: v.ID, CreatedAt: v.CreatedAt, TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output} + return &biz.TimedTaskLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output} } func (r *taskRepo) ListTaskLogs(ctx context.Context, page, size int, taskID uint, status string) ([]*biz.TimedTaskLog, int64, error) { db := r.data.gormDB.WithContext(ctx).Model(&taskLogPO{}) diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index 1326c8d..037e607 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -68,8 +68,9 @@ func TestDictionaryImportKeepsHierarchyInOneTransaction(t *testing.T) { ctx := context.Background() repo := &dictionaryRepo{data: data} parentID := uint(10) - dictionary := &biz.Dictionary{Name: "status", Type: "status", Status: true} - details := []*biz.DictionaryDetail{{ID: 10, Label: "parent", Value: "1", Status: true}, {ID: 11, Label: "child", Value: "2", ParentID: &parentID, Status: true}} + active := true + dictionary := &biz.Dictionary{Name: "status", Type: "status", Status: &active} + details := []*biz.DictionaryDetail{{ID: 10, Label: "parent", Value: "1", Status: &active}, {ID: 11, Label: "child", Value: "2", ParentID: &parentID, Status: &active}} if err := repo.ImportDictionary(ctx, dictionary, details); err != nil { t.Fatal(err) } diff --git a/internal/data/user.go b/internal/data/user.go index 8962dd6..557f26f 100644 --- a/internal/data/user.go +++ b/internal/data/user.go @@ -79,7 +79,12 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error) } func toBizAuthority(po authorityPO) biz.Authority { - return biz.Authority{AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter} + var deletedAt *time.Time + if po.DeletedAt.Valid { + value := po.DeletedAt.Time + deletedAt = &value + } + return biz.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAt, AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter} } func baseBizUser(po *userPO) *biz.User { @@ -207,7 +212,9 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b byID := make(map[uint]*biz.Menu, len(pos)) menuIDs := make([]uint, 0, len(pos)) for _, po := range pos { - menu := &biz.Menu{ID: po.ID, ParentID: po.ParentID, Path: po.Path, Name: po.Name, Hidden: po.Hidden, Component: po.Component, Sort: po.Sort, ActiveName: po.ActiveName, KeepAlive: po.KeepAlive, DefaultMenu: po.DefaultMenu, Title: po.Title, Icon: po.Icon, CloseTab: po.CloseTab, TransitionType: po.TransitionType, Children: []*biz.Menu{}} + menu := menuFromPO(po) + menu.MenuID = po.ID + menu.Children = []*biz.Menu{} menu.Parameters = []*biz.MenuParameter{} menu.Buttons = []*biz.MenuButton{} byID[po.ID] = menu @@ -220,7 +227,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b } for _, parameter := range parameters { menu := byID[parameter.MenuID] - menu.Parameters = append(menu.Parameters, &biz.MenuParameter{ID: parameter.ID, MenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter)) } var buttons []menuButtonPO if err := r.data.gormDB.WithContext(ctx).Table("sys_base_menu_btns").Joins("JOIN sys_authority_btns ON sys_authority_btns.sys_base_menu_btn_id = sys_base_menu_btns.id").Where("sys_authority_btns.authority_id = ? AND sys_authority_btns.sys_menu_id IN ?", authorityID, menuIDs).Find(&buttons).Error; err != nil { @@ -228,7 +235,9 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b } for _, button := range buttons { menu := byID[button.MenuID] - menu.Buttons = append(menu.Buttons, &biz.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: button.MenuID, AuthorityID: authorityID}) + value := menuButtonFromPO(button) + value.AuthorityID = authorityID + menu.Buttons = append(menu.Buttons, value) } } roots := make([]*biz.Menu, 0) @@ -236,7 +245,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b menu := byID[po.ID] if parent := byID[po.ParentID]; parent != nil { parent.Children = append(parent.Children, menu) - } else { + } else if po.ParentID == 0 { roots = append(roots, menu) } } @@ -302,6 +311,16 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User authorityIDs = []uint{user.AuthorityID} } if err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // The compatible registration rejects an existing non-deleted username before creating the + // account. The username column is intentionally not unique for legacy + // schema compatibility, so keep the behavioral check here rather than + // relying on a database constraint. + var existing userPO + if err := tx.Where("username = ?", user.Username).First(&existing).Error; err == nil { + return errors.New("用户名已注册") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } if err := validateAuthorities(tx, authorityIDs); err != nil { return err } diff --git a/internal/data/version.go b/internal/data/version.go index 1d7456b..0fc3b74 100644 --- a/internal/data/version.go +++ b/internal/data/version.go @@ -2,8 +2,7 @@ package data import ( "context" - "errors" - "strings" + "sort" "time" "gorm.io/gorm" @@ -38,7 +37,7 @@ func (r *versionRepo) CreateVersion(ctx context.Context, v *biz.Version) error { return nil } func (r *versionRepo) DeleteVersions(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Delete(&versionPO{}, ids).Error + return r.data.gormDB.WithContext(ctx).Where("id IN ?", ids).Delete(&versionPO{}).Error } func (r *versionRepo) FindVersion(ctx context.Context, id uint) (*biz.Version, error) { var po versionPO @@ -77,24 +76,27 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d var menus []menuPO if len(menuIDs) > 0 { if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", menuIDs).Order("sort,id").Find(&menus).Error; err != nil { - return nil, err + return nil, &biz.VersionStageError{Stage: biz.VersionStageMenus, Err: err} } } byID := map[uint]*biz.Menu{} for _, po := range menus { v := menuFromPO(po) - v.Children = []*biz.Menu{} - var btns []menuButtonPO - _ = r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", po.ID).Find(&btns).Error - for _, btn := range btns { - v.Buttons = append(v.Buttons, &biz.MenuButton{Name: btn.Name, Description: btn.Description}) - } byID[po.ID] = v } if len(menuIDs) > 0 { + var buttons []menuButtonPO + if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(&buttons).Error; err != nil { + return nil, &biz.VersionStageError{Stage: biz.VersionStageMenus, Err: err} + } + for _, button := range buttons { + if menu := byID[button.MenuID]; menu != nil { + menu.Buttons = append(menu.Buttons, &biz.MenuButton{Name: button.Name, Description: button.Description}) + } + } var parameters []menuParameterPO if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(¶meters).Error; err != nil { - return nil, err + return nil, &biz.VersionStageError{Stage: biz.VersionStageMenus, Err: err} } for _, parameter := range parameters { if menu := byID[parameter.MenuID]; menu != nil { @@ -110,6 +112,14 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d bundle.Menus = append(bundle.Menus, v) } } + var sortMenus func([]*biz.Menu) + sortMenus = func(items []*biz.Menu) { + sort.Slice(items, func(i, j int) bool { return items[i].Sort < items[j].Sort }) + for _, item := range items { + sortMenus(item.Children) + } + } + sortMenus(bundle.Menus) var cleanMenu func(*biz.Menu) cleanMenu = func(menu *biz.Menu) { menu.ID = 0 @@ -133,7 +143,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d var apis []apiPO if len(apiIDs) > 0 { if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", apiIDs).Find(&apis).Error; err != nil { - return nil, err + return nil, &biz.VersionStageError{Stage: biz.VersionStageAPIs, Err: err} } } for _, po := range apis { @@ -144,7 +154,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d var dictionaries []dictionaryPO if len(dictIDs) > 0 { if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", dictIDs).Find(&dictionaries).Error; err != nil { - return nil, err + return nil, &biz.VersionStageError{Stage: biz.VersionStageDictionaries, Err: err} } } dictionariesRepo := &dictionaryRepo{data: r.data} @@ -153,7 +163,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d v.ID = 0 details, _, err := dictionariesRepo.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: po.ID}) if err != nil { - return nil, err + return nil, &biz.VersionStageError{Stage: biz.VersionStageDictionaries, Err: err} } for _, d := range details { d.ID = 0 @@ -168,67 +178,86 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d return bundle, nil } func (r *versionRepo) ImportVersionBundle(ctx context.Context, b *biz.VersionBundle) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var addMenus func([]*biz.Menu, uint) error - addMenus = func(items []*biz.Menu, parent uint) error { - for _, item := range items { - var existing menuPO - err := tx.Where("name = ? AND path = ?", item.Name, item.Path).First(&existing).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - po := menuToPO(item) - po.ID = 0 - po.ParentID = parent - if err = tx.Create(&po).Error; err != nil { - return err - } - existing = po - for _, button := range item.Buttons { - if err = tx.Create(&menuButtonPO{Name: button.Name, Description: button.Description, MenuID: po.ID}).Error; err != nil { - return err - } - } - for _, parameter := range item.Parameters { - if err = tx.Create(&menuParameterPO{MenuID: po.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}).Error; err != nil { - return err - } - } - } else if err != nil { - return err - } - if err = addMenus(item.Children, existing.ID); err != nil { - return err - } - } - return nil + db := r.data.gormDB.WithContext(ctx) + if len(b.Menus) > 0 { + if err := db.Transaction(func(tx *gorm.DB) error { return importVersionMenus(tx, b.Menus, 0) }); err != nil { + return &biz.VersionStageError{Stage: biz.VersionStageMenus, Err: err} } - if err := addMenus(b.Menus, 0); err != nil { + } + if len(b.APIs) > 0 { + if err := db.Transaction(func(tx *gorm.DB) error { return importVersionAPIs(tx, b.APIs) }); err != nil { + return &biz.VersionStageError{Stage: biz.VersionStageAPIs, Err: err} + } + } + if len(b.Dictionaries) > 0 { + if err := db.Transaction(func(tx *gorm.DB) error { return importVersionDictionaries(tx, b.Dictionaries) }); err != nil { + return &biz.VersionStageError{Stage: biz.VersionStageDictionaries, Err: err} + } + } + return nil +} + +func importVersionMenus(tx *gorm.DB, items []*biz.Menu, parentID uint) error { + for _, item := range items { + var existing menuPO + if err := tx.Where("name = ? AND path = ?", item.Name, item.Path).First(&existing).Error; err == nil { + if err = importVersionMenus(tx, item.Children, existing.ID); err != nil { + return err + } + continue + } + po := menuToPO(item) + po.ID = 0 + po.ParentID = parentID + if err := tx.Create(&po).Error; err != nil { return err } - for _, item := range b.APIs { - po := apiPO{Path: item.Path, Description: item.Description, APIGroup: item.APIGroup, Method: strings.ToUpper(item.Method)} - if err := tx.Where("path = ? AND method = ?", po.Path, po.Method).FirstOrCreate(&po).Error; err != nil { + for _, parameter := range item.Parameters { + if err := tx.Create(&menuParameterPO{MenuID: po.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}).Error; err != nil { return err } } - for _, item := range b.Dictionaries { - var count int64 - if err := tx.Model(&dictionaryPO{}).Where("type = ?", item.Type).Count(&count).Error; err != nil { + for _, button := range item.Buttons { + if err := tx.Create(&menuButtonPO{Name: button.Name, Description: button.Description, MenuID: po.ID}).Error; err != nil { return err } - if count > 0 { - continue - } - po := dictionaryPO{Name: item.Name, Type: item.Type, Status: item.Status, Desc: item.Desc} - if err := tx.Create(&po).Error; err != nil { - return err - } - for _, detail := range item.Details { - d := dictionaryDetailPO{Label: detail.Label, Value: detail.Value, Extend: detail.Extend, Status: detail.Status, Sort: detail.Sort, DictionaryID: po.ID} - if err := tx.Create(&d).Error; err != nil { - return err - } - } } - return nil - }) + if err := importVersionMenus(tx, item.Children, po.ID); err != nil { + return err + } + } + return nil +} + +func importVersionAPIs(tx *gorm.DB, items []*biz.API) error { + for _, item := range items { + var existing apiPO + if err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(&existing).Error; err == nil { + continue + } + if err := tx.Create(&apiPO{Path: item.Path, Description: item.Description, APIGroup: item.APIGroup, Method: item.Method}).Error; err != nil { + return err + } + } + return nil +} + +func importVersionDictionaries(tx *gorm.DB, items []*biz.Dictionary) error { + for _, item := range items { + var existing dictionaryPO + if err := tx.Where("type = ?", item.Type).First(&existing).Error; err == nil { + continue + } + po := dictionaryPO{Name: item.Name, Type: item.Type, Status: item.Status, Desc: item.Desc} + if err := tx.Create(&po).Error; err != nil { + return err + } + for _, detail := range item.Details { + value := dictionaryDetailPO{Label: detail.Label, Value: detail.Value, Extend: detail.Extend, Status: detail.Status, Sort: detail.Sort, DictionaryID: po.ID} + if err := tx.Create(&value).Error; err != nil { + return err + } + } + } + return nil } diff --git a/internal/server/gin.go b/internal/server/gin.go index 1f78ea2..527a35c 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -23,7 +23,7 @@ import ( 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 { 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), servermiddleware.OperationAudit(runtime, audit)) + engine.Use(servermiddleware.RequestMeta(), servermiddleware.Recovery(audit, logger), servermiddleware.AccessLog(runtime, logger), servermiddleware.ErrorAudit(audit), servermiddleware.SecurityRateLimit(security)) prefix := "" config := runtime.Admin() @@ -35,7 +35,11 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h serverrouter.RegisterPublic(public, engine, handlers.Public) private := engine.Group(prefix) - private.Use(servermiddleware.Auth(auth), servermiddleware.MustChangePassword(), servermiddleware.AccessControl(runtime, access, audit)) + // The reference administration behavior installs operation recording after JWT, + // password and Casbin/data-scope middleware. Keeping the recorder after the + // equivalent Kra guards avoids persisting rejected unauthenticated or + // unauthorized requests as successful business operations. + private.Use(servermiddleware.Auth(auth), servermiddleware.MustChangePassword(), servermiddleware.AccessControl(runtime, access), servermiddleware.OperationAudit(runtime, audit)) serverrouter.RegisterUser(private, handlers.User) serverrouter.RegisterNavigation(private, handlers.Navigation) serverrouter.RegisterSession(private, handlers.Session) diff --git a/internal/server/handler/announcement.go b/internal/server/handler/announcement.go index 57595b0..2cd1c3b 100644 --- a/internal/server/handler/announcement.go +++ b/internal/server/handler/announcement.go @@ -35,8 +35,16 @@ func (h *Announcement) Create(c *gin.Context) { } func (h *Announcement) Delete(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - if err := h.service.Delete(c.Request.Context(), uint(id)); err != nil { + id, present, err := queryUintValue(c, "ID") + if err != nil { + httpx.Fail(c, err.Error()) + return + } + if !present { + // Preserve the legacy endpoint behavior for an omitted ID. + id = 0 + } + if err := h.service.Delete(c.Request.Context(), id); err != nil { httpx.Fail(c, "删除失败") return } @@ -78,8 +86,12 @@ func (h *Announcement) Update(c *gin.Context) { } func (h *Announcement) Find(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - item, err := h.service.Find(c.Request.Context(), uint(id)) + id, _, err := queryUintValue(c, "ID") + if err != nil { + httpx.Fail(c, err.Error()) + return + } + item, err := h.service.Find(c.Request.Context(), id) if err != nil { httpx.Fail(c, "查询失败") return @@ -88,20 +100,17 @@ func (h *Announcement) Find(c *gin.Context) { } func (h *Announcement) List(c *gin.Context) { - page, _ := strconv.Atoi(c.Query("page")) - pageSize, _ := strconv.Atoi(c.Query("pageSize")) - start, startErr := parseTime(c.Query("startCreatedAt")) - end, endErr := parseTime(c.Query("endCreatedAt")) - if startErr != nil || endErr != nil || (start == nil) != (end == nil) { - httpx.Fail(c, "创建日期范围不合法") + var req dto.AnnouncementSearchRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) return } - items, total, err := h.service.List(c.Request.Context(), page, pageSize, start, end) + items, total, err := h.service.List(c.Request.Context(), req.Page, req.PageSize, req.StartCreatedAt, req.EndCreatedAt) if err != nil { httpx.Fail(c, "获取失败") return } - httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: page, PageSize: pageSize}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") } func (h *Announcement) DataSource(c *gin.Context) { diff --git a/internal/server/handler/api.go b/internal/server/handler/api.go index eb530d7..c03c2bd 100644 --- a/internal/server/handler/api.go +++ b/internal/server/handler/api.go @@ -18,6 +18,14 @@ func (h *API) List(c *gin.Context) { httpx.Fail(c, err.Error()) return } + if req.Page == 0 { + httpx.Fail(c, "Page值不能为空") + return + } + if req.PageSize == 0 { + httpx.Fail(c, "PageSize值不能为空") + return + } items, total, err := h.service.ListAPI(c.Request.Context(), &req) if err != nil { httpx.Fail(c, "获取失败") @@ -39,6 +47,10 @@ func (h *API) Create(c *gin.Context) { httpx.Fail(c, err.Error()) return } + if message := validateAPIRequest(&req); message != "" { + httpx.Fail(c, message) + return + } created, err := h.service.CreateAPIRequest(c.Request.Context(), &req) if err != nil { httpx.Fail(c, "创建失败") @@ -52,6 +64,10 @@ func (h *API) Update(c *gin.Context) { httpx.Fail(c, err.Error()) return } + if message := validateAPIRequest(&req); message != "" { + httpx.Fail(c, message) + return + } if err := h.service.UpdateAPIRequest(c.Request.Context(), &req); err != nil { httpx.Fail(c, "修改失败") return @@ -64,7 +80,11 @@ func (h *API) Delete(c *gin.Context) { httpx.Fail(c, err.Error()) return } - if err := h.service.DeleteAPIs(c.Request.Context(), []uint{req.ID}); err != nil { + if req.ID == 0 { + httpx.Fail(c, "ID值不能为空") + return + } + if err := h.service.DeleteAPI(c.Request.Context(), req.ID); err != nil { httpx.Fail(c, "删除失败") return } @@ -88,6 +108,10 @@ func (h *API) Find(c *gin.Context) { httpx.Fail(c, err.Error()) return } + if req.ID == 0 { + httpx.Fail(c, "ID值不能为空") + return + } item, err := h.service.FindAPIResponse(c.Request.Context(), req.ID) if err != nil { httpx.Fail(c, "获取失败") @@ -156,10 +180,6 @@ func (h *API) Ignore(c *gin.Context) { httpx.Fail(c, err.Error()) return } - if req.Path == "" || req.Method == "" { - httpx.Fail(c, "参数错误") - return - } if err := h.service.SetAPIIgnored(c.Request.Context(), req.Path, req.Method, req.Flag); err != nil { httpx.Fail(c, "忽略失败") return @@ -173,12 +193,15 @@ func (h *API) ApplySync(c *gin.Context) { return } if err := h.service.ApplyAPISyncRequest(c.Request.Context(), &req); err != nil { - httpx.Fail(c, "同步失败") + httpx.Fail(c, "忽略失败") return } httpx.OK(c) } func (h *API) FreshCasbin(c *gin.Context) { + // Policies are read from casbin_rule on every authorization decision, so + // there is no in-memory enforcer cache to reload. Keep the compatible + // endpoint and success response. httpx.Write(c, httpx.CodeSuccess, gin.H{}, "刷新成功") } @@ -188,6 +211,10 @@ func (h *API) SetPolicyPaths(c *gin.Context) { httpx.Fail(c, err.Error()) return } + if req.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") + return + } if err := h.service.SetPolicyPathsRequest(c.Request.Context(), &req); err != nil { httpx.Fail(c, "更新失败") return @@ -200,6 +227,10 @@ func (h *API) PolicyPaths(c *gin.Context) { httpx.Fail(c, err.Error()) return } + if req.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") + return + } paths, err := h.service.PolicyPathResponses(c.Request.Context(), req.AuthorityID) if err != nil { httpx.Fail(c, "获取失败") @@ -207,3 +238,18 @@ func (h *API) PolicyPaths(c *gin.Context) { } httpx.Write(c, httpx.CodeSuccess, gin.H{"paths": paths}, "获取成功") } + +func validateAPIRequest(req *dto.APIRequest) string { + switch { + case req.Path == "": + return "Path值不能为空" + case req.Description == "": + return "Description值不能为空" + case req.APIGroup == "": + return "ApiGroup值不能为空" + case req.Method == "": + return "Method值不能为空" + default: + return "" + } +} diff --git a/internal/server/handler/audit.go b/internal/server/handler/audit.go index fb5da58..ea94dcf 100644 --- a/internal/server/handler/audit.go +++ b/internal/server/handler/audit.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "io" "strconv" "time" @@ -28,6 +29,15 @@ func page(c *gin.Context) (int, int) { size, _ := strconv.Atoi(c.Query("pageSize")) return value, size } + +func queryUintValue(c *gin.Context, key string) (uint, bool, error) { + raw, ok := c.GetQuery(key) + if !ok || raw == "" { + return 0, false, nil + } + value, err := strconv.ParseUint(raw, 10, 0) + return uint(value), true, err +} func IDsFromQuery(c *gin.Context) []uint { values := c.QueryArray("IDs[]") if len(values) == 0 { @@ -43,19 +53,57 @@ 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. +func auditID(c *gin.Context) (uint, error) { + if raw := c.Query("ID"); raw != "" { + value, err := strconv.ParseUint(raw, 10, 64) + return uint(value), err + } + var request dto.IDRequest + if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) { + return 0, err + } + return request.ID, nil +} + +// auditIDs mirrors auditID for bulk deletes and supports both query-array and +// JSON-body encodings used by compatible GVA pages. +func auditIDs(c *gin.Context) ([]uint, error) { + if ids := IDsFromQuery(c); len(ids) > 0 { + return ids, nil + } + var request dto.IDsRequest + if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + return request.IDs, nil +} + func (h *Audit) Operations(c *gin.Context) { - p, size := page(c) - status, _ := strconv.Atoi(c.Query("status")) - items, total, err := h.service.OperationsFilter(c.Request.Context(), p, size, c.Query("path"), c.Query("method"), status) + var req dto.OperationRecordSearchRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + items, total, err := h.service.OperationsFilter(c.Request.Context(), req.Page, req.PageSize, req.Path, req.Method, req.Status) if err != nil { httpx.Fail(c, "获取失败") return } - httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: p, PageSize: size}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") } func (h *Audit) Operation(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - item, err := h.service.Operation(c.Request.Context(), uint(id)) + var req dto.AuditIDQuery + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.ID == 0 { + httpx.Fail(c, "ID值不能为空") + return + } + item, err := h.service.Operation(c.Request.Context(), req.ID) if err != nil { httpx.Fail(c, "查询失败") return @@ -63,24 +111,28 @@ func (h *Audit) Operation(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysOperationRecord": item}, "查询成功") } func (h *Audit) DeleteOperation(c *gin.Context) { - var req dto.IDRequest - if err := c.ShouldBindJSON(&req); err != nil { + id, err := auditID(c) + if err != nil { httpx.Fail(c, err.Error()) return } - if err := h.service.DeleteOperations(c.Request.Context(), []uint{req.ID}); err != nil { + if id == 0 { + httpx.Fail(c, "删除失败") + return + } + if err := h.service.DeleteOperations(c.Request.Context(), []uint{id}); err != nil { httpx.Fail(c, "删除失败") return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Audit) DeleteOperations(c *gin.Context) { - var req dto.IDsRequest - if err := c.ShouldBindJSON(&req); err != nil { + ids, err := auditIDs(c) + if err != nil { httpx.Fail(c, err.Error()) return } - if err := h.service.DeleteOperations(c.Request.Context(), req.IDs); err != nil { + if err := h.service.DeleteOperations(c.Request.Context(), ids); err != nil { httpx.Fail(c, "批量删除失败") return } @@ -88,18 +140,25 @@ func (h *Audit) DeleteOperations(c *gin.Context) { } func (h *Audit) Logins(c *gin.Context) { - p, size := page(c) - status, _ := strconv.ParseBool(c.Query("status")) - items, total, err := h.service.LoginsFilter(c.Request.Context(), p, size, c.Query("username"), status) + var req dto.LoginLogSearchRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + items, total, err := h.service.LoginsFilter(c.Request.Context(), req.Page, req.PageSize, req.Username, req.Status) if err != nil { httpx.Fail(c, "获取失败") return } - httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: p, PageSize: size}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") } func (h *Audit) Login(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - item, err := h.service.Login(c.Request.Context(), uint(id)) + var req dto.AuditIDQuery + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + item, err := h.service.Login(c.Request.Context(), req.ID) if err != nil { httpx.Fail(c, "查询失败") return @@ -107,24 +166,28 @@ func (h *Audit) Login(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, item, "查询成功") } func (h *Audit) DeleteLogin(c *gin.Context) { - var req dto.IDRequest - if err := c.ShouldBindJSON(&req); err != nil { + id, err := auditID(c) + if err != nil { httpx.Fail(c, err.Error()) return } - if err := h.service.DeleteLogins(c.Request.Context(), []uint{req.ID}); err != nil { + if id == 0 { + httpx.Fail(c, "删除失败") + return + } + if err := h.service.DeleteLogins(c.Request.Context(), []uint{id}); err != nil { httpx.Fail(c, "删除失败") return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Audit) DeleteLogins(c *gin.Context) { - var req dto.IDsRequest - if err := c.ShouldBindJSON(&req); err != nil { + ids, err := auditIDs(c) + if err != nil { httpx.Fail(c, err.Error()) return } - if err := h.service.DeleteLogins(c.Request.Context(), req.IDs); err != nil { + if err := h.service.DeleteLogins(c.Request.Context(), ids); err != nil { httpx.Fail(c, "批量删除失败") return } @@ -133,8 +196,8 @@ func (h *Audit) DeleteLogins(c *gin.Context) { func (h *Audit) DataAccess(c *gin.Context) { var req dto.DataAccessListRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } items, total, err := h.service.DataAccessRequest(c.Request.Context(), &req) @@ -146,8 +209,8 @@ func (h *Audit) DataAccess(c *gin.Context) { } func (h *Audit) DeleteDataAccess(c *gin.Context) { var req dto.IDsRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.DeleteDataAccess(c.Request.Context(), req.IDs); err != nil { @@ -158,7 +221,14 @@ func (h *Audit) DeleteDataAccess(c *gin.Context) { } func (h *Audit) LogDates(c *gin.Context) { - data, err := h.logs.LogDates(c.Request.Context(), c.Query("month")) + month := c.Query("month") + // Month uses `binding:"required"`; an omitted or empty query is a + // generic parameter error, not the service's format-specific error. + if month == "" { + httpx.Fail(c, "参数错误") + return + } + data, err := h.logs.LogDates(c.Request.Context(), month) if err != nil { failLogViewer(c, err) return @@ -166,7 +236,12 @@ func (h *Audit) LogDates(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, data, "获取成功") } func (h *Audit) LogFiles(c *gin.Context) { - data, err := h.logs.LogFiles(c.Request.Context(), c.Query("date")) + date := c.Query("date") + if date == "" { + httpx.Fail(c, "参数错误") + return + } + data, err := h.logs.LogFiles(c.Request.Context(), date) if err != nil { failLogViewer(c, err) return @@ -174,16 +249,23 @@ func (h *Audit) LogFiles(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, data, "获取成功") } func (h *Audit) LogContent(c *gin.Context) { + date, path := c.Query("date"), c.Query("path") + if date == "" || path == "" { + httpx.Fail(c, "参数错误") + return + } var cursor *int64 if raw := c.Query("cursor"); raw != "" { value, err := strconv.ParseInt(raw, 10, 64) - if err != nil || value < 0 { + if err != nil { httpx.Fail(c, "参数错误") return } cursor = &value } - data, err := h.logs.LogContent(c.Request.Context(), c.Query("date"), c.Query("path"), cursor) + // Preserve negative cursors for the usecase to classify with the compatible + // "日志文件路径不合法" 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) return @@ -211,15 +293,24 @@ func failLogViewer(c *gin.Context, err error) { } func (h *Audit) DeleteError(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - if err := h.service.DeleteErrors(c.Request.Context(), []uint{uint(id)}); err != nil { + id, err := auditID(c) + if err != nil { + httpx.Fail(c, err.Error()) + return + } + if err := h.service.DeleteErrors(c.Request.Context(), []uint{id}); err != nil { httpx.Fail(c, "删除失败:"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Audit) DeleteErrors(c *gin.Context) { - if err := h.service.DeleteErrors(c.Request.Context(), IDsFromQuery(c)); err != nil { + ids, err := auditIDs(c) + if err != nil { + httpx.Fail(c, err.Error()) + return + } + if err := h.service.DeleteErrors(c.Request.Context(), ids); err != nil { httpx.Fail(c, "批量删除失败:"+err.Error()) return } @@ -227,8 +318,8 @@ func (h *Audit) DeleteErrors(c *gin.Context) { } func (h *Audit) UpdateError(c *gin.Context) { var req dto.ErrorRecordRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.UpdateErrorRequest(c.Request.Context(), &req); err != nil { diff --git a/internal/server/handler/authority.go b/internal/server/handler/authority.go index a1c7fc6..168cf13 100644 --- a/internal/server/handler/authority.go +++ b/internal/server/handler/authority.go @@ -1,8 +1,6 @@ package handler import ( - "strconv" - "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" @@ -17,15 +15,23 @@ func NewAuthority(service *service.AuthorityService) *Authority { return &Author func (h *Authority) List(c *gin.Context) { items, err := h.service.Authorities(c.Request.Context()) if err != nil { - httpx.Fail(c, "获取失败") + httpx.Fail(c, "获取失败"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, items, "获取成功") } func (h *Authority) Create(c *gin.Context) { var req dto.AuthorityRequest - if c.ShouldBindJSON(&req) != nil || req.AuthorityID == 0 { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") + return + } + if req.AuthorityName == "" { + httpx.Fail(c, "AuthorityName值不能为空") return } value, err := h.service.CreateAuthorityRequest(c.Request.Context(), &req) @@ -37,8 +43,20 @@ func (h *Authority) Create(c *gin.Context) { } func (h *Authority) Copy(c *gin.Context) { var req dto.CopyAuthorityRequest - if c.ShouldBindJSON(&req) != nil || req.Authority.AuthorityID == 0 || req.OldAuthorityID == 0 { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.OldAuthorityID == 0 { + httpx.Fail(c, "OldAuthorityId值不能为空") + return + } + if req.Authority.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") + return + } + if req.Authority.AuthorityName == "" { + httpx.Fail(c, "AuthorityName值不能为空") return } value, err := h.service.CopyAuthorityRequest(c.Request.Context(), &req) @@ -50,8 +68,16 @@ func (h *Authority) Copy(c *gin.Context) { } func (h *Authority) Update(c *gin.Context) { var req dto.AuthorityRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") + return + } + if req.AuthorityName == "" { + httpx.Fail(c, "AuthorityName值不能为空") return } value, err := h.service.UpdateAuthorityRequest(c.Request.Context(), &req) @@ -63,8 +89,12 @@ func (h *Authority) Update(c *gin.Context) { } func (h *Authority) Delete(c *gin.Context) { var req dto.DeleteAuthorityRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") return } if err := h.service.DeleteAuthority(c.Request.Context(), req.AuthorityID); err != nil { @@ -75,8 +105,12 @@ func (h *Authority) Delete(c *gin.Context) { } func (h *Authority) SetUsers(c *gin.Context) { var req dto.SetRoleUsersRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.AuthorityID == 0 { + httpx.Fail(c, "角色ID不能为空") return } if err := h.service.SetAuthorityUsers(c.Request.Context(), req.AuthorityID, req.UserIDs); err != nil { @@ -86,8 +120,12 @@ func (h *Authority) SetUsers(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, gin.H{}, "设置成功") } func (h *Authority) Users(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("authorityId"), 10, 64) - ids, err := h.service.AuthorityUserIDs(c.Request.Context(), uint(id)) + var req dto.SetRoleUsersRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + ids, err := h.service.AuthorityUserIDs(c.Request.Context(), req.AuthorityID) if err != nil { httpx.Fail(c, "获取失败"+err.Error()) return @@ -99,8 +137,12 @@ func (h *Authority) Users(c *gin.Context) { } func (h *Authority) SetDataScope(c *gin.Context) { var req dto.SetDataScopeRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.AuthorityID == 0 { + httpx.Fail(c, "角色ID不能为空") return } if err := h.service.SetDataScope(c.Request.Context(), req.AuthorityID, req.DataScope, req.DepartmentIDs); err != nil { @@ -110,8 +152,12 @@ func (h *Authority) SetDataScope(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, gin.H{}, "设置成功") } func (h *Authority) DataScopeDepartments(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("authorityId"), 10, 64) - ids, err := h.service.DataScopeDepartmentIDs(c.Request.Context(), uint(id)) + var req dto.SetDataScopeRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + ids, err := h.service.DataScopeDepartmentIDs(c.Request.Context(), req.AuthorityID) if err != nil { httpx.Fail(c, "获取失败"+err.Error()) return diff --git a/internal/server/handler/media.go b/internal/server/handler/media.go index 7cd90bd..e446a2d 100644 --- a/internal/server/handler/media.go +++ b/internal/server/handler/media.go @@ -25,7 +25,9 @@ func (h *Media) Upload(c *gin.Context) { } opened, err := header.Open() if err != nil { - httpx.Fail(c, "读取文件失败") + // Let the upload service surface this as the generic upload + // failure; keep the endpoint message consistent. + httpx.Fail(c, "上传文件失败") return } defer opened.Close() @@ -41,8 +43,8 @@ func (h *Media) Upload(c *gin.Context) { func (h *Media) List(c *gin.Context) { var req dto.MediaListRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } items, total, err := h.service.MediaList(c.Request.Context(), biz.MediaFilter{Page: req.Page, PageSize: req.PageSize, Keyword: req.Keyword, CategoryID: req.ClassID, Tag: req.Tag, UserID: req.UserID, StartCreatedAt: req.StartCreatedAt, EndCreatedAt: req.EndCreatedAt, OrderKey: req.OrderKey, Desc: req.Desc}) @@ -54,8 +56,8 @@ func (h *Media) List(c *gin.Context) { } func (h *Media) Delete(c *gin.Context) { var req dto.IDRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.Delete(c.Request.Context(), req.ID); err != nil { @@ -66,8 +68,8 @@ func (h *Media) Delete(c *gin.Context) { } func (h *Media) DeleteMany(c *gin.Context) { var req dto.IDsRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } failed := []uint{} @@ -97,8 +99,8 @@ func (h *Media) Find(c *gin.Context) { } func (h *Media) Rename(c *gin.Context) { var req dto.RenameMediaRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.Rename(c.Request.Context(), req.ID, req.Name); err != nil { @@ -109,8 +111,8 @@ func (h *Media) Rename(c *gin.Context) { } func (h *Media) ImportURLs(c *gin.Context) { var req []dto.ImportMediaRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.ImportURLRequests(c.Request.Context(), req); err != nil { @@ -121,8 +123,8 @@ func (h *Media) ImportURLs(c *gin.Context) { } func (h *Media) Storage(c *gin.Context) { var req dto.StorageListRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } items, next, more, err := h.service.Storage(c.Request.Context(), req.Prefix, req.Cursor, req.Limit) @@ -143,7 +145,7 @@ func (h *Media) Categories(c *gin.Context) { } func (h *Media) SaveCategory(c *gin.Context) { var req dto.CategoryRequest - if c.ShouldBindJSON(&req) != nil { + if err := c.ShouldBindJSON(&req); err != nil { httpx.Fail(c, "参数错误") return } @@ -169,10 +171,14 @@ func (h *Media) DeleteCategory(c *gin.Context) { func (h *Media) InitUpload(c *gin.Context) { claims := servermiddleware.Claims(c) var req dto.InitUploadRequest - if claims == nil || c.ShouldBindJSON(&req) != nil { + if claims == nil { httpx.Fail(c, "参数错误") return } + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } if config := h.service.MediaConfig(); config.MaxFileSize > 0 && req.FileSize > config.MaxFileSize { httpx.Fail(c, "文件超过大小上限") return @@ -195,7 +201,7 @@ func (h *Media) SaveChunk(c *gin.Context) { } opened, err := header.Open() if err != nil { - httpx.Fail(c, "读取分片失败") + httpx.Fail(c, "分片读取失败") return } defer opened.Close() @@ -208,10 +214,14 @@ func (h *Media) SaveChunk(c *gin.Context) { func (h *Media) CompleteUpload(c *gin.Context) { claims := servermiddleware.Claims(c) var req dto.CompleteUploadRequest - if claims == nil || c.ShouldBindJSON(&req) != nil { + if claims == nil { httpx.Fail(c, "参数错误") return } + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } item, err := h.service.CompleteUpload(c.Request.Context(), claims.ID, req.UploadID) if err != nil { httpx.Fail(c, err.Error()) diff --git a/internal/server/handler/menu.go b/internal/server/handler/menu.go index 3a6d851..e4e44bc 100644 --- a/internal/server/handler/menu.go +++ b/internal/server/handler/menu.go @@ -1,8 +1,6 @@ package handler import ( - "strconv" - "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" @@ -34,8 +32,28 @@ func (h *Menu) Tree(c *gin.Context) { func (h *Menu) Create(c *gin.Context) { var req dto.MenuRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.Path == "" { + httpx.Fail(c, "Path值不能为空") + return + } + if req.Name == "" { + httpx.Fail(c, "Name值不能为空") + return + } + if req.Component == "" { + httpx.Fail(c, "Component值不能为空") + return + } + if req.Sort < 0 { + httpx.Fail(c, "Sort长度或值不在合法范围,ge=0") + return + } + if req.Meta.Title == "" { + httpx.Fail(c, "Title值不能为空") return } if err := h.service.Create(c.Request.Context(), &req); err != nil { @@ -47,8 +65,28 @@ func (h *Menu) Create(c *gin.Context) { func (h *Menu) Update(c *gin.Context) { var req dto.MenuRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.Path == "" { + httpx.Fail(c, "Path值不能为空") + return + } + if req.Name == "" { + httpx.Fail(c, "Name值不能为空") + return + } + if req.Component == "" { + httpx.Fail(c, "Component值不能为空") + return + } + if req.Sort < 0 { + httpx.Fail(c, "Sort长度或值不在合法范围,ge=0") + return + } + if req.Meta.Title == "" { + httpx.Fail(c, "Title值不能为空") return } if err := h.service.Update(c.Request.Context(), &req); err != nil { @@ -60,8 +98,12 @@ func (h *Menu) Update(c *gin.Context) { func (h *Menu) Delete(c *gin.Context) { var req dto.DeleteMenuRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.ID == 0 { + httpx.Fail(c, "ID值不能为空") return } if err := h.service.Delete(c.Request.Context(), req.ID); err != nil { @@ -73,8 +115,12 @@ func (h *Menu) Delete(c *gin.Context) { func (h *Menu) Find(c *gin.Context) { var req dto.GetMenuRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.ID == 0 { + httpx.Fail(c, "ID值不能为空") return } item, err := h.service.Find(c.Request.Context(), req.ID) @@ -87,8 +133,12 @@ func (h *Menu) Find(c *gin.Context) { func (h *Menu) SetAuthorityMenus(c *gin.Context) { var req dto.SetAuthorityMenusRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") return } if err := h.service.SetAuthorityMenus(c.Request.Context(), &req); err != nil { @@ -100,8 +150,12 @@ func (h *Menu) SetAuthorityMenus(c *gin.Context) { func (h *Menu) AuthorityMenus(c *gin.Context) { var req dto.GetAuthorityMenusRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") return } menus, err := h.service.AuthorityMenus(c.Request.Context(), req.AuthorityID) @@ -113,18 +167,26 @@ func (h *Menu) AuthorityMenus(c *gin.Context) { } func (h *Menu) RoleIDs(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("menuId"), 10, 64) - ids, err := h.service.RoleIDs(c.Request.Context(), uint(id)) + var req dto.SetMenuRolesRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.MenuID == 0 { + httpx.Fail(c, "菜单ID不能为空") + return + } + ids, err := h.service.RoleIDs(c.Request.Context(), req.MenuID) if err != nil { - httpx.Fail(c, "获取失败") + httpx.Fail(c, "获取失败"+err.Error()) return } if ids == nil { ids = []uint{} } - defaultIDs, err := h.service.DefaultRouterRoleIDs(c.Request.Context(), uint(id)) + defaultIDs, err := h.service.DefaultRouterRoleIDs(c.Request.Context(), req.MenuID) if err != nil { - httpx.Fail(c, "获取失败") + httpx.Fail(c, "获取失败"+err.Error()) return } if defaultIDs == nil { @@ -135,12 +197,16 @@ func (h *Menu) RoleIDs(c *gin.Context) { func (h *Menu) SetRoles(c *gin.Context) { var req dto.SetMenuRolesRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.MenuID == 0 { + httpx.Fail(c, "菜单ID不能为空") return } if err := h.service.SetRoles(c.Request.Context(), &req); err != nil { - httpx.Fail(c, "设置失败") + httpx.Fail(c, "设置失败"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "设置成功") diff --git a/internal/server/handler/organization.go b/internal/server/handler/organization.go index 3674701..0d435bb 100644 --- a/internal/server/handler/organization.go +++ b/internal/server/handler/organization.go @@ -41,7 +41,7 @@ func (h *Organization) CreateDepartment(c *gin.Context) { httpx.Fail(c, "创建失败:"+err.Error()) return } - httpx.Write(c, httpx.CodeSuccess, nil, "创建成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "创建成功") } func (h *Organization) UpdateDepartment(c *gin.Context) { var req dto.DepartmentRequest @@ -57,7 +57,7 @@ func (h *Organization) UpdateDepartment(c *gin.Context) { httpx.Fail(c, "更新失败:"+err.Error()) return } - httpx.Write(c, httpx.CodeSuccess, nil, "更新成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "更新成功") } func (h *Organization) DeleteDepartment(c *gin.Context) { var req dto.DeleteDepartmentRequest @@ -69,7 +69,7 @@ func (h *Organization) DeleteDepartment(c *gin.Context) { httpx.Fail(c, "删除失败:"+err.Error()) return } - httpx.Write(c, httpx.CodeSuccess, nil, "删除成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Organization) FindDepartment(c *gin.Context) { var req dto.DeleteDepartmentRequest @@ -122,8 +122,12 @@ func (h *Organization) SetUserDepartments(c *gin.Context) { httpx.Fail(c, err.Error()) return } + if req.ID == 0 { + httpx.Fail(c, "用户ID不能为空") + return + } if err := h.departments.SetUserDepartments(c.Request.Context(), &req); err != nil { - httpx.Fail(c, "设置失败") + httpx.Fail(c, "设置失败:"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "设置成功") @@ -156,7 +160,7 @@ func (h *Organization) CreatePosition(c *gin.Context) { httpx.Fail(c, "创建失败:"+err.Error()) return } - httpx.Write(c, httpx.CodeSuccess, nil, "创建成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "创建成功") } func (h *Organization) UpdatePosition(c *gin.Context) { var req dto.PositionRequest @@ -172,7 +176,7 @@ func (h *Organization) UpdatePosition(c *gin.Context) { httpx.Fail(c, "更新失败:"+err.Error()) return } - httpx.Write(c, httpx.CodeSuccess, nil, "更新成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "更新成功") } func (h *Organization) DeletePosition(c *gin.Context) { var req dto.DeletePositionRequest @@ -184,7 +188,7 @@ func (h *Organization) DeletePosition(c *gin.Context) { httpx.Fail(c, "删除失败:"+err.Error()) return } - httpx.Write(c, httpx.CodeSuccess, nil, "删除成功") + httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Organization) FindPosition(c *gin.Context) { var req dto.DeletePositionRequest @@ -237,8 +241,12 @@ func (h *Organization) SetUserPositions(c *gin.Context) { httpx.Fail(c, err.Error()) return } + if req.ID == 0 { + httpx.Fail(c, "用户ID不能为空") + return + } if err := h.positions.SetUserPositions(c.Request.Context(), &req); err != nil { - httpx.Fail(c, "设置失败") + httpx.Fail(c, "设置失败:"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "设置成功") diff --git a/internal/server/handler/parameter.go b/internal/server/handler/parameter.go index e078bed..282bd09 100644 --- a/internal/server/handler/parameter.go +++ b/internal/server/handler/parameter.go @@ -82,17 +82,15 @@ func (h *Parameter) Get(c *gin.Context) { } func (h *Parameter) List(c *gin.Context) { - p, size := page(c) - start, startErr := parseTime(c.Query("startCreatedAt")) - end, endErr := parseTime(c.Query("endCreatedAt")) - if startErr != nil || endErr != nil { - httpx.Fail(c, "参数错误") + var req dto.SystemParameterSearchRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) return } - items, total, err := h.service.ParametersFilter(c.Request.Context(), p, size, c.Query("name"), c.Query("key"), start, end) + items, total, err := h.service.ParametersFilter(c.Request.Context(), req.Page, req.PageSize, req.Name, req.Key, req.StartCreatedAt, req.EndCreatedAt) if err != nil { httpx.Fail(c, "获取失败:"+err.Error()) return } - httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: p, PageSize: size}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") } diff --git a/internal/server/handler/permission.go b/internal/server/handler/permission.go index f4c590a..040f31b 100644 --- a/internal/server/handler/permission.go +++ b/internal/server/handler/permission.go @@ -18,8 +18,8 @@ func NewPermission(service *service.PermissionService) *Permission { func (h *Permission) Buttons(c *gin.Context) { var req dto.GetAuthorityButtonsRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } selected, err := h.service.SelectedButtons(c.Request.Context(), req.AuthorityID, req.MenuID) @@ -32,8 +32,8 @@ func (h *Permission) Buttons(c *gin.Context) { func (h *Permission) SetButtons(c *gin.Context) { var req dto.SetAuthorityButtonsRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.SetSelectedButtons(c.Request.Context(), req.AuthorityID, req.MenuID, req.Selected); err != nil { @@ -47,7 +47,7 @@ func (h *Permission) CanRemoveButton(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("id"), 10, 64) allowed, err := h.service.CanRemoveButton(c.Request.Context(), uint(id)) if err != nil { - httpx.Fail(c, "检查失败") + httpx.Fail(c, err.Error()) return } if !allowed { diff --git a/internal/server/handler/public.go b/internal/server/handler/public.go index 9dae495..62e9338 100644 --- a/internal/server/handler/public.go +++ b/internal/server/handler/public.go @@ -53,8 +53,16 @@ func (h *Public) Captcha(c *gin.Context) { func (h *Public) Login(c *gin.Context) { var req dto.LoginRequest - if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" { - httpx.Fail(c, "请输入用户名和密码") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.Username == "" { + httpx.Fail(c, "Username值不能为空") + return + } + if req.Password == "" { + httpx.Fail(c, "Password值不能为空") return } result, err := h.auth.Login(c.Request.Context(), &req, c.ClientIP(), c.Request.UserAgent()) @@ -109,12 +117,12 @@ func (h *Public) InitializeDatabase(engine *gin.Engine) gin.HandlerFunc { return } if initialized { - httpx.Fail(c, "数据库已初始化,无需重复初始化") + httpx.Fail(c, "已存在数据库配置") return } var input service.DatabaseInit if c.ShouldBindJSON(&input) != nil { - httpx.Fail(c, "数据库初始化参数无效") + httpx.Fail(c, "参数校验不通过") return } routes := engine.Routes() diff --git a/internal/server/handler/task.go b/internal/server/handler/task.go index 540b55c..5d30bb9 100644 --- a/internal/server/handler/task.go +++ b/internal/server/handler/task.go @@ -3,7 +3,6 @@ package handler import ( "fmt" "net/http" - "strconv" "time" "kra/internal/server/httpx" @@ -24,8 +23,8 @@ func NewTask(service *service.TaskService) *Task { func (h *Task) Create(c *gin.Context) { var req dto.TaskRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } _, err := h.service.CreateRequest(c.Request.Context(), &req) @@ -38,8 +37,8 @@ func (h *Task) Create(c *gin.Context) { func (h *Task) Update(c *gin.Context) { var req dto.TaskRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.UpdateRequest(c.Request.Context(), &req); err != nil { @@ -51,8 +50,8 @@ func (h *Task) Update(c *gin.Context) { func (h *Task) Delete(c *gin.Context) { var req dto.DeleteTaskRequest - if c.ShouldBindJSON(&req) != nil || req.ID == 0 { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.Delete(c.Request.Context(), req.ID); err != nil { @@ -64,8 +63,8 @@ func (h *Task) Delete(c *gin.Context) { func (h *Task) Toggle(c *gin.Context) { var req dto.ToggleTaskRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.Toggle(c.Request.Context(), req.ID, req.Enabled); err != nil { @@ -77,8 +76,8 @@ func (h *Task) Toggle(c *gin.Context) { func (h *Task) Trigger(c *gin.Context) { var req dto.DeleteTaskRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.Trigger(c.Request.Context(), req.ID); err != nil { @@ -89,31 +88,31 @@ func (h *Task) Trigger(c *gin.Context) { } func (h *Task) List(c *gin.Context) { - page, _ := strconv.Atoi(c.Query("page")) - size, _ := strconv.Atoi(c.Query("pageSize")) - var enabled *bool - if raw := c.Query("enabled"); raw != "" { - value, _ := strconv.ParseBool(raw) - enabled = &value + var req dto.TaskListRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return } - items, total, err := h.service.ListRequest(c.Request.Context(), page, size, c.Query("name"), c.Query("executorType"), enabled) + items, total, err := h.service.ListRequest(c.Request.Context(), req.Page, req.PageSize, req.Name, req.ExecutorType, req.Enabled) if err != nil { httpx.Fail(c, "获取失败") return } - httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") } func (h *Task) Logs(c *gin.Context) { - page, _ := strconv.Atoi(c.Query("page")) - size, _ := strconv.Atoi(c.Query("pageSize")) - taskID, _ := strconv.ParseUint(c.Query("taskId"), 10, 64) - items, total, err := h.service.Logs(c.Request.Context(), page, size, uint(taskID), c.Query("status")) + var req dto.TaskLogListRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + items, total, err := h.service.Logs(c.Request.Context(), req.Page, req.PageSize, req.TaskID, req.Status) if err != nil { httpx.Fail(c, "获取失败") return } - httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: page, PageSize: size}, "获取成功") + httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") } func (h *Task) Methods(c *gin.Context) { diff --git a/internal/server/handler/user.go b/internal/server/handler/user.go index 8fcedeb..e6a3335 100644 --- a/internal/server/handler/user.go +++ b/internal/server/handler/user.go @@ -23,8 +23,16 @@ func NewUser(user *service.UserService, auth *service.AuthService) *User { func (h *User) List(c *gin.Context) { var req dto.UserListRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.Page == 0 { + httpx.Fail(c, "Page值不能为空") + return + } + if req.PageSize == 0 { + httpx.Fail(c, "PageSize值不能为空") return } items, total, err := h.service.ListUsersRequest(c.Request.Context(), &req) @@ -37,8 +45,24 @@ func (h *User) List(c *gin.Context) { func (h *User) Create(c *gin.Context) { var req dto.UserRequest - if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.Username == "" { + httpx.Fail(c, "Username值不能为空") + return + } + if req.Password == "" { + httpx.Fail(c, "Password值不能为空") + return + } + if req.NickName == "" { + httpx.Fail(c, "NickName值不能为空") + return + } + if req.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") return } result, err := h.service.CreateUserRequest(c.Request.Context(), &req) @@ -51,8 +75,12 @@ func (h *User) Create(c *gin.Context) { func (h *User) Update(c *gin.Context) { var req dto.UserRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.ID == 0 { + httpx.Fail(c, "ID值不能为空") return } if err := h.service.UpdateUserRequest(c.Request.Context(), &req); err != nil { @@ -69,8 +97,8 @@ func (h *User) UpdateSelf(c *gin.Context) { return } var req dto.SelfUserRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } value := service.UserInput{ID: claims.ID, NickName: req.NickName, HeaderImg: req.HeaderImg, Phone: req.Phone, Email: req.Email, Enable: req.Enable} @@ -83,8 +111,12 @@ func (h *User) UpdateSelf(c *gin.Context) { func (h *User) Delete(c *gin.Context) { var req dto.IDRequest - if c.ShouldBind(&req) != nil || req.ID == 0 { - httpx.Fail(c, "参数错误") + if err := c.ShouldBind(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.ID == 0 { + httpx.Fail(c, "ID值不能为空") return } claims := middleware.Claims(c) @@ -101,8 +133,8 @@ func (h *User) Delete(c *gin.Context) { func (h *User) ResetPassword(c *gin.Context) { var req dto.ResetPasswordRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.ResetPassword(c.Request.Context(), req.ID, req.Password); err != nil { @@ -115,8 +147,20 @@ func (h *User) ResetPassword(c *gin.Context) { func (h *User) ChangePassword(c *gin.Context) { claims := middleware.Claims(c) var req dto.ChangePasswordRequest - if claims == nil || c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if claims == nil { + httpx.NoAuth(c, "未登录或非法访问") + return + } + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.Password == "" { + httpx.Fail(c, "Password值不能为空") + return + } + if req.NewPassword == "" { + httpx.Fail(c, "NewPassword值不能为空") return } if err := h.service.ChangePassword(c.Request.Context(), claims.ID, req.Password, req.NewPassword); err != nil { @@ -129,8 +173,12 @@ func (h *User) ChangePassword(c *gin.Context) { func (h *User) SetSelfSetting(c *gin.Context) { claims := middleware.Claims(c) var setting map[string]any - if claims == nil || c.ShouldBindJSON(&setting) != nil { - httpx.Fail(c, "参数错误") + if claims == nil { + httpx.NoAuth(c, "未登录或非法访问") + return + } + if err := c.ShouldBindJSON(&setting); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.SetUserSetting(c.Request.Context(), claims.ID, setting); err != nil { @@ -142,8 +190,8 @@ func (h *User) SetSelfSetting(c *gin.Context) { func (h *User) SetAuthorities(c *gin.Context) { var req dto.UserAuthoritiesRequest - if c.ShouldBindJSON(&req) != nil || req.ID == 0 || len(req.AuthorityIDs) == 0 { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.SetUserAuthorities(c.Request.Context(), req.ID, req.AuthorityIDs); err != nil { @@ -156,11 +204,19 @@ func (h *User) SetAuthorities(c *gin.Context) { func (h *User) SwitchAuthority(c *gin.Context) { claims := middleware.Claims(c) var req dto.SwitchAuthorityRequest - if claims == nil || c.ShouldBindJSON(&req) != nil || req.AuthorityID == 0 { - httpx.Fail(c, "参数错误") + if claims == nil { + httpx.NoAuth(c, "未登录或非法访问") return } - login, err := h.auth.SwitchAuthority(c.Request.Context(), claims.ID, req.AuthorityID) + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) + return + } + if req.AuthorityID == 0 { + httpx.Fail(c, "AuthorityId值不能为空") + return + } + login, err := h.auth.SwitchAuthority(c.Request.Context(), claims, req.AuthorityID) if err != nil { httpx.Fail(c, err.Error()) return diff --git a/internal/server/handler/version.go b/internal/server/handler/version.go index 70ee06c..313eba2 100644 --- a/internal/server/handler/version.go +++ b/internal/server/handler/version.go @@ -1,11 +1,13 @@ package handler import ( + "errors" "fmt" "net/http" "strconv" "time" + "kra/internal/biz" "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" @@ -17,10 +19,18 @@ type Version struct{ service *service.VersionService } func NewVersion(service *service.VersionService) *Version { return &Version{service: service} } +func versionStage(err error) (biz.VersionStage, bool) { + var stageErr *biz.VersionStageError + if !errors.As(err, &stageErr) { + return "", false + } + return stageErr.Stage, true +} + func (h *Version) Delete(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) if err := h.service.DeleteVersions(c.Request.Context(), []uint{uint(id)}); err != nil { - httpx.Fail(c, "删除失败") + httpx.Fail(c, "删除失败:"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") @@ -33,7 +43,7 @@ func (h *Version) DeleteMany(c *gin.Context) { ids = append(ids, uint(id)) } if err := h.service.DeleteVersions(c.Request.Context(), ids); err != nil { - httpx.Fail(c, "删除失败") + httpx.Fail(c, "批量删除失败:"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "批量删除成功") @@ -42,60 +52,99 @@ func (h *Version) Find(c *gin.Context) { id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) item, err := h.service.Version(c.Request.Context(), uint(id)) if err != nil { - httpx.Fail(c, "查询失败") + httpx.Fail(c, "查询失败:"+err.Error()) return } httpx.OKWithData(c, item) } func (h *Version) List(c *gin.Context) { - p, size := page(c) - createdAtRange := make([]*time.Time, 0, 2) - for _, raw := range c.QueryArray("createdAtRange[]") { - value, parseErr := parseTime(raw) - if parseErr != nil { - httpx.Fail(c, parseErr.Error()) - return - } - createdAtRange = append(createdAtRange, value) - } - items, total, err := h.service.Versions(c.Request.Context(), p, size, c.Query("versionName"), c.Query("versionCode"), createdAtRange) - if err != nil { - httpx.Fail(c, "获取失败") + var req dto.VersionSearchRequest + if err := c.ShouldBindQuery(&req); err != nil { + httpx.Fail(c, err.Error()) return } - httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: p, PageSize: size}, "获取成功") + name, code := "", "" + if req.VersionName != nil { + name = *req.VersionName + } + if req.VersionCode != nil { + code = *req.VersionCode + } + createdAtRange := make([]*time.Time, 0, len(req.CreatedAtRange)) + for i := range req.CreatedAtRange { + createdAtRange = append(createdAtRange, &req.CreatedAtRange[i]) + } + items, total, err := h.service.Versions(c.Request.Context(), req.Page, req.PageSize, name, code, createdAtRange) + if err != nil { + httpx.Fail(c, "获取失败:"+err.Error()) + return + } + httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功") } func (h *Version) Export(c *gin.Context) { var req dto.ExportVersionRequest - if c.ShouldBindJSON(&req) != nil || req.VersionName == "" || req.VersionCode == "" { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, err.Error()) return } if err := h.service.Export(c.Request.Context(), req.VersionName, req.VersionCode, req.Description, req.MenuIDs, req.APIIDs, req.DictIDs); err != nil { - httpx.Fail(c, "创建发版失败:"+err.Error()) + switch stage, _ := versionStage(err); stage { + case biz.VersionStageMenus: + httpx.Fail(c, "获取菜单数据失败:"+err.Error()) + case biz.VersionStageAPIs: + httpx.Fail(c, "获取API数据失败:"+err.Error()) + case biz.VersionStageDictionaries: + httpx.Fail(c, "获取字典数据失败:"+err.Error()) + case biz.VersionStageJSON: + httpx.Fail(c, "JSON序列化失败:"+err.Error()) + case biz.VersionStageSave: + httpx.Fail(c, "保存版本记录失败:"+err.Error()) + default: + httpx.Fail(c, "创建发版失败:"+err.Error()) + } return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "创建发版成功") } func (h *Version) Download(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + rawID := c.Query("ID") + if rawID == "" { + httpx.Fail(c, "版本ID不能为空") + return + } + id, _ := strconv.ParseUint(rawID, 10, 64) raw, code, err := h.service.VersionData(c.Request.Context(), uint(id)) if err != nil { - httpx.Fail(c, "下载失败") + httpx.Fail(c, "获取版本记录失败:"+err.Error()) return } name := fmt.Sprintf("version_%s_%s.json", code, time.Now().Format("20060102150405")) + c.Header("Content-Type", "application/json") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", name)) + c.Header("Content-Length", strconv.Itoa(len(raw))) c.Data(http.StatusOK, "application/json; charset=utf-8", []byte(raw)) } func (h *Version) Import(c *gin.Context) { var req dto.ImportVersionRequest - if c.ShouldBindJSON(&req) != nil { - httpx.Fail(c, "参数错误") + if err := c.ShouldBindJSON(&req); err != nil { + httpx.Fail(c, "解析JSON数据失败:"+err.Error()) return } if err := h.service.ImportRequest(c.Request.Context(), &req); err != nil { - httpx.Fail(c, "导入失败:"+err.Error()) + if errors.Is(err, biz.ErrInvalidVersion) { + httpx.Fail(c, "版本信息格式错误") + return + } + switch stage, _ := versionStage(err); stage { + case biz.VersionStageMenus: + httpx.Fail(c, "导入菜单失败: "+err.Error()) + case biz.VersionStageAPIs: + httpx.Fail(c, "导入API失败: "+err.Error()) + case biz.VersionStageDictionaries: + httpx.Fail(c, "导入字典失败: "+err.Error()) + default: + httpx.Fail(c, "导入失败:"+err.Error()) + } return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "导入成功") diff --git a/internal/server/middleware/access.go b/internal/server/middleware/access.go index 8afceb2..59b579e 100644 --- a/internal/server/middleware/access.go +++ b/internal/server/middleware/access.go @@ -7,12 +7,11 @@ import ( "kra/internal/conf" "kra/internal/server/httpx" "kra/internal/service" - "kra/internal/service/dto" "github.com/gin-gonic/gin" ) -func AccessControl(runtime *conf.Runtime, access *service.AccessControlService, audit *service.AuditRecorder) gin.HandlerFunc { +func AccessControl(runtime *conf.Runtime, access *service.AccessControlService) gin.HandlerFunc { return func(c *gin.Context) { claims := Claims(c) if claims == nil { @@ -29,9 +28,6 @@ func AccessControl(runtime *conf.Runtime, access *service.AccessControlService, } allowed, err := access.Authorize(c.Request.Context(), claims.AuthorityID, policyPath, c.Request.Method) if err != nil || !allowed { - requestID, _ := c.Get("request_id") - requestIDText, _ := requestID.(string) - _ = audit.RecordDataAccessRequest(c.Request.Context(), &dto.DataAccessRecordRequest{EventType: "blocked_access", Operation: c.Request.Method, UserID: claims.ID, AuthorityID: claims.AuthorityID, RequestID: requestIDText, Method: c.Request.Method, Path: path, Detail: "Casbin policy denied the request"}) httpx.Write(c, httpx.CodeError, gin.H{}, "权限不足") c.Abort() return diff --git a/internal/server/middleware/audit.go b/internal/server/middleware/audit.go index 7572ded..adec34b 100644 --- a/internal/server/middleware/audit.go +++ b/internal/server/middleware/audit.go @@ -2,7 +2,11 @@ package middleware import ( "bytes" + "encoding/json" "io" + "net/http" + "net/url" + "strconv" "strings" "time" @@ -21,11 +25,13 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H return } var requestBody []byte - maxBytes := 32768 + maxBytes := 1024 if config := runtime.Admin(); config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 { maxBytes = int(config.Zap.AccessLogMaxBytes) } - if c.Request.Body != nil { + if c.Request.Method == http.MethodGet { + requestBody = operationQueryBody(c.Request.URL.RawQuery) + } else if c.Request.Body != nil { if strings.Contains(c.GetHeader("Content-Type"), "multipart/form-data") { requestBody = []byte("[文件]") } else { @@ -33,23 +39,95 @@ func OperationAudit(runtime *conf.Runtime, service *service.AuditRecorder) gin.H c.Request.Body = io.NopCloser(bytes.NewReader(requestBody)) } } - writer := &captureWriter{ResponseWriter: c.Writer, maxBytes: maxBytes} + // The reference middleware captures up to the global 1 MiB response + // 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) if claims := Claims(c); claims != nil { userID = claims.ID + } else if value, err := strconv.ParseUint(c.GetHeader("x-user-id"), 10, 64); err == nil { + userID = uint(value) } requestID, _ := c.Get("request_id") - status, errorMessage := c.Writer.Status(), "" - if status >= 400 { - errorMessage = writer.body.String() + status := c.Writer.Status() + responseBody := writer.body.String() + if isDownloadResponse(c) && len(responseBody) > maxBytes { + responseBody = "[超出记录长度]" } - _ = service.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: redactJSONLimit(requestBody, maxBytes), Response: writer.body.String(), UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}) + errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String() + _ = service.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}) } } +func operationQueryBody(raw string) []byte { + query, _ := url.QueryUnescape(raw) + values := make(map[string]string) + for _, item := range strings.Split(query, "&") { + parts := strings.Split(item, "=") + if len(parts) == 2 { + values[parts[0]] = parts[1] + } + } + body, _ := json.Marshal(&values) + return body +} + +func operationRequestBody(raw []byte, contentType string, limit int) string { + if strings.Contains(contentType, "multipart/form-data") { + return "[文件]" + } + text := string(raw) + if strings.Contains(strings.ToLower(contentType), "json") && text != "" { + var value any + if json.Unmarshal(raw, &value) == nil { + maskOperationBody(value) + if encoded, err := json.Marshal(value); err == nil { + text = string(encoded) + } + } + } + if len(text) > limit { + return "[超出记录长度]" + } + return text +} + +func maskOperationBody(value any) { + switch current := value.(type) { + case map[string]any: + for key, item := range current { + normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", "")) + if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" { + current[key] = "***" + continue + } + maskOperationBody(item) + } + case []any: + for _, item := range current { + maskOperationBody(item) + } + } +} + +func isDownloadResponse(c *gin.Context) bool { + header := c.Writer.Header() + return strings.Contains(header.Get("Pragma"), "public") || + strings.Contains(header.Get("Expires"), "0") || + strings.Contains(header.Get("Cache-Control"), "must-revalidate, post-check=0, pre-check=0") || + strings.Contains(header.Get("Content-Type"), "application/force-download") || + strings.Contains(header.Get("Content-Type"), "application/octet-stream") || + strings.Contains(header.Get("Content-Type"), "application/vnd.ms-excel") || + strings.Contains(header.Get("Content-Type"), "application/download") || + strings.Contains(header.Get("Content-Disposition"), "attachment") || + strings.Contains(header.Get("Content-Transfer-Encoding"), "binary") +} + // recordsOperation mirrors the routes on which operation records are enabled. // Matching by suffix keeps the behavior stable when router-prefix is configured. func recordsOperation(method, path string) bool { @@ -82,7 +160,7 @@ var operationRoutes = func() map[string]struct{} { "DELETE /sysVersion/deleteSysVersion", "DELETE /sysVersion/deleteSysVersionByIds", "POST /sysVersion/exportVersion", "POST /sysVersion/importVersion", "POST /sysExportTemplate/createSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplate", "DELETE /sysExportTemplate/deleteSysExportTemplateByIds", "PUT /sysExportTemplate/updateSysExportTemplate", "POST /sysExportTemplate/importExcel", "DELETE /sysError/deleteSysError", "DELETE /sysError/deleteSysErrorByIds", "PUT /sysError/updateSysError", - "DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /sysOperationRecord/deleteSysOperationRecord", "DELETE /sysOperationRecord/deleteSysOperationRecordByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds", + "DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds", "POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask", "POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail", } diff --git a/internal/server/middleware/error_audit.go b/internal/server/middleware/error_audit.go index 2c53c77..cbbd16b 100644 --- a/internal/server/middleware/error_audit.go +++ b/internal/server/middleware/error_audit.go @@ -27,7 +27,7 @@ func ErrorAudit(audit *service.AuditRecorder) gin.HandlerFunc { return } requestID, _ := c.Get("request_id") - _ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: response.Msg, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), Status: "未解决"}) + _ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: response.Msg, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")}) } } diff --git a/internal/server/middleware/recovery.go b/internal/server/middleware/recovery.go index 9b2bb7a..ad7010a 100644 --- a/internal/server/middleware/recovery.go +++ b/internal/server/middleware/recovery.go @@ -36,7 +36,7 @@ func Recovery(audit *service.AuditRecorder, logger *slog.Logger) gin.HandlerFunc logger.ErrorContext(c.Request.Context(), "recovery from panic", "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"), Status: "未解决"}) + _ = audit.CreateErrorRequest(c.Request.Context(), &dto.ErrorRecordRequest{Form: c.Request.URL.Path, Info: info, Level: "error", RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id")}) if brokenPipe { if err, ok := panicValue.(error); ok { _ = c.Error(err) diff --git a/internal/service/announcement.go b/internal/service/announcement.go index 15d09f9..7796f96 100644 --- a/internal/service/announcement.go +++ b/internal/service/announcement.go @@ -24,11 +24,7 @@ func NewAnnouncementService(uc *biz.AnnouncementUsecase) *AnnouncementService { } func announcementDTO(item *biz.Announcement) *dto.AnnouncementResponse { - attachments := any([]any{}) - if len(item.Attachments) > 0 { - _ = json.Unmarshal(item.Attachments, &attachments) - } - return &dto.AnnouncementResponse{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, DeletedAt: nil, Title: item.Title, Content: item.Content, UserID: item.UserID, Attachments: attachments} + return &dto.AnnouncementResponse{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, DeletedAt: nil, Title: item.Title, Content: item.Content, UserID: item.UserID, Attachments: json.RawMessage(item.Attachments)} } func announcementDO(in AnnouncementInput) *biz.Announcement { diff --git a/internal/service/api.go b/internal/service/api.go index 2a87b16..e3fa952 100644 --- a/internal/service/api.go +++ b/internal/service/api.go @@ -63,7 +63,10 @@ func (s *APIService) AllAPI(ctx context.Context, strict bool) ([]*dto.APIRespons } func (s *APIService) Groups(ctx context.Context) ([]string, map[string]string, error) { - items, err := s.AllAPI(ctx, false) + // The compatible group endpoint uses an unqualified Find, whose natural order is + // the primary-key ascending order. GetAllApis intentionally uses id DESC, + // so do not reuse it here or the group/dropdown order changes. + items, _, err := s.uc.ListAPIs(ctx, 0, 0, &biz.API{OrderKey: "id"}) if err != nil { return nil, nil, err } @@ -133,15 +136,9 @@ func (s *APIService) SyncAPIResponses(ctx context.Context, routes []dto.APIReque items := make([]*biz.API, 0, len(routes)) for i := range routes { routes[i].Path = s.NormalizeRoutePath(routes[i].Path) - if routes[i].APIGroup == "" || routes[i].Description == "" { - group, description := routeMetadata(routes[i].Method, routes[i].Path) - if routes[i].APIGroup == "" { - routes[i].APIGroup = group - } - if routes[i].Description == "" { - routes[i].Description = description - } - } + // The compatible sync endpoint compares Gin's route table directly, so newly + // discovered routes carry only path and method. Group/description are + // intentionally left empty for the operator to fill in the sync dialog. items = append(items, apiDomain(&routes[i])) } return s.SyncAPIs(ctx, items) @@ -150,6 +147,9 @@ func (s *APIService) SyncAPIResponses(ctx context.Context, routes []dto.APIReque func (s *APIService) DeleteAPIs(ctx context.Context, ids []uint) error { return s.uc.DeleteAPIs(ctx, ids) } +func (s *APIService) DeleteAPI(ctx context.Context, id uint) error { + return s.uc.DeleteAPI(ctx, id) +} func (s *APIService) APIRoleIDs(ctx context.Context, path, method string) ([]uint, error) { return s.uc.APIRoleIDs(ctx, path, method) } diff --git a/internal/service/audit.go b/internal/service/audit.go index 41680a8..0a31a27 100644 --- a/internal/service/audit.go +++ b/internal/service/audit.go @@ -29,7 +29,7 @@ func opDTO(v *biz.OperationRecord) *dto.OperationRecordResponse { if v.User != nil { user = convertUser(v.User) } - return &dto.OperationRecordResponse{ID: v.ID, CreatedAt: v.CreatedAt, DeletedAt: nil, IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID, User: user} + return &dto.OperationRecordResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID, User: user} } func (s *AuditService) Operations(ctx context.Context, page, size int, q *biz.OperationRecord) ([]*dto.OperationRecordResponse, int64, error) { items, total, err := s.uc.ListOperations(ctx, page, size, q) @@ -67,7 +67,7 @@ func loginDTO(v *biz.LoginLog) *dto.LoginLogResponse { if v.User != nil { user = convertUser(v.User) } - return &dto.LoginLogResponse{ID: v.ID, CreatedAt: v.CreatedAt, DeletedAt: nil, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID, User: user} + return &dto.LoginLogResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID, User: user} } func (s *AuditService) Logins(ctx context.Context, page, size int, q *biz.LoginLog) ([]*dto.LoginLogResponse, int64, error) { items, total, err := s.uc.ListLogins(ctx, page, size, q) @@ -98,10 +98,10 @@ func (s *AuditRecorder) RecordDataAccessRequest(ctx context.Context, value *dto. return s.RecordDataAccess(ctx, &biz.DataAccessLog{EventType: value.EventType, Operation: value.Operation, UserID: value.UserID, AuthorityID: value.AuthorityID, RequestID: value.RequestID, Method: value.Method, Path: value.Path, Detail: value.Detail}) } func (s *AuditService) DataAccessRequest(ctx context.Context, req *dto.DataAccessListRequest) ([]*dto.DataAccessLogResponse, int64, error) { - return s.DataAccess(ctx, req.Page, req.PageSize, &biz.DataAccessLog{EventType: req.EventType, TargetTable: req.TargetTable, UserID: req.UserID}) + return s.DataAccess(ctx, req.Page, req.PageSize, &biz.DataAccessLog{EventType: req.EventType, TargetTable: req.TargetTable}) } func dataAccessDTO(v *biz.DataAccessLog) *dto.DataAccessLogResponse { - return &dto.DataAccessLogResponse{ID: v.ID, CreatedAt: v.CreatedAt, DeletedAt: nil, EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail} + return &dto.DataAccessLogResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail} } func (s *AuditService) DataAccess(ctx context.Context, page, size int, q *biz.DataAccessLog) ([]*dto.DataAccessLogResponse, int64, error) { items, total, err := s.uc.ListDataAccess(ctx, page, size, q) diff --git a/internal/service/audit_error.go b/internal/service/audit_error.go index 2cd9087..3bd2baf 100644 --- a/internal/service/audit_error.go +++ b/internal/service/audit_error.go @@ -9,7 +9,7 @@ import ( ) func errorDTO(v *biz.ErrorRecord) *dto.ErrorRecordResponse { - return &dto.ErrorRecordResponse{ID: v.ID, CreatedAt: v.CreatedAt, DeletedAt: nil, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status} + return &dto.ErrorRecordResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status} } func (s *AuditRecorder) CreateError(ctx context.Context, v *biz.ErrorRecord) error { return s.uc.CreateError(ctx, v) diff --git a/internal/service/authentication.go b/internal/service/authentication.go index 659a94a..a6ef7e6 100644 --- a/internal/service/authentication.go +++ b/internal/service/authentication.go @@ -34,8 +34,8 @@ func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest, ip, agen return loginResult(value), nil } -func (s *AuthService) SwitchAuthority(ctx context.Context, id, authorityID uint) (*LoginResult, error) { - value, err := s.uc.SwitchAuthority(ctx, id, authorityID) +func (s *AuthService) SwitchAuthority(ctx context.Context, claims *biz.AuthClaims, authorityID uint) (*LoginResult, error) { + value, err := s.uc.SwitchAuthority(ctx, claims, authorityID) if err != nil { return nil, err } diff --git a/internal/service/authority.go b/internal/service/authority.go index 439b833..aca4159 100644 --- a/internal/service/authority.go +++ b/internal/service/authority.go @@ -28,7 +28,7 @@ func authorityDomain(req *dto.AuthorityRequest) *biz.Authority { } func authorityResponse(value *biz.Authority) *dto.AuthorityResponse { - return &dto.AuthorityResponse{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} + return &dto.AuthorityResponse{CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: value.DeletedAt, AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: []*dto.AuthorityResponse{}, Menus: []any{}, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} } func (s *AuthorityService) Authorities(ctx context.Context) ([]*dto.AuthorityResponse, error) { diff --git a/internal/service/dictionary.go b/internal/service/dictionary.go index 955a3d9..4615204 100644 --- a/internal/service/dictionary.go +++ b/internal/service/dictionary.go @@ -14,11 +14,7 @@ func NewDictionaryService(uc *biz.DictionaryUsecase) *DictionaryService { } func dictionaryDomain(value *dto.DictionaryRequest) *biz.Dictionary { - status := true - if value.Status != nil { - status = *value.Status - } - return &biz.Dictionary{ID: value.ID, Name: value.Name, Type: value.Type, Status: status, Desc: value.Description, ParentID: value.ParentID} + return &biz.Dictionary{ID: value.ID, Name: value.Name, Type: value.Type, Status: value.Status, Desc: value.Description, ParentID: value.ParentID} } func (s *DictionaryService) CreateDictionaryRequest(ctx context.Context, req *dto.DictionaryRequest) (*dto.DictionaryResponse, error) { value := dictionaryDomain(req) @@ -31,13 +27,19 @@ func (s *DictionaryService) UpdateDictionaryRequest(ctx context.Context, req *dt return s.UpdateDictionary(ctx, dictionaryDomain(req)) } func dictionaryDTO(v *biz.Dictionary) *dto.DictionaryResponse { - children := make([]*dto.DictionaryResponse, 0, len(v.Children)) - for _, x := range v.Children { - children = append(children, dictionaryDTO(x)) + var children []*dto.DictionaryResponse + if v.Children != nil { + children = make([]*dto.DictionaryResponse, 0, len(v.Children)) + for _, x := range v.Children { + children = append(children, dictionaryDTO(x)) + } } - details := make([]*dto.DictionaryDetailResponse, 0, len(v.Details)) - for _, x := range v.Details { - details = append(details, detailDTO(x)) + var details []*dto.DictionaryDetailResponse + if v.Details != nil { + details = make([]*dto.DictionaryDetailResponse, 0, len(v.Details)) + for _, x := range v.Details { + details = append(details, detailDTO(x)) + } } return &dto.DictionaryResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Name: v.Name, Type: v.Type, Status: v.Status, Desc: v.Desc, ParentID: v.ParentID, Children: children, SysDictionaryDetails: details} } @@ -87,11 +89,7 @@ func (s *DictionaryService) DeleteDictionary(ctx context.Context, id uint) error } func detailDomain(value *dto.DictionaryDetailRequest) *biz.DictionaryDetail { - status := true - if value.Status != nil { - status = *value.Status - } - return &biz.DictionaryDetail{ID: value.ID, Label: value.Label, Value: value.Value, Extend: value.Extend, Status: status, Sort: value.Sort, DictionaryID: value.DictionaryID, ParentID: value.ParentID, Level: value.Level, Path: value.Path} + return &biz.DictionaryDetail{ID: value.ID, Label: value.Label, Value: value.Value, Extend: value.Extend, Status: value.Status, Sort: value.Sort, DictionaryID: value.DictionaryID, ParentID: value.ParentID, Level: value.Level, Path: value.Path} } func (s *DictionaryService) CreateDictionaryDetailRequest(ctx context.Context, req *dto.DictionaryDetailRequest) error { return s.CreateDictionaryDetail(ctx, detailDomain(req)) @@ -100,11 +98,15 @@ func (s *DictionaryService) UpdateDictionaryDetailRequest(ctx context.Context, r return s.UpdateDictionaryDetail(ctx, detailDomain(req)) } func detailDTO(v *biz.DictionaryDetail) *dto.DictionaryDetailResponse { - children := make([]*dto.DictionaryDetailResponse, 0, len(v.Children)) - for _, x := range v.Children { - children = append(children, detailDTO(x)) + var children []*dto.DictionaryDetailResponse + if v.Children != nil { + children = make([]*dto.DictionaryDetailResponse, 0, len(v.Children)) + for _, x := range v.Children { + children = append(children, detailDTO(x)) + } } - return &dto.DictionaryDetailResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Label: v.Label, Value: v.Value, Extend: v.Extend, Status: v.Status, Sort: v.Sort, DictionaryID: v.DictionaryID, ParentID: v.ParentID, Level: v.Level, Path: v.Path, Disabled: !v.Status, Children: children} + disabled := v.Status != nil && !*v.Status + return &dto.DictionaryDetailResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Label: v.Label, Value: v.Value, Extend: v.Extend, Status: v.Status, Sort: v.Sort, DictionaryID: v.DictionaryID, ParentID: v.ParentID, Level: v.Level, Path: v.Path, Disabled: disabled, Children: children} } func (s *DictionaryService) DictionaryDetails(ctx context.Context, page, size int, filter biz.DictionaryDetailFilter) ([]*dto.DictionaryDetailResponse, int64, error) { items, total, err := s.uc.ListDictionaryDetails(ctx, page, size, filter) diff --git a/internal/service/dto/announcement.go b/internal/service/dto/announcement.go index dd1c574..4070d83 100644 --- a/internal/service/dto/announcement.go +++ b/internal/service/dto/announcement.go @@ -13,15 +13,24 @@ type AnnouncementRequest struct { Attachments json.RawMessage `json:"attachments"` } +type AnnouncementSearchRequest struct { + Page int `form:"page"` + PageSize int `form:"pageSize"` + StartCreatedAt *time.Time `form:"startCreatedAt"` + EndCreatedAt *time.Time `form:"endCreatedAt"` +} + type AnnouncementResponse struct { - ID uint `json:"ID"` - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` - Title string `json:"title"` - Content string `json:"content"` - UserID *uint `json:"userID"` - Attachments any `json:"attachments"` + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + // Keep the soft-delete slot in the transport shape while omitting it from + // JSON responses. + DeletedAt any `json:"-"` + Title string `json:"title"` + Content string `json:"content"` + UserID *uint `json:"userID"` + Attachments any `json:"attachments"` } type SelectOptionResponse struct { diff --git a/internal/service/dto/api.go b/internal/service/dto/api.go index 29ae7ae..6f8cb1c 100644 --- a/internal/service/dto/api.go +++ b/internal/service/dto/api.go @@ -25,7 +25,7 @@ type APIResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` Path string `json:"path"` Description string `json:"description"` APIGroup string `json:"apiGroup"` diff --git a/internal/service/dto/audit.go b/internal/service/dto/audit.go index c43da37..8f5e9ed 100644 --- a/internal/service/dto/audit.go +++ b/internal/service/dto/audit.go @@ -34,11 +34,30 @@ type DataAccessListRequest struct { PageSize int `json:"pageSize"` EventType string `json:"eventType"` TargetTable string `json:"targetTable"` - UserID uint `json:"userId"` } + +type OperationRecordSearchRequest struct { + Page int `form:"page"` + PageSize int `form:"pageSize"` + Path string `form:"path"` + Method string `form:"method"` + Status int `form:"status"` +} + +type LoginLogSearchRequest struct { + Page int `form:"page"` + PageSize int `form:"pageSize"` + Username string `form:"username"` + Status bool `form:"status"` +} + +type AuditIDQuery struct { + ID uint `form:"ID"` +} + type ErrorRecordRequest struct { ID uint `json:"ID"` - Form string `json:"form"` + Form string `json:"form" binding:"required"` Info string `json:"info"` Level string `json:"level"` RequestID string `json:"request_id"` @@ -50,7 +69,8 @@ type ErrorRecordRequest struct { type OperationRecordResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` - DeletedAt any `json:"DeletedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt any `json:"-"` IP string `json:"ip"` Method string `json:"method"` Path string `json:"path"` @@ -70,7 +90,8 @@ type OperationRecordResponse struct { type LoginLogResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` - DeletedAt any `json:"DeletedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt any `json:"-"` Username string `json:"username"` IP string `json:"ip"` Status bool `json:"status"` @@ -83,7 +104,8 @@ type LoginLogResponse struct { type DataAccessLogResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` - DeletedAt any `json:"DeletedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt any `json:"-"` EventType string `json:"eventType"` TargetTable string `json:"targetTable"` Operation string `json:"operation"` @@ -99,7 +121,8 @@ type DataAccessLogResponse struct { type ErrorRecordResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` - DeletedAt any `json:"DeletedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt any `json:"-"` Form string `json:"form"` Info string `json:"info"` Level string `json:"level"` diff --git a/internal/service/dto/authority.go b/internal/service/dto/authority.go index d65a2ee..e93a6d5 100644 --- a/internal/service/dto/authority.go +++ b/internal/service/dto/authority.go @@ -1,5 +1,7 @@ package dto +import "time" + type AuthorityRequest struct { AuthorityID uint `json:"authorityId"` AuthorityName string `json:"authorityName"` @@ -17,16 +19,19 @@ type DeleteAuthorityRequest struct { AuthorityID uint `json:"authorityId"` } type SetRoleUsersRequest struct { - AuthorityID uint `json:"authorityId"` + AuthorityID uint `json:"authorityId" form:"authorityId"` UserIDs []uint `json:"userIds"` } type SetDataScopeRequest struct { - AuthorityID uint `json:"authorityId"` + AuthorityID uint `json:"authorityId" form:"authorityId"` DataScope int `json:"dataScope"` DepartmentIDs []uint `json:"deptIds"` } type AuthorityResponse struct { + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt *time.Time `json:"DeletedAt"` AuthorityID uint `json:"authorityId"` AuthorityName string `json:"authorityName"` ParentID *uint `json:"parentId"` diff --git a/internal/service/dto/export.go b/internal/service/dto/export.go index 273d396..787773c 100644 --- a/internal/service/dto/export.go +++ b/internal/service/dto/export.go @@ -3,14 +3,22 @@ package dto import "time" type ExportConditionRequest struct { - From string `json:"from"` - Column string `json:"column"` - Operator string `json:"operator"` + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + TemplateID string `json:"templateID"` + From string `json:"from"` + Column string `json:"column"` + Operator string `json:"operator"` } type ExportJoinRequest struct { - Join string `json:"joins"` - Table string `json:"table"` - On string `json:"on"` + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + TemplateID string `json:"templateID"` + Join string `json:"joins"` + Table string `json:"table"` + On string `json:"on"` } type ExportTemplateRequest struct { ID uint `json:"ID"` @@ -28,22 +36,31 @@ type ExportTemplateRequest struct { } type ExportConditionResponse struct { - From string `json:"from"` - Column string `json:"column"` - Operator string `json:"operator"` + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + TemplateID string `json:"templateID"` + From string `json:"from"` + Column string `json:"column"` + Operator string `json:"operator"` } type ExportJoinResponse struct { - Join string `json:"joins"` - Table string `json:"table"` - On string `json:"on"` + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + TemplateID string `json:"templateID"` + Join string `json:"joins"` + Table string `json:"table"` + On string `json:"on"` } type ExportTemplateResponse struct { - ID uint `json:"ID"` - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + // The compatible embedded gorm.DeletedAt field is json:"-". + DeletedAt any `json:"-"` DBName string `json:"dbName"` Name string `json:"name"` TableName string `json:"tableName"` diff --git a/internal/service/dto/media.go b/internal/service/dto/media.go index 0bdbbc1..b902d81 100644 --- a/internal/service/dto/media.go +++ b/internal/service/dto/media.go @@ -6,23 +6,24 @@ type MediaResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` - Name string `json:"name"` - ClassID int `json:"classId"` - URL string `json:"url"` - Tag string `json:"tag"` - Key string `json:"key"` - Size int64 `json:"size"` - Mime string `json:"mime"` - MD5 string `json:"md5"` - UserID uint `json:"userId"` + // The compatible model embeds gorm.DeletedAt with json:"-", so this field is omitted. + DeletedAt any `json:"-"` + Name string `json:"name"` + ClassID int `json:"classId"` + URL string `json:"url"` + Tag string `json:"tag"` + Key string `json:"key"` + Size int64 `json:"size"` + Mime string `json:"mime"` + MD5 string `json:"md5"` + UserID uint `json:"userId"` } type MediaCategoryResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` Name string `json:"name"` PID uint `json:"pid"` Children []*MediaCategoryResponse `json:"children"` @@ -37,9 +38,9 @@ type StorageObjectResponse struct { type InitUploadResponse struct { Instant bool `json:"instant"` - UploadID uint `json:"uploadId,omitempty"` + UploadID uint `json:"uploadId"` UploadedChunks []int `json:"uploadedChunks"` - Media *MediaResponse `json:"media"` + Media *MediaResponse `json:"media,omitempty"` } type MediaListRequest struct { @@ -86,13 +87,13 @@ type CategoryRequest struct { PID uint `json:"pid"` } type InitUploadRequest struct { - FileName string `json:"fileName"` - FileHash string `json:"fileHash"` - FileSize int64 `json:"fileSize"` - ChunkSize int64 `json:"chunkSize"` - ChunkTotal int `json:"chunkTotal"` + FileName string `json:"fileName" binding:"required"` + FileHash string `json:"fileHash" binding:"required"` + FileSize int64 `json:"fileSize" binding:"required"` + ChunkSize int64 `json:"chunkSize" binding:"required"` + ChunkTotal int `json:"chunkTotal" binding:"required"` Mime string `json:"mime"` } type CompleteUploadRequest struct { - UploadID uint `json:"uploadId"` + UploadID uint `json:"uploadId" binding:"required"` } diff --git a/internal/service/dto/menu.go b/internal/service/dto/menu.go index ada7d5a..a75db25 100644 --- a/internal/service/dto/menu.go +++ b/internal/service/dto/menu.go @@ -1,5 +1,7 @@ package dto +import "time" + type MenuMetaRequest struct { ActiveName string `json:"activeName"` KeepAlive bool `json:"keepAlive"` @@ -58,7 +60,7 @@ type GetAuthorityMenusRequest struct { } type SetMenuRolesRequest struct { - MenuID uint `json:"menuId"` + MenuID uint `json:"menuId" form:"menuId"` AuthorityIDs []uint `json:"authorityIds"` } @@ -73,22 +75,29 @@ type MenuMetaResponse struct { } type MenuButtonResponse struct { - ID uint `json:"ID"` - Name string `json:"name"` - Description string `json:"desc"` - SysBaseMenuID uint `json:"sysBaseMenuID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + ID uint `json:"ID"` + Name string `json:"name"` + Description string `json:"desc"` + SysBaseMenuID uint `json:"sysBaseMenuID"` } type MenuParameterResponse struct { - ID uint `json:"ID"` - SysBaseMenuID uint `json:"SysBaseMenuID"` - Type string `json:"type"` - Key string `json:"key"` - Value string `json:"value"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + ID uint `json:"ID"` + SysBaseMenuID uint `json:"SysBaseMenuID"` + Type string `json:"type"` + Key string `json:"key"` + Value string `json:"value"` } type MenuResponse struct { + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` ID uint `json:"ID"` + MenuID uint `json:"menuId,omitempty"` ParentID uint `json:"parentId"` Path string `json:"path"` Name string `json:"name"` @@ -103,8 +112,10 @@ type MenuResponse struct { } type AuthorityMenuResponse struct { - MenuID uint `json:"menuId"` - AuthorityID uint `json:"authorityId"` - SysBaseMenu *MenuResponse `json:"sysBaseMenu"` - ParentID uint `json:"parentId"` + // The administration response embeds the base-menu fields, so menu fields are + // flat in the response (there is no sysBaseMenu envelope). Keep the + // authority ID internal, matching the compatible json:"-" field. + *MenuResponse + MenuID uint `json:"menuId"` + AuthorityID uint `json:"-"` } diff --git a/internal/service/dto/organization.go b/internal/service/dto/organization.go index 8805212..c9fdd52 100644 --- a/internal/service/dto/organization.go +++ b/internal/service/dto/organization.go @@ -31,7 +31,7 @@ type DepartmentResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` Name string `json:"name"` ParentID uint `json:"parentId"` Ancestors string `json:"ancestors"` @@ -75,7 +75,7 @@ type PositionResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` Name string `json:"name"` Code string `json:"code"` Sort int `json:"sort"` diff --git a/internal/service/dto/settings.go b/internal/service/dto/settings.go index 8efaa20..1063832 100644 --- a/internal/service/dto/settings.go +++ b/internal/service/dto/settings.go @@ -32,11 +32,20 @@ type DictionaryDetailsByParentRequest struct { } type SystemParameterRequest struct { ID uint `json:"ID" form:"ID"` - Name string `json:"name" form:"name"` - Key string `json:"key" form:"key"` - Value string `json:"value" form:"value"` + Name string `json:"name" form:"name" binding:"required"` + Key string `json:"key" form:"key" binding:"required"` + Value string `json:"value" form:"value" binding:"required"` Description string `json:"desc" form:"desc"` } + +type SystemParameterSearchRequest struct { + Page int `json:"page" form:"page"` + PageSize int `json:"pageSize" form:"pageSize"` + StartCreatedAt *time.Time `json:"startCreatedAt" form:"startCreatedAt"` + EndCreatedAt *time.Time `json:"endCreatedAt" form:"endCreatedAt"` + Name string `json:"name" form:"name"` + Key string `json:"key" form:"key"` +} type CreateAPITokenRequest struct { UserID uint `json:"userId"` AuthorityID uint `json:"authorityId"` @@ -54,7 +63,7 @@ type APITokenResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` UserID uint `json:"userId"` AuthorityID uint `json:"authorityId"` Token string `json:"token"` @@ -65,33 +74,36 @@ type APITokenResponse struct { } type SecurityConfigResponse struct { - ID uint `json:"ID"` - CaptchaOpen int `json:"captchaOpen"` - CaptchaTimeout int `json:"captchaTimeout"` - KeyLong int `json:"keyLong"` - ImgWidth int `json:"imgWidth"` - ImgHeight int `json:"imgHeight"` - PwdMinLength int `json:"pwdMinLength"` - PwdRequireUpper bool `json:"pwdRequireUpper"` - PwdRequireLower bool `json:"pwdRequireLower"` - PwdRequireDigit bool `json:"pwdRequireDigit"` - PwdRequireSpecial bool `json:"pwdRequireSpecial"` - LimitEnable bool `json:"limitEnable"` - LimitWindow int `json:"limitWindow"` - LimitCount int `json:"limitCount"` - LockEnable bool `json:"lockEnable"` - LockThreshold int `json:"lockThreshold"` - LockDuration int `json:"lockDuration"` - PwdExpireEnable bool `json:"pwdExpireEnable"` - PwdExpireDays int `json:"pwdExpireDays"` - ForceNewUserChangePassword bool `json:"forceNewUserChangePassword"` + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt any `json:"-"` + CaptchaOpen int `json:"captchaOpen"` + CaptchaTimeout int `json:"captchaTimeout"` + KeyLong int `json:"keyLong"` + ImgWidth int `json:"imgWidth"` + ImgHeight int `json:"imgHeight"` + PwdMinLength int `json:"pwdMinLength"` + PwdRequireUpper bool `json:"pwdRequireUpper"` + PwdRequireLower bool `json:"pwdRequireLower"` + PwdRequireDigit bool `json:"pwdRequireDigit"` + PwdRequireSpecial bool `json:"pwdRequireSpecial"` + LimitEnable bool `json:"limitEnable"` + LimitWindow int `json:"limitWindow"` + LimitCount int `json:"limitCount"` + LockEnable bool `json:"lockEnable"` + LockThreshold int `json:"lockThreshold"` + LockDuration int `json:"lockDuration"` + PwdExpireEnable bool `json:"pwdExpireEnable"` + PwdExpireDays int `json:"pwdExpireDays"` + ForceNewUserChangePassword bool `json:"forceNewUserChangePassword"` } type SystemParameterResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` Name string `json:"name"` Key string `json:"key"` Value string `json:"value"` @@ -102,10 +114,10 @@ type DictionaryResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` Name string `json:"name"` Type string `json:"type"` - Status bool `json:"status"` + Status *bool `json:"status"` Desc string `json:"desc"` ParentID *uint `json:"parentID"` Children []*DictionaryResponse `json:"children"` @@ -116,11 +128,11 @@ type DictionaryDetailResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` Label string `json:"label"` Value string `json:"value"` Extend string `json:"extend"` - Status bool `json:"status"` + Status *bool `json:"status"` Sort int `json:"sort"` DictionaryID uint `json:"sysDictionaryID"` ParentID *uint `json:"parentID"` @@ -133,7 +145,7 @@ type DictionaryDetailResponse struct { type DictionaryExportResponse struct { Name string `json:"name"` Type string `json:"type"` - Status bool `json:"status"` + Status *bool `json:"status"` Desc string `json:"desc"` SysDictionaryDetails []*DictionaryDetailExportResponse `json:"sysDictionaryDetails"` } @@ -142,7 +154,7 @@ type DictionaryDetailExportResponse struct { Label string `json:"label"` Value string `json:"value"` Extend string `json:"extend"` - Status bool `json:"status"` + Status *bool `json:"status"` Sort int `json:"sort"` Level int `json:"level"` Path string `json:"path"` diff --git a/internal/service/dto/system.go b/internal/service/dto/system.go index d79b29b..3f19f65 100644 --- a/internal/service/dto/system.go +++ b/internal/service/dto/system.go @@ -78,7 +78,7 @@ type UserResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` UUID string `json:"uuid"` Username string `json:"userName"` NickName string `json:"nickName"` diff --git a/internal/service/dto/task.go b/internal/service/dto/task.go index 2a3b9d2..a0788c0 100644 --- a/internal/service/dto/task.go +++ b/internal/service/dto/task.go @@ -23,18 +23,33 @@ type TaskRequest struct { } type DeleteTaskRequest struct { - ID uint `json:"ID"` + ID uint `json:"ID" binding:"required"` } type ToggleTaskRequest struct { - ID uint `json:"ID"` + ID uint `json:"ID" binding:"required"` Enabled bool `json:"enabled"` } +type TaskListRequest struct { + Page int `form:"page"` + PageSize int `form:"pageSize"` + Name string `form:"name"` + ExecutorType string `form:"executorType"` + Enabled *bool `form:"enabled"` +} + +type TaskLogListRequest struct { + Page int `form:"page"` + PageSize int `form:"pageSize"` + TaskID uint `form:"taskId"` + Status string `form:"status"` +} + type TaskResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` Name string `json:"name"` Description string `json:"description"` Spec string `json:"spec"` @@ -54,7 +69,8 @@ type TaskResponse struct { type TaskLogResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` - DeletedAt any `json:"DeletedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt any `json:"-"` TaskID uint `json:"taskId"` TaskName string `json:"taskName"` TriggerType string `json:"triggerType"` diff --git a/internal/service/dto/version.go b/internal/service/dto/version.go index cf4d4f1..11ec334 100644 --- a/internal/service/dto/version.go +++ b/internal/service/dto/version.go @@ -6,13 +6,21 @@ type VersionResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"DeletedAt"` + DeletedAt any `json:"-"` VersionName string `json:"versionName"` VersionCode string `json:"versionCode"` Description string `json:"description"` VersionData string `json:"versionData"` } +type VersionSearchRequest struct { + CreatedAtRange []time.Time `json:"createdAtRange" form:"createdAtRange[]"` + VersionName *string `json:"versionName" form:"versionName"` + VersionCode *string `json:"versionCode" form:"versionCode"` + Page int `json:"page" form:"page"` + PageSize int `json:"pageSize" form:"pageSize"` +} + type VersionMenuMetaRequest struct { ActiveName string `json:"activeName"` KeepAlive bool `json:"keepAlive"` @@ -61,24 +69,24 @@ type VersionDictionaryDetailRequest struct { type VersionDictionaryRequest struct { Name string `json:"name"` Type string `json:"type"` - Status bool `json:"status"` + Status *bool `json:"status"` Description string `json:"desc"` Details []VersionDictionaryDetailRequest `json:"sysDictionaryDetails"` } type ImportVersionRequest struct { Version struct { - Name string `json:"name"` - Code string `json:"code"` + Name string `json:"name" binding:"required"` + Code string `json:"code" binding:"required"` Description string `json:"description"` ExportTime string `json:"exportTime"` - } `json:"version"` + } `json:"version" binding:"required"` Menus []VersionMenuRequest `json:"menus"` APIs []VersionAPIRequest `json:"apis"` Dictionaries []VersionDictionaryRequest `json:"dictionaries"` } type ExportVersionRequest struct { - VersionName string `json:"versionName"` - VersionCode string `json:"versionCode"` + VersionName string `json:"versionName" binding:"required"` + VersionCode string `json:"versionCode" binding:"required"` Description string `json:"description"` MenuIDs []uint `json:"menuIds"` APIIDs []uint `json:"apiIds"` diff --git a/internal/service/export.go b/internal/service/export.go index 34d8b5e..04f2a40 100644 --- a/internal/service/export.go +++ b/internal/service/export.go @@ -70,10 +70,10 @@ func (s *ExportService) ConsumeToken(ctx context.Context, token string, expectBl func exportTemplateDomain(value *dto.ExportTemplateRequest) *biz.ExportTemplate { out := &biz.ExportTemplate{ID: value.ID, DBName: value.DBName, Name: value.Name, TableName: value.TableName, TemplateID: value.TemplateID, TemplateInfo: value.TemplateInfo, SQL: value.SQL, ImportSQL: value.ImportSQL, Limit: value.Limit, Order: value.Order} for _, item := range value.Conditions { - out.Conditions = append(out.Conditions, biz.ExportCondition{From: item.From, Column: item.Column, Operator: item.Operator}) + out.Conditions = append(out.Conditions, biz.ExportCondition{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, TemplateID: item.TemplateID, From: item.From, Column: item.Column, Operator: item.Operator}) } for _, item := range value.Joins { - out.Joins = append(out.Joins, biz.ExportJoin{Join: item.Join, Table: item.Table, On: item.On}) + out.Joins = append(out.Joins, biz.ExportJoin{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, TemplateID: item.TemplateID, Join: item.Join, Table: item.Table, On: item.On}) } return out } @@ -91,14 +91,14 @@ func exportDTO(v *biz.ExportTemplate) *dto.ExportTemplateResponse { if v.Conditions != nil { conditions = make([]dto.ExportConditionResponse, 0, len(v.Conditions)) for _, x := range v.Conditions { - conditions = append(conditions, dto.ExportConditionResponse{From: x.From, Column: x.Column, Operator: x.Operator}) + conditions = append(conditions, dto.ExportConditionResponse{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator}) } } var joins []dto.ExportJoinResponse if v.Joins != nil { joins = make([]dto.ExportJoinResponse, 0, len(v.Joins)) for _, x := range v.Joins { - joins = append(joins, dto.ExportJoinResponse{Join: x.Join, Table: x.Table, On: x.On}) + joins = append(joins, dto.ExportJoinResponse{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, Join: x.Join, Table: x.Table, On: x.On}) } } return &dto.ExportTemplateResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, DBName: v.DBName, Name: v.Name, TableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order, Conditions: conditions, Joins: joins} diff --git a/internal/service/media.go b/internal/service/media.go index 0be75c3..593fea1 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -23,9 +23,12 @@ func mediaDTO(v *biz.MediaFile) *dto.MediaResponse { return &dto.MediaResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Name: v.Name, ClassID: v.CategoryID, URL: v.URL, Tag: v.Tag, Key: v.Key, Size: v.Size, Mime: v.Mime, MD5: v.MD5, UserID: v.UserID} } func categoryDTO(v *biz.AttachmentCategory) *dto.MediaCategoryResponse { - children := make([]*dto.MediaCategoryResponse, 0, len(v.Children)) - for _, x := range v.Children { - children = append(children, categoryDTO(x)) + var children []*dto.MediaCategoryResponse + if v.Children != nil { + children = make([]*dto.MediaCategoryResponse, 0, len(v.Children)) + for _, x := range v.Children { + children = append(children, categoryDTO(x)) + } } return &dto.MediaCategoryResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, Name: v.Name, PID: v.ParentID, Children: children} } diff --git a/internal/service/media_upload.go b/internal/service/media_upload.go index 40daf9a..edae30d 100644 --- a/internal/service/media_upload.go +++ b/internal/service/media_upload.go @@ -13,7 +13,9 @@ func (s *MediaService) InitUpload(ctx context.Context, userID uint, name, hash s return nil, err } if media != nil { - return &dto.InitUploadResponse{Instant: true, Media: mediaDTO(media), UploadedChunks: []int{}}, nil + // The compatible response leaves UploadedChunks nil on an instant-upload hit, producing + // JSON null while still returning uploadId: 0. + return &dto.InitUploadResponse{Instant: true, Media: mediaDTO(media)}, nil } return &dto.InitUploadResponse{Instant: false, UploadID: session.ID, UploadedChunks: chunks, Media: nil}, nil } diff --git a/internal/service/menu.go b/internal/service/menu.go index 1fdfa6b..0e721d9 100644 --- a/internal/service/menu.go +++ b/internal/service/menu.go @@ -58,7 +58,7 @@ func (s *MenuService) AuthorityMenus(ctx context.Context, authorityID uint) ([]* } out := make([]*dto.AuthorityMenuResponse, 0, len(items)) for _, item := range items { - out = append(out, &dto.AuthorityMenuResponse{MenuID: item.ID, AuthorityID: authorityID, SysBaseMenu: menuResponse(item), ParentID: item.ParentID}) + out = append(out, &dto.AuthorityMenuResponse{MenuResponse: menuResponse(item), MenuID: item.ID, AuthorityID: authorityID}) } return out, nil } @@ -94,16 +94,16 @@ func menuResponse(menu *biz.Menu) *dto.MenuResponse { buttons := make([]dto.MenuButtonResponse, 0, len(menu.Buttons)) buttonAuthorities := make(map[string]uint, len(menu.Buttons)) for _, button := range menu.Buttons { - buttons = append(buttons, dto.MenuButtonResponse{ID: button.ID, Name: button.Name, Description: button.Description, SysBaseMenuID: button.MenuID}) + buttons = append(buttons, dto.MenuButtonResponse{CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, ID: button.ID, Name: button.Name, Description: button.Description, SysBaseMenuID: button.MenuID}) if button.AuthorityID != 0 { buttonAuthorities[button.Name] = button.AuthorityID } } parameters := make([]dto.MenuParameterResponse, 0, len(menu.Parameters)) for _, parameter := range menu.Parameters { - parameters = append(parameters, dto.MenuParameterResponse{ID: parameter.ID, SysBaseMenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + parameters = append(parameters, dto.MenuParameterResponse{CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, ID: parameter.ID, SysBaseMenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) } - return &dto.MenuResponse{ID: menu.ID, ParentID: menu.ParentID, Path: menu.Path, Name: menu.Name, Hidden: menu.Hidden, Component: menu.Component, Sort: menu.Sort, Meta: dto.MenuMetaResponse{ActiveName: menu.ActiveName, KeepAlive: menu.KeepAlive, DefaultMenu: menu.DefaultMenu, Title: menu.Title, Icon: menu.Icon, CloseTab: menu.CloseTab, TransitionType: menu.TransitionType}, Children: children, Parameters: parameters, MenuButtons: buttons, Buttons: buttonAuthorities} + return &dto.MenuResponse{CreatedAt: menu.CreatedAt, UpdatedAt: menu.UpdatedAt, ID: menu.ID, MenuID: menu.MenuID, ParentID: menu.ParentID, Path: menu.Path, Name: menu.Name, Hidden: menu.Hidden, Component: menu.Component, Sort: menu.Sort, Meta: dto.MenuMetaResponse{ActiveName: menu.ActiveName, KeepAlive: menu.KeepAlive, DefaultMenu: menu.DefaultMenu, Title: menu.Title, Icon: menu.Icon, CloseTab: menu.CloseTab, TransitionType: menu.TransitionType}, Children: children, Parameters: parameters, MenuButtons: buttons, Buttons: buttonAuthorities} } func menuResponses(items []*biz.Menu) []*dto.MenuResponse { diff --git a/internal/service/security.go b/internal/service/security.go index bcd9e09..734fede 100644 --- a/internal/service/security.go +++ b/internal/service/security.go @@ -8,7 +8,7 @@ import ( ) func securityDTO(v *biz.SecurityConfig) *dto.SecurityConfigResponse { - return &dto.SecurityConfigResponse{ID: v.ID, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword} + return &dto.SecurityConfigResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword} } func (s *SecurityService) CurrentSecurity(ctx context.Context) (*biz.SecurityConfig, error) { return s.uc.Current(ctx) diff --git a/internal/service/system_init.go b/internal/service/system_init.go index ed38194..4f74402 100644 --- a/internal/service/system_init.go +++ b/internal/service/system_init.go @@ -21,14 +21,20 @@ type DatabaseInit struct { } func (s *SystemConfigService) Initialize(ctx context.Context, input *DatabaseInit, apis []*biz.API) error { - config := "" - switch input.DBType { + driver, config := input.DBType, "" + switch driver { case "mysql": - config = "timeout=5s&parseTime=True&loc=Local&charset=utf8mb4" + config = "charset=utf8mb4&parseTime=True&loc=Local" case "pgsql": - config = "sslmode=disable TimeZone=Local" + config = "sslmode=disable TimeZone=Asia/Shanghai" + case "sqlite", "mssql": + default: + // The compatible initialization flow treats an omitted or unknown dbType as MySQL during + // initialization rather than rejecting it as an unsupported driver. + driver = "mysql" + config = "charset=utf8mb4&parseTime=True&loc=Local" } - return s.uc.Initialize(ctx, &biz.DatabaseConfig{Driver: input.DBType, Host: input.Host, Port: input.Port, User: input.UserName, Password: input.Password, Name: input.DBName, Path: input.DBPath, Config: config, Template: input.Template, AdminPassword: input.AdminPassword, APIs: apis}) + return s.uc.Initialize(ctx, &biz.DatabaseConfig{Driver: driver, Host: input.Host, Port: input.Port, User: input.UserName, Password: input.Password, Name: input.DBName, Path: input.DBPath, Config: config, Template: input.Template, AdminPassword: input.AdminPassword, APIs: apis}) } func (s *SystemConfigService) InitializeRoutes(ctx context.Context, input *DatabaseInit, routes []dto.Route) error { diff --git a/internal/service/task.go b/internal/service/task.go index 6ca5319..3de317e 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -71,7 +71,7 @@ func (s *TaskService) Logs(ctx context.Context, page, size int, taskID uint, sta } out := make([]*dto.TaskLogResponse, 0, len(items)) for _, v := range items { - out = append(out, &dto.TaskLogResponse{ID: v.ID, CreatedAt: v.CreatedAt, DeletedAt: nil, TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output}) + out = append(out, &dto.TaskLogResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output}) } return out, total, nil } diff --git a/internal/service/user.go b/internal/service/user.go index f4d2faa..c5afa92 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -90,7 +90,9 @@ func (s *UserService) CreateUser(ctx context.Context, input UserInput) (*dto.Use return convertUser(user), nil } func (s *UserService) UpdateUser(ctx context.Context, input UserInput) error { - return s.uc.UpdateUser(ctx, &biz.User{ID: input.ID, NickName: input.NickName, HeaderImg: input.HeaderImg, AuthorityID: input.AuthorityID, Phone: input.Phone, Email: input.Email, Enable: input.Enable}, input.AuthorityIDs) + // The compatible ChangeUserInfo payload uses authorityIds for role assignment; the + // standalone authorityId field is not applied by setUserInfo. + return s.uc.UpdateUser(ctx, &biz.User{ID: input.ID, NickName: input.NickName, HeaderImg: input.HeaderImg, Phone: input.Phone, Email: input.Email, Enable: input.Enable}, input.AuthorityIDs) } func (s *UserService) UpdateSelfUser(ctx context.Context, input UserInput) error { return s.uc.UpdateSelfUser(ctx, &biz.User{ID: input.ID, NickName: input.NickName, HeaderImg: input.HeaderImg, Phone: input.Phone, Email: input.Email, Enable: input.Enable}) diff --git a/internal/service/user_conversion.go b/internal/service/user_conversion.go index 23d9417..af6e2f5 100644 --- a/internal/service/user_conversion.go +++ b/internal/service/user_conversion.go @@ -6,7 +6,7 @@ import ( ) func convertAuthority(value biz.Authority) *dto.AuthorityResponse { - return &dto.AuthorityResponse{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: []*dto.AuthorityResponse{}, Menus: []any{}, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} + return &dto.AuthorityResponse{CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: value.DeletedAt, AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: []*dto.AuthorityResponse{}, Menus: []any{}, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} } func convertUser(user *biz.User) *dto.UserResponse { authorities := make([]*dto.AuthorityResponse, 0, len(user.Authorities)) diff --git a/internal/service/version.go b/internal/service/version.go index e900845..f43d944 100644 --- a/internal/service/version.go +++ b/internal/service/version.go @@ -12,6 +12,123 @@ import ( type VersionService struct{ uc *biz.VersionUsecase } func NewVersionService(uc *biz.VersionUsecase) *VersionService { return &VersionService{uc: uc} } + +type versionExportInfo struct { + Name string `json:"name"` + Code string `json:"code"` + Description string `json:"description"` + ExportTime string `json:"exportTime"` +} + +type versionExportMenuParameter struct { + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + SysBaseMenuID uint `json:"SysBaseMenuID"` + Type string `json:"type"` + Key string `json:"key"` + Value string `json:"value"` +} + +type versionExportMenuButton struct { + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + Name string `json:"name"` + Description string `json:"desc"` + SysBaseMenuID uint `json:"sysBaseMenuID"` +} + +type versionExportMenu struct { + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + ParentID uint `json:"parentId"` + Path string `json:"path"` + Name string `json:"name"` + Hidden bool `json:"hidden"` + Component string `json:"component"` + Sort int `json:"sort"` + Meta dto.VersionMenuMetaRequest `json:"meta"` + Authorities []any `json:"authoritys"` + Children []*versionExportMenu `json:"children"` + Parameters []versionExportMenuParameter `json:"parameters"` + MenuButtons []versionExportMenuButton `json:"menuBtn"` +} + +type versionExportAPI struct { + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + Path string `json:"path"` + Description string `json:"description"` + APIGroup string `json:"apiGroup"` + Method string `json:"method"` +} + +type versionExportDictionaryDetail struct { + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + Label string `json:"label"` + Value string `json:"value"` + Extend string `json:"extend"` + Status *bool `json:"status"` + Sort int `json:"sort"` + DictionaryID int `json:"sysDictionaryID"` + ParentID *uint `json:"parentID"` + Children []versionExportDictionaryDetail `json:"children"` + Level int `json:"level"` + Path string `json:"path"` + Disabled bool `json:"disabled"` +} + +type versionExportDictionary struct { + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + Name string `json:"name"` + Type string `json:"type"` + Status *bool `json:"status"` + Desc string `json:"desc"` + ParentID *uint `json:"parentID"` + Children []versionExportDictionary `json:"children"` + Details []versionExportDictionaryDetail `json:"sysDictionaryDetails"` +} + +type versionExportDocument struct { + Version versionExportInfo `json:"version"` + Menus []*versionExportMenu `json:"menus"` + APIs []versionExportAPI `json:"apis"` + Dictionaries []versionExportDictionary `json:"dictionaries"` +} + +func exportMenu(value *biz.Menu) *versionExportMenu { + menu := &versionExportMenu{ + Path: value.Path, Name: value.Name, Hidden: value.Hidden, Component: value.Component, Sort: value.Sort, + Meta: dto.VersionMenuMetaRequest{ActiveName: value.ActiveName, KeepAlive: value.KeepAlive, DefaultMenu: value.DefaultMenu, Title: value.Title, Icon: value.Icon, CloseTab: value.CloseTab, TransitionType: value.TransitionType}, + } + if len(value.Parameters) > 0 { + menu.Parameters = make([]versionExportMenuParameter, 0, len(value.Parameters)) + for _, parameter := range value.Parameters { + menu.Parameters = append(menu.Parameters, versionExportMenuParameter{Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + } + } + if len(value.Buttons) > 0 { + menu.MenuButtons = make([]versionExportMenuButton, 0, len(value.Buttons)) + for _, button := range value.Buttons { + menu.MenuButtons = append(menu.MenuButtons, versionExportMenuButton{Name: button.Name, Description: button.Description}) + } + } + if len(value.Children) > 0 { + menu.Children = make([]*versionExportMenu, 0, len(value.Children)) + for _, child := range value.Children { + menu.Children = append(menu.Children, exportMenu(child)) + } + } + return menu +} + func versionDTO(v *biz.Version) *dto.VersionResponse { return &dto.VersionResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, VersionName: v.Name, VersionCode: v.Code, Description: v.Description, VersionData: v.Data} } @@ -38,7 +155,19 @@ func (s *VersionService) VersionData(ctx context.Context, id uint) (string, stri if err != nil { return "", "", err } - return v.Data, v.Code, nil + if v.Data != "" { + return v.Data, v.Code, nil + } + payload := versionExportDocument{ + Version: versionExportInfo{Name: v.Name, Code: v.Code, Description: v.Description, ExportTime: v.CreatedAt.Format("2006-01-02 15:04:05")}, + Menus: []*versionExportMenu{}, + APIs: []versionExportAPI{}, + } + raw, marshalErr := json.MarshalIndent(payload, "", " ") + if marshalErr != nil { + return "", "", marshalErr + } + return string(raw), v.Code, nil } func (s *VersionService) DeleteVersions(ctx context.Context, ids []uint) error { return s.uc.DeleteVersions(ctx, ids) @@ -53,24 +182,34 @@ func (s *VersionService) Export(ctx context.Context, name, code, description str bundle.Code = code bundle.Description = description bundle.ExportTime = time.Now().Format("2006-01-02 15:04:05") - menus := make([]*dto.MenuResponse, 0, len(bundle.Menus)) - for _, v := range bundle.Menus { - menus = append(menus, menuResponse(v)) + var menus []*versionExportMenu + if bundle.Menus != nil { + menus = make([]*versionExportMenu, 0, len(bundle.Menus)) + for _, value := range bundle.Menus { + menus = append(menus, exportMenu(value)) + } } - apis := make([]*dto.APIResponse, 0, len(bundle.APIs)) - for _, v := range bundle.APIs { - apis = append(apis, apiResponse(v)) + apis := make([]versionExportAPI, 0, len(bundle.APIs)) + for _, value := range bundle.APIs { + apis = append(apis, versionExportAPI{Path: value.Path, Description: value.Description, APIGroup: value.APIGroup, Method: value.Method}) } - dicts := make([]*dto.DictionaryResponse, 0, len(bundle.Dictionaries)) - for _, v := range bundle.Dictionaries { - dicts = append(dicts, dictionaryDTO(v)) + dictionaries := make([]versionExportDictionary, 0, len(bundle.Dictionaries)) + for _, value := range bundle.Dictionaries { + details := make([]versionExportDictionaryDetail, 0, len(value.Details)) + for _, detail := range value.Details { + details = append(details, versionExportDictionaryDetail{Label: detail.Label, Value: detail.Value, Extend: detail.Extend, Status: detail.Status, Sort: detail.Sort}) + } + dictionaries = append(dictionaries, versionExportDictionary{Name: value.Name, Type: value.Type, Status: value.Status, Desc: value.Desc, Details: details}) } - payload := map[string]any{"version": map[string]any{"name": name, "code": code, "description": description, "exportTime": bundle.ExportTime}, "menus": menus, "apis": apis, "dictionaries": dicts} + payload := versionExportDocument{Version: versionExportInfo{Name: name, Code: code, Description: description, ExportTime: bundle.ExportTime}, Menus: menus, APIs: apis, Dictionaries: dictionaries} raw, err := json.MarshalIndent(payload, "", " ") if err != nil { - return err + return &biz.VersionStageError{Stage: biz.VersionStageJSON, Err: err} } - return s.uc.CreateVersion(ctx, &biz.Version{Name: name, Code: code, Description: description, Data: string(raw)}) + if err = s.uc.CreateVersion(ctx, &biz.Version{Name: name, Code: code, Description: description, Data: string(raw)}); err != nil { + return &biz.VersionStageError{Stage: biz.VersionStageSave, Err: err} + } + return nil } func (s *VersionService) Import(ctx context.Context, bundle *biz.VersionBundle) error { return s.uc.ImportVersionBundle(ctx, bundle) @@ -102,11 +241,7 @@ func (s *VersionService) ImportRequest(ctx context.Context, req *dto.ImportVersi for _, dictionary := range req.Dictionaries { item := &biz.Dictionary{Name: dictionary.Name, Type: dictionary.Type, Status: dictionary.Status, Desc: dictionary.Description} for _, detail := range dictionary.Details { - status := true - if detail.Status != nil { - status = *detail.Status - } - item.Details = append(item.Details, &biz.DictionaryDetail{Label: detail.Label, Value: detail.Value, Extend: detail.Extend, Status: status, Sort: detail.Sort, ParentID: detail.ParentID, Level: detail.Level, Path: detail.Path}) + item.Details = append(item.Details, &biz.DictionaryDetail{Label: detail.Label, Value: detail.Value, Extend: detail.Extend, Status: detail.Status, Sort: detail.Sort, ParentID: detail.ParentID, Level: detail.Level, Path: detail.Path}) } bundle.Dictionaries = append(bundle.Dictionaries, item) } diff --git a/internal/worker/task_executor.go b/internal/worker/task_executor.go index f8afd7d..a2af49b 100644 --- a/internal/worker/task_executor.go +++ b/internal/worker/task_executor.go @@ -25,7 +25,9 @@ type TaskExecutor struct { } func NewTaskExecutor(tasks *biz.TaskUsecase, media *biz.MediaUsecase, runtime *conf.Runtime) *TaskExecutor { - return &TaskExecutor{tasks: tasks, media: media, runtime: runtime} + executor := &TaskExecutor{tasks: tasks, media: media, runtime: runtime} + registerTaskMethods(executor) + return executor } func privateIP(ip net.IP) bool { @@ -61,7 +63,7 @@ func (e *TaskExecutor) runHTTP(ctx context.Context, task *biz.TimedTask) (string if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", fmt.Errorf("仅允许 http/https, 实际为 %q", parsed.Scheme) } - method := strings.ToUpper(task.HTTPMethod) + method := strings.ToUpper(strings.TrimSpace(task.HTTPMethod)) if method == "" { method = http.MethodGet } @@ -102,6 +104,10 @@ func truncateTaskText(value string) string { } func (e *TaskExecutor) runMethod(task *biz.TimedTask) error { + method, ok := biz.TaskMethodByName(task.MethodName) + if !ok { + return fmt.Errorf("方法 %s 未注册(需在 internal/worker/task_registry.go 经 biz.RegisterTaskMethod 注册)", task.MethodName) + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() done := make(chan error, 1) @@ -111,21 +117,7 @@ func (e *TaskExecutor) runMethod(task *biz.TimedTask) error { done <- fmt.Errorf("panic: %v", recovered) } }() - var err error - switch task.MethodName { - case biz.TaskMethodClearDB: - err = e.tasks.CleanupLogs(ctx) - case biz.TaskMethodUploads: - ttl := 24 - config := e.runtime.Admin() - if config != nil && config.Media != nil && config.Media.SessionTtl > 0 { - ttl = int(config.Media.SessionTtl) - } - err = e.media.CleanupStale(ctx, ttl) - default: - err = fmt.Errorf("方法 %s 未注册(需在 initialize/timer.go 经 task.Register 注册)", task.MethodName) - } - done <- err + done <- method(ctx, json.RawMessage(task.Params)) }() select { case err := <-done: diff --git a/internal/worker/task_registry.go b/internal/worker/task_registry.go new file mode 100644 index 0000000..e50a10d --- /dev/null +++ b/internal/worker/task_registry.go @@ -0,0 +1,32 @@ +package worker + +import ( + "context" + "encoding/json" + + "kra/internal/biz" +) + +// registerTaskMethods defines the in-process methods available to the task +// management page. Add application-specific registrations here. +func registerTaskMethods(executor *TaskExecutor) { + biz.RegisterTaskMethod( + biz.TaskMethodClearDB, + "清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", + func(ctx context.Context, _ json.RawMessage) error { + return executor.tasks.CleanupLogs(ctx) + }, + ) + biz.RegisterTaskMethod( + biz.TaskMethodUploads, + "清理过期大文件上传会话", + func(ctx context.Context, _ json.RawMessage) error { + ttl := 24 + config := executor.runtime.Admin() + if config != nil && config.Media != nil && config.Media.SessionTtl > 0 { + ttl = int(config.Media.SessionTtl) + } + return executor.media.CleanupStale(ctx, ttl) + }, + ) +} diff --git a/pkg/adminauth/token.go b/pkg/adminauth/token.go index 0664e1f..8959927 100644 --- a/pkg/adminauth/token.go +++ b/pkg/adminauth/token.go @@ -32,7 +32,7 @@ func Generate(secret, issuer string, expires, buffer time.Duration, userID, auth return "", nil, errors.New("empty JWT signing key") } now := time.Now() - claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), UserType: "admin", MustChangePwd: mustChange, RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"GVA"}, Issuer: issuer, NotBefore: jwt.NewNumericDate(now.Add(-1000)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}} + claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), UserType: "admin", MustChangePwd: mustChange, RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"KRA"}, Issuer: issuer, NotBefore: jwt.NewNumericDate(now.Add(-1000)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}} token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) return token, claims, err } diff --git a/web/src/utils/asyncRouter.js b/web/src/utils/asyncRouter.js index 7c4267c..a6940a2 100644 --- a/web/src/utils/asyncRouter.js +++ b/web/src/utils/asyncRouter.js @@ -1,14 +1,20 @@ const viewModules = import.meta.glob('../view/**/*.vue') const moduleViews = import.meta.glob('../modules/**/*.vue') +const legacyModuleComponents = { + 'plugin/email/view/index.vue': 'modules/email/view/index.vue', + 'plugin/announcement/view/info.vue': 'modules/announcement/view/info.vue', + 'plugin/announcement/form/info.vue': 'modules/announcement/form/info.vue' +} export const asyncRouterHandle = (asyncRouter) => { asyncRouter.forEach((item) => { if (item.component && typeof item.component === 'string') { - item.meta.path = '/src/' + item.component - if (item.component.split('/')[0] === 'view') { - item.component = dynamicImport(viewModules, item.component) - } else if (item.component.split('/')[0] === 'modules') { - item.component = dynamicImport(moduleViews, item.component) + const component = legacyModuleComponents[item.component] || item.component + item.meta.path = '/src/' + component + if (component.split('/')[0] === 'view') { + item.component = dynamicImport(viewModules, component) + } else if (component.split('/')[0] === 'modules') { + item.component = dynamicImport(moduleViews, component) } } if (item.children) { diff --git a/web/src/view/dashboard/components/notice.vue b/web/src/view/dashboard/components/notice.vue index e2c0a8c..18adea7 100644 --- a/web/src/view/dashboard/components/notice.vue +++ b/web/src/view/dashboard/components/notice.vue @@ -38,7 +38,7 @@ if (response.code !== 0) return notices.value = (response.data?.list || []).map((item) => ({ typeTitle: item.type === 2 ? '公告' : '通知', - time: item.createdAt ? new Date(item.createdAt).toLocaleDateString('zh-CN') : '', + time: item.CreatedAt ? new Date(item.CreatedAt).toLocaleDateString('zh-CN') : '', title: item.title, dotClass: 'bg-cyan-500', tagClass: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-200' diff --git a/web/src/view/systemTools/timedTask/index.vue b/web/src/view/systemTools/timedTask/index.vue index 999aae7..75c9fed 100644 --- a/web/src/view/systemTools/timedTask/index.vue +++ b/web/src/view/systemTools/timedTask/index.vue @@ -124,7 +124,7 @@
- 没有想要的方法?需在后端 server/initialize/timer.go 的 Timer() 中注册,重启服务后即可选择 + 没有想要的方法?需在后端 internal/worker/task_registry.go 的 registerTaskMethods() 中注册,重启服务后即可选择