From 6a05803e08188418c8f2dd34d03c8fe78c50dad3 Mon Sep 17 00:00:00 2001 From: yvan <8574526@qq.com> Date: Mon, 17 Aug 2026 02:12:17 +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 --- internal/biz/announcement.go | 12 +- internal/biz/api.go | 8 +- internal/biz/authentication.go | 13 +- internal/biz/authentication_test.go | 60 ++++++ internal/biz/authority.go | 1 + internal/biz/dictionary.go | 36 +--- internal/biz/infrastructure.go | 3 + internal/biz/media.go | 15 +- internal/biz/media_test.go | 81 ++++++++ internal/biz/menu.go | 33 +-- internal/biz/parameter.go | 5 +- internal/biz/permission.go | 2 +- internal/biz/security.go | 9 +- internal/biz/security_test.go | 29 +++ internal/biz/user.go | 49 ++--- internal/biz/user_test.go | 37 ++++ internal/biz/version.go | 8 +- internal/data/announcement.go | 12 +- internal/data/announcement_test.go | 40 ++++ internal/data/api.go | 92 +++++---- internal/data/api_policy_test.go | 114 +++++++++++ internal/data/api_token.go | 2 +- internal/data/authority.go | 148 ++++++++++---- internal/data/authority_test.go | 179 +++++++++++++++++ internal/data/config_store.go | 16 +- internal/data/data.go | 4 +- internal/data/data_access_log.go | 2 +- internal/data/department.go | 18 +- internal/data/dictionary.go | 139 ++++++++----- .../data/dictionary_department_parity_test.go | 188 ++++++++++++++++++ internal/data/error_record.go | 2 +- internal/data/error_record_test.go | 140 +++++++++++++ internal/data/export.go | 54 +++-- internal/data/login_log.go | 4 +- internal/data/media.go | 25 ++- internal/data/media_test.go | 33 +++ internal/data/menu.go | 90 +++++---- internal/data/menu_test.go | 141 +++++++++++++ internal/data/migrations.go | 173 +++++++++++++++- internal/data/migrations_test.go | 57 ++++++ internal/data/operation_log.go | 2 +- internal/data/organization_test.go | 51 +++++ internal/data/pagination.go | 26 ++- internal/data/parameter.go | 33 +-- internal/data/parameter_test.go | 68 +++++++ internal/data/permission.go | 31 ++- internal/data/permission_test.go | 83 ++++++++ internal/data/position.go | 11 +- internal/data/runtime_settings.go | 34 +++- internal/data/security.go | 39 ++-- internal/data/system.go | 11 +- internal/data/system_init.go | 12 +- internal/data/system_init_state_test.go | 60 ++++++ internal/data/transactions_test.go | 105 +++++++++- internal/data/user.go | 98 +++++---- internal/data/version.go | 8 +- internal/data/version_test.go | 24 +++ internal/server/handler/announcement.go | 34 +--- internal/server/handler/audit.go | 105 ++++------ internal/server/handler/dictionary.go | 20 +- internal/server/handler/export.go | 12 +- internal/server/handler/media.go | 2 +- internal/server/handler/menu.go | 4 +- internal/server/handler/parameter.go | 12 +- internal/server/handler/permission.go | 5 +- internal/server/handler/public.go | 2 +- internal/server/handler/task.go | 6 +- internal/server/handler/user.go | 11 +- internal/server/handler/version.go | 3 +- internal/service/announcement.go | 6 +- internal/service/api.go | 7 +- internal/service/authority.go | 10 +- internal/service/department.go | 9 +- internal/service/department_test.go | 16 ++ internal/service/dictionary.go | 2 +- internal/service/dictionary_test.go | 28 +++ internal/service/dto/audit_test.go | 38 ++++ internal/service/dto/authority.go | 8 +- internal/service/dto/media.go | 3 + internal/service/dto/menu.go | 56 ++++-- internal/service/dto/permission.go | 5 +- internal/service/dto/settings.go | 12 +- internal/service/dto/system.go | 39 ++-- internal/service/dto/version.go | 8 +- internal/service/menu.go | 83 ++++++-- internal/service/menu_test.go | 63 ++++++ internal/service/parameter.go | 15 +- internal/service/permission.go | 2 +- internal/service/security.go | 2 +- internal/service/user.go | 12 +- internal/service/user_conversion.go | 39 ++-- internal/service/version.go | 27 ++- internal/worker/task_scheduler.go | 9 +- internal/worker/task_scheduler_test.go | 25 +++ pkg/adminauth/token.go | 9 +- web/src/view/systemTools/system/system.vue | 54 ++++- 96 files changed, 2888 insertions(+), 685 deletions(-) create mode 100644 internal/biz/media_test.go create mode 100644 internal/biz/user_test.go create mode 100644 internal/data/announcement_test.go create mode 100644 internal/data/api_policy_test.go create mode 100644 internal/data/authority_test.go create mode 100644 internal/data/dictionary_department_parity_test.go create mode 100644 internal/data/error_record_test.go create mode 100644 internal/data/media_test.go create mode 100644 internal/data/menu_test.go create mode 100644 internal/data/organization_test.go create mode 100644 internal/data/parameter_test.go create mode 100644 internal/data/permission_test.go create mode 100644 internal/data/version_test.go create mode 100644 internal/service/department_test.go create mode 100644 internal/service/dictionary_test.go create mode 100644 internal/service/dto/audit_test.go create mode 100644 internal/service/menu_test.go diff --git a/internal/biz/announcement.go b/internal/biz/announcement.go index 3b5dbe0..0e468b4 100644 --- a/internal/biz/announcement.go +++ b/internal/biz/announcement.go @@ -23,10 +23,10 @@ type AnnouncementFilter struct { type AnnouncementRepo interface { Create(context.Context, *Announcement) error - Delete(context.Context, uint) error - DeleteByIDs(context.Context, []uint) error + Delete(context.Context, string) error + DeleteByIDs(context.Context, []string) error Update(context.Context, *Announcement) error - Find(context.Context, uint) (*Announcement, error) + Find(context.Context, string) (*Announcement, error) List(context.Context, AnnouncementFilter) ([]*Announcement, int64, error) UserOptions(context.Context) ([]UserOption, error) } @@ -50,15 +50,15 @@ func (uc *AnnouncementUsecase) Update(ctx context.Context, item *Announcement) e return uc.repo.Update(ctx, item) } -func (uc *AnnouncementUsecase) Delete(ctx context.Context, id uint) error { +func (uc *AnnouncementUsecase) Delete(ctx context.Context, id string) error { return uc.repo.Delete(ctx, id) } -func (uc *AnnouncementUsecase) DeleteByIDs(ctx context.Context, ids []uint) error { +func (uc *AnnouncementUsecase) DeleteByIDs(ctx context.Context, ids []string) error { return uc.repo.DeleteByIDs(ctx, ids) } -func (uc *AnnouncementUsecase) Find(ctx context.Context, id uint) (*Announcement, error) { +func (uc *AnnouncementUsecase) Find(ctx context.Context, id string) (*Announcement, error) { return uc.repo.Find(ctx, id) } diff --git a/internal/biz/api.go b/internal/biz/api.go index c144a13..ae0aafc 100644 --- a/internal/biz/api.go +++ b/internal/biz/api.go @@ -79,7 +79,13 @@ func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff for _, item := range stored { storedSet[key(item)] = item } - diff := &APISyncDiff{Ignored: ignored} + // The sync API always returns JSON arrays for all three collections, + // including when no routes were added or removed. + diff := &APISyncDiff{ + Added: make([]*API, 0), + Deleted: make([]*API, 0), + Ignored: ignored, + } added := make(map[string]bool) for _, item := range routes { routeKey := key(item) diff --git a/internal/biz/authentication.go b/internal/biz/authentication.go index 7dd5f4a..ceb5f76 100644 --- a/internal/biz/authentication.go +++ b/internal/biz/authentication.go @@ -142,17 +142,14 @@ func (uc *AuthenticationUsecase) SwitchAuthority(ctx context.Context, claims *Au if err := uc.users.SetUserAuthority(ctx, claims.ID, authorityID); err != nil { return nil, err } - user, err := uc.users.User(ctx, claims.ID) - if err != nil { - return nil, err - } - // 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) + // Role switching signs the current claims again after replacing only the + // authority ID. It must not reload mutable profile fields or reset JWT + // timing/buffer claims as if this were a fresh login. + issued, err := uc.issuer.ReissueToken(claims, authorityID) if err != nil { return nil, err } + user := &User{ID: claims.ID, UUID: claims.UUID, Username: claims.Username, NickName: claims.NickName, AuthorityID: authorityID, MustChangePassword: claims.MustChangePwd} return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: claims.MustChangePwd}, nil } diff --git a/internal/biz/authentication_test.go b/internal/biz/authentication_test.go index 813fb6b..11fdc4b 100644 --- a/internal/biz/authentication_test.go +++ b/internal/biz/authentication_test.go @@ -18,6 +18,10 @@ func (r *authenticationUserRepo) FindUserByUsername(context.Context, string) (*U return r.user, nil } +func (*authenticationUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) { + return true, nil +} + type authenticationSecurityRepo struct{ SecurityRepo } func (*authenticationSecurityRepo) SecurityConfig(context.Context) (*SecurityConfig, error) { @@ -48,11 +52,42 @@ func (*authenticationIssuer) IssueToken(*User, uint, bool, time.Duration) (*Issu return &IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil } +func (*authenticationIssuer) ReissueToken(claims *AuthClaims, _ uint) (*IssuedToken, error) { + return &IssuedToken{Value: "token", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil +} + type authenticationAudit struct { AuditRecordRepo logins []*LoginLog } +type switchAuthorityUserRepo struct { + UserRepo + setUserID, setAuthorityID uint + findCalls int +} + +func (r *switchAuthorityUserRepo) SetUserAuthority(_ context.Context, userID, authorityID uint) error { + r.setUserID, r.setAuthorityID = userID, authorityID + return nil +} + +func (r *switchAuthorityUserRepo) FindUserByID(context.Context, uint) (*User, error) { + r.findCalls++ + return &User{NickName: "changed-in-database"}, nil +} + +type switchAuthorityIssuer struct { + TokenIssuer + claims *AuthClaims + authorityID uint +} + +func (i *switchAuthorityIssuer) ReissueToken(claims *AuthClaims, authorityID uint) (*IssuedToken, error) { + i.claims, i.authorityID = claims, authorityID + return &IssuedToken{Value: "switched", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil +} + func (a *authenticationAudit) RecordLogin(_ context.Context, value *LoginLog) error { a.logins = append(a.logins, value) return nil @@ -77,3 +112,28 @@ func TestLoginRecordsSuccessBeforeMultipointCacheFailure(t *testing.T) { t.Fatalf("expected successful login audit before cache failure, got %+v", audit.logins) } } + +func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T) { + repo := &switchAuthorityUserRepo{} + issuer := &switchAuthorityIssuer{} + uc := NewAuthenticationUsecase(NewUserUsecase(repo), nil, issuer, nil) + expiresAt := time.Now().Add(time.Hour).Truncate(time.Second) + claims := &AuthClaims{ID: 7, UUID: "uuid", Username: "admin", NickName: "token-nickname", AuthorityID: 888, MustChangePwd: true, ExpiresAt: expiresAt} + + result, err := uc.SwitchAuthority(context.Background(), claims, 999) + if err != nil { + t.Fatal(err) + } + if repo.setUserID != 7 || repo.setAuthorityID != 999 { + t.Fatalf("stored authority switch = user:%d authority:%d", repo.setUserID, repo.setAuthorityID) + } + if repo.findCalls != 0 { + t.Fatalf("switch reloaded user %d times", repo.findCalls) + } + if issuer.claims != claims || issuer.authorityID != 999 { + t.Fatalf("reissue input = claims:%p authority:%d", issuer.claims, issuer.authorityID) + } + if result.User.NickName != "token-nickname" || result.User.AuthorityID != 999 || !result.NeedChangePassword || !result.ExpiresAt.Equal(expiresAt) { + t.Fatalf("switch result = %+v", result) + } +} diff --git a/internal/biz/authority.go b/internal/biz/authority.go index 7e143be..82d7177 100644 --- a/internal/biz/authority.go +++ b/internal/biz/authority.go @@ -31,6 +31,7 @@ type Authority struct { DataScope int DefaultRouter string Children []*Authority + Menus []*Menu } type AuthorityUsecase struct{ AuthorityAccessRepo } diff --git a/internal/biz/dictionary.go b/internal/biz/dictionary.go index 3415666..40c332d 100644 --- a/internal/biz/dictionary.go +++ b/internal/biz/dictionary.go @@ -37,6 +37,7 @@ type DictionaryDetailRepo interface { FindDictionaryDetail(context.Context, uint) (*DictionaryDetail, error) ListDictionaryDetails(context.Context, int, int, DictionaryDetailFilter) ([]*DictionaryDetail, int64, error) DictionaryDetailTree(context.Context, uint, string) ([]*DictionaryDetail, error) + DictionaryDetailsByParent(context.Context, uint, *uint, bool) ([]*DictionaryDetail, error) } type Dictionary struct { @@ -78,38 +79,5 @@ func (uc *DictionaryUsecase) ImportDictionary(ctx context.Context, dictionary *D } func (uc *DictionaryUsecase) DictionaryDetailsByParent(ctx context.Context, dictionaryID uint, parentID *uint, includeChildren bool) ([]*DictionaryDetail, error) { - items, _, err := uc.ListDictionaryDetails(ctx, 0, 0, DictionaryDetailFilter{DictionaryID: dictionaryID}) - if err != nil { - return nil, err - } - direct := make([]*DictionaryDetail, 0) - for _, item := range items { - if (parentID == nil && item.ParentID == nil) || (parentID != nil && item.ParentID != nil && *item.ParentID == *parentID) { - direct = append(direct, item) - } - } - if !includeChildren { - return direct, nil - } - children := make(map[uint][]*DictionaryDetail) - for _, item := range items { - item.Children = []*DictionaryDetail{} - if item.ParentID != nil { - children[*item.ParentID] = append(children[*item.ParentID], item) - } - } - var attach func(*DictionaryDetail) - attach = func(item *DictionaryDetail) { - item.Children = children[item.ID] - if item.Children == nil { - item.Children = []*DictionaryDetail{} - } - for _, child := range item.Children { - attach(child) - } - } - for _, item := range direct { - attach(item) - } - return direct, nil + return uc.DictionaryRepo.DictionaryDetailsByParent(ctx, dictionaryID, parentID, includeChildren) } diff --git a/internal/biz/infrastructure.go b/internal/biz/infrastructure.go index a2f104f..c54b7d6 100644 --- a/internal/biz/infrastructure.go +++ b/internal/biz/infrastructure.go @@ -82,6 +82,8 @@ type AuthClaims struct { BufferTime time.Duration MustChangePwd bool Issuer string + Audience []string + NotBefore time.Time ExpiresAt time.Time } @@ -96,5 +98,6 @@ var ( type TokenIssuer interface { IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error) + ReissueToken(*AuthClaims, uint) (*IssuedToken, error) ParseToken(string) (*AuthClaims, error) } diff --git a/internal/biz/media.go b/internal/biz/media.go index f384996..481ec96 100644 --- a/internal/biz/media.go +++ b/internal/biz/media.go @@ -132,10 +132,11 @@ func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, 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" + // Multipart chunks are an internal staging detail. The compatible local + // implementation keeps them outside the configured object-storage root, so + // the public listing must not expose them even when the selected backend is + // also used for staging. + chunkRoot := "uploads/chunks" if uc.settings != nil { if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" { chunkRoot = configured @@ -145,7 +146,11 @@ func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, result := make([]*StoredFile, 0, limit) nextCursor := cursor for { - items, next, more, err := uc.files.List(ctx, prefix, nextCursor, limit) + // Ask only for the remaining visible capacity. Otherwise a page that is + // partially filtered could fill the response halfway through the next + // storage page while returning that page's end cursor, skipping objects. + remaining := limit - len(result) + items, next, more, err := uc.files.List(ctx, prefix, nextCursor, remaining) if err != nil { return nil, "", false, err } diff --git a/internal/biz/media_test.go b/internal/biz/media_test.go new file mode 100644 index 0000000..b846abc --- /dev/null +++ b/internal/biz/media_test.go @@ -0,0 +1,81 @@ +package biz + +import ( + "context" + "io" + "strings" + "testing" +) + +type listStorage struct { + items []*StoredFile + limits []int +} + +func (*listStorage) Put(context.Context, string, io.Reader) (*StoredFile, error) { + return nil, nil +} +func (*listStorage) Open(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("")), nil +} +func (*listStorage) Delete(context.Context, string) error { return nil } +func (*listStorage) Compose(context.Context, []string, string) (*StoredFile, string, error) { + return nil, "", nil +} +func (*listStorage) DeletePrefix(context.Context, string) error { return nil } +func (s *listStorage) List(_ context.Context, _ string, cursor string, limit int) ([]*StoredFile, string, bool, error) { + s.limits = append(s.limits, limit) + start := 0 + for start < len(s.items) && s.items[start].Path <= cursor { + start++ + } + end := start + limit + if end > len(s.items) { + end = len(s.items) + } + page := s.items[start:end] + more := end < len(s.items) + next := "" + if len(page) > 0 { + next = page[len(page)-1].Path + } + return page, next, more, nil +} + +func TestListStorageFiltersChunksWithoutSkippingVisibleObjects(t *testing.T) { + storage := &listStorage{items: []*StoredFile{ + {Path: "uploads/chunks/1/00000000"}, + {Path: "uploads/chunks/1/00000001"}, + {Path: "visible/a"}, + {Path: "visible/b"}, + {Path: "visible/c"}, + {Path: "visible/d"}, + {Path: "visible/e"}, + }} + uc := NewMediaUsecase(nil, storage, nil) + + first, cursor, more, err := uc.ListStorage(context.Background(), "", "", 3) + if err != nil { + t.Fatal(err) + } + if got := []string{first[0].Path, first[1].Path, first[2].Path}; strings.Join(got, ",") != "visible/a,visible/b,visible/c" { + t.Fatalf("first page = %v", got) + } + if cursor != "visible/c" || !more { + t.Fatalf("first cursor/more = %q/%v", cursor, more) + } + if len(storage.limits) != 2 || storage.limits[0] != 3 || storage.limits[1] != 2 { + t.Fatalf("storage limits = %v, want [3 2]", storage.limits) + } + + second, cursor, more, err := uc.ListStorage(context.Background(), "", cursor, 3) + if err != nil { + t.Fatal(err) + } + if got := []string{second[0].Path, second[1].Path}; strings.Join(got, ",") != "visible/d,visible/e" { + t.Fatalf("second page = %v", got) + } + if cursor != "visible/e" || more { + t.Fatalf("second cursor/more = %q/%v", cursor, more) + } +} diff --git a/internal/biz/menu.go b/internal/biz/menu.go index 122d1bf..55f57f7 100644 --- a/internal/biz/menu.go +++ b/internal/biz/menu.go @@ -59,6 +59,11 @@ type MenuRepo interface { List(context.Context) ([]*Menu, error) SetAuthorityMenus(context.Context, uint, []uint) error AuthorityMenuIDs(context.Context, uint) ([]uint, error) + // ListAuthorityMenus returns the base menus assigned to the target + // authority. Unlike List, it must not apply the current actor's strict + // menu filter: the administration endpoint is explicitly querying another + // role's assignments. + ListAuthorityMenus(context.Context, uint) ([]*Menu, error) MenuRoleIDs(context.Context, uint) ([]uint, error) DefaultRouterRoleIDs(context.Context, uint) ([]uint, error) SetMenuRoles(context.Context, uint, []uint) error @@ -79,10 +84,10 @@ func (uc *MenuUsecase) Tree(ctx context.Context) ([]*Menu, error) { } byID := make(map[uint]*Menu, len(items)) for _, item := range items { - item.Children = []*Menu{} + item.Children = nil byID[item.ID] = item } - roots := make([]*Menu, 0) + var roots []*Menu for _, item := range items { if parent := byID[item.ParentID]; parent != nil { parent.Children = append(parent.Children, item) @@ -114,31 +119,11 @@ func (uc *MenuUsecase) SetAuthorityMenus(ctx context.Context, authorityID uint, } func (uc *MenuUsecase) AuthorityMenus(ctx context.Context, authorityID uint) ([]*Menu, error) { - ids, err := uc.repo.AuthorityMenuIDs(ctx, authorityID) + items, err := uc.repo.ListAuthorityMenus(ctx, authorityID) if err != nil { return nil, err } - items, err := uc.repo.List(ctx) - if err != nil { - return nil, err - } - selected := make(map[uint]struct{}, len(ids)) - for _, id := range ids { - selected[id] = struct{}{} - } - result := make([]*Menu, 0, len(ids)) - for _, item := range items { - 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. 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) - } - } - return result, nil + return items, nil } func (uc *MenuUsecase) RoleIDs(ctx context.Context, menuID uint) ([]uint, error) { diff --git a/internal/biz/parameter.go b/internal/biz/parameter.go index e16f578..526ce15 100644 --- a/internal/biz/parameter.go +++ b/internal/biz/parameter.go @@ -20,8 +20,9 @@ type SystemParameter struct { type ParameterRepo interface { CreateParameter(context.Context, *SystemParameter) error UpdateParameter(context.Context, *SystemParameter) error - DeleteParameters(context.Context, []uint) error - FindParameter(context.Context, uint, string) (*SystemParameter, error) + DeleteParameters(context.Context, []string) error + FindParameterByID(context.Context, string) (*SystemParameter, error) + FindParameterByKey(context.Context, string) (*SystemParameter, error) ListParameters(context.Context, int, int, *SystemParameter) ([]*SystemParameter, int64, error) } diff --git a/internal/biz/permission.go b/internal/biz/permission.go index 4331607..7d83e6e 100644 --- a/internal/biz/permission.go +++ b/internal/biz/permission.go @@ -8,7 +8,7 @@ type PermissionRepo interface { AuthorityButtonIDs(context.Context, uint) ([]uint, error) SelectedButtons(context.Context, uint, uint) ([]uint, error) SetSelectedButtons(context.Context, uint, uint, []uint) error - CanRemoveButton(context.Context, uint) (bool, error) + CanRemoveButton(context.Context, string) (bool, error) } type PermissionUsecase struct{ PermissionRepo } diff --git a/internal/biz/security.go b/internal/biz/security.go index 6b216ce..7229a75 100644 --- a/internal/biz/security.go +++ b/internal/biz/security.go @@ -99,7 +99,7 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error) uc.mu.RUnlock() value, err := uc.repo.SecurityConfig(ctx) if err != nil { - // GVA returns its default value together with the database-not-ready + // Return the default value together with the database-not-ready // error. Callers such as login/runtime policy consumers intentionally // ignore the error and continue with that default, while the HTTP // settings endpoint still reports the failure. @@ -115,6 +115,13 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error) return value, nil } +// Security returns the persisted row for the administration endpoint. Runtime +// policy consumers use Current so their hot path remains cached, while the +// settings page mirrors the reference behavior and always observes storage. +func (uc *SecurityUsecase) Security(ctx context.Context) (*SecurityConfig, error) { + return uc.repo.SecurityConfig(ctx) +} + func (uc *SecurityUsecase) ValidatePassword(value *SecurityConfig, password string) error { if value.PwdMinLength > 0 && utf8.RuneCountInString(password) < value.PwdMinLength { return fmt.Errorf("密码长度不能少于 %d 位", value.PwdMinLength) diff --git a/internal/biz/security_test.go b/internal/biz/security_test.go index 1f592df..87dfd0b 100644 --- a/internal/biz/security_test.go +++ b/internal/biz/security_test.go @@ -81,3 +81,32 @@ func TestUpdateSecurityDoesNotBackfillWithoutDisabledToEnabledTransition(t *test t.Fatalf("call order = %v, want [get save]", got) } } + +func TestSecurityReadsPersistedValueWhileCurrentUsesCache(t *testing.T) { + repo := &securityUpdateRepo{current: &SecurityConfig{ID: 1, LimitCount: 10}} + uc := NewSecurityUsecase(repo, nil, nil, nil) + + cached, err := uc.Current(context.Background()) + if err != nil { + t.Fatal(err) + } + if cached.LimitCount != 10 { + t.Fatalf("initial cached limit = %d", cached.LimitCount) + } + repo.current.LimitCount = 20 + + current, err := uc.Current(context.Background()) + if err != nil { + t.Fatal(err) + } + if current.LimitCount != 10 { + t.Fatalf("Current() bypassed cache: %d", current.LimitCount) + } + fresh, err := uc.Security(context.Background()) + if err != nil { + t.Fatal(err) + } + if fresh.LimitCount != 20 { + t.Fatalf("Security() returned stale value: %d", fresh.LimitCount) + } +} diff --git a/internal/biz/user.go b/internal/biz/user.go index 3a96b0a..2da723a 100644 --- a/internal/biz/user.go +++ b/internal/biz/user.go @@ -38,6 +38,8 @@ type UserListFilter struct { type UserRepo interface { FindUserByUsername(context.Context, string) (*User, error) FindUserByID(context.Context, uint) (*User, error) + FindUserByUUID(context.Context, string) (*User, error) + HasAuthorityMenu(context.Context, uint, string) (bool, error) MenusByAuthority(context.Context, uint) ([]*Menu, error) ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error) CreateUser(context.Context, *User) (*User, error) @@ -77,23 +79,29 @@ func (uc *UserUsecase) User(ctx context.Context, id uint) (*User, error) { return user, nil } -func (uc *UserUsecase) fallbackDefaultRouter(ctx context.Context, user *User) { - if user == nil || user.Authority.DefaultRouter == "" { - return - } - menus, err := uc.repo.MenusByAuthority(ctx, user.AuthorityID) - if err != nil || !menuNameExists(menus, user.Authority.DefaultRouter) { - user.Authority.DefaultRouter = "404" +// UserByUUID resolves the identity from the UUID embedded in the JWT rather +// than from the mutable numeric ID used by administrative records. +func (uc *UserUsecase) UserByUUID(ctx context.Context, uuid string) (*User, error) { + user, err := uc.repo.FindUserByUUID(ctx, uuid) + if err != nil { + return nil, err } + _ = uc.repo.FillDepartmentNamePaths(ctx, user) + uc.fallbackDefaultRouter(ctx, user) + return user, nil } -func menuNameExists(menus []*Menu, name string) bool { - for _, menu := range menus { - if menu.Name == name || menuNameExists(menu.Children, name) { - return true - } +func (uc *UserUsecase) fallbackDefaultRouter(ctx context.Context, user *User) { + if user == nil { + return + } + hasMenu, err := uc.repo.HasAuthorityMenu(ctx, user.AuthorityID, user.Authority.DefaultRouter) + if err != nil { + return + } + if !hasMenu { + user.Authority.DefaultRouter = "404" } - return false } func (uc *UserUsecase) Menus(ctx context.Context, authorityID uint) ([]*Menu, error) { @@ -104,10 +112,6 @@ func (uc *UserUsecase) ListUsers(ctx context.Context, page, pageSize int, filter return uc.repo.ListUsers(ctx, page, pageSize, filter) } func (uc *UserUsecase) CreateUser(ctx context.Context, user *User, authorityIDs []uint) (*User, error) { - if user.AuthorityID == 0 && len(authorityIDs) > 0 { - user.AuthorityID = authorityIDs[0] - } - authorityIDs = includeAuthority(authorityIDs, user.AuthorityID) hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) if err != nil { return nil, err @@ -132,17 +136,6 @@ func (uc *UserUsecase) UpdateSelfUser(ctx context.Context, user *User) error { return uc.repo.UpdateSelfUser(ctx, user) } -func includeAuthority(ids []uint, primary uint) []uint { - if primary == 0 { - return ids - } - for _, id := range ids { - if id == primary { - return ids - } - } - return append(ids, primary) -} func (uc *UserUsecase) DeleteUser(ctx context.Context, id uint) error { return uc.repo.DeleteUser(ctx, id) } diff --git a/internal/biz/user_test.go b/internal/biz/user_test.go new file mode 100644 index 0000000..ee74a11 --- /dev/null +++ b/internal/biz/user_test.go @@ -0,0 +1,37 @@ +package biz + +import ( + "context" + "errors" + "testing" +) + +type defaultRouterUserRepo struct { + UserRepo + hasMenu bool + err error +} + +func (r *defaultRouterUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) { + return r.hasMenu, r.err +} + +func TestFallbackDefaultRouterMatchesMenuLookupOutcome(t *testing.T) { + user := &User{AuthorityID: 888, Authority: Authority{DefaultRouter: "dashboard"}} + NewUserUsecase(&defaultRouterUserRepo{hasMenu: false}).fallbackDefaultRouter(context.Background(), user) + if user.Authority.DefaultRouter != "404" { + t.Fatalf("missing default route = %q", user.Authority.DefaultRouter) + } + + user.Authority.DefaultRouter = "dashboard" + NewUserUsecase(&defaultRouterUserRepo{err: errors.New("database unavailable")}).fallbackDefaultRouter(context.Background(), user) + if user.Authority.DefaultRouter != "dashboard" { + t.Fatalf("lookup error changed default route to %q", user.Authority.DefaultRouter) + } + + user.Authority.DefaultRouter = "" + NewUserUsecase(&defaultRouterUserRepo{}).fallbackDefaultRouter(context.Background(), user) + if user.Authority.DefaultRouter != "404" { + t.Fatalf("empty missing default route = %q", user.Authority.DefaultRouter) + } +} diff --git a/internal/biz/version.go b/internal/biz/version.go index 9b4354d..59012e8 100644 --- a/internal/biz/version.go +++ b/internal/biz/version.go @@ -30,10 +30,10 @@ type Version struct { ID uint CreatedAt time.Time UpdatedAt time.Time - Name string - Code string - Description string - Data string + Name *string + Code *string + Description *string + Data *string } type VersionBundle struct { Name string diff --git a/internal/data/announcement.go b/internal/data/announcement.go index cbb35a0..fb4adcd 100644 --- a/internal/data/announcement.go +++ b/internal/data/announcement.go @@ -44,12 +44,12 @@ func (r *announcementRepo) Create(ctx context.Context, item *biz.Announcement) e return nil } -func (r *announcementRepo) Delete(ctx context.Context, id uint) error { - return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, id).Error +func (r *announcementRepo) Delete(ctx context.Context, id string) error { + return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, "id = ?", id).Error } -func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Where("id IN ?", ids).Delete(&announcementPO{}).Error +func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []string) error { + return r.data.gormDB.WithContext(ctx).Delete(&[]announcementPO{}, "id IN ?", ids).Error } func (r *announcementRepo) Update(ctx context.Context, item *biz.Announcement) error { @@ -57,9 +57,9 @@ func (r *announcementRepo) Update(ctx context.Context, item *biz.Announcement) e return r.data.gormDB.WithContext(ctx).Model(&announcementPO{}).Where("id = ?", item.ID).Updates(&po).Error } -func (r *announcementRepo) Find(ctx context.Context, id uint) (*biz.Announcement, error) { +func (r *announcementRepo) Find(ctx context.Context, id string) (*biz.Announcement, error) { var po announcementPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { return nil, err } return announcementToBiz(po), nil diff --git a/internal/data/announcement_test.go b/internal/data/announcement_test.go new file mode 100644 index 0000000..bd51086 --- /dev/null +++ b/internal/data/announcement_test.go @@ -0,0 +1,40 @@ +package data + +import ( + "context" + "testing" + + "kra/internal/biz" +) + +func TestAnnouncementRepositoryKeepsRawIDQuerySemantics(t *testing.T) { + data := newTransactionTestData(t) + repo := &announcementRepo{data: data} + ctx := context.Background() + item := &biz.Announcement{Title: "notice"} + if err := repo.Create(ctx, item); err != nil { + t.Fatal(err) + } + + found, err := repo.Find(ctx, "1") + if err != nil { + t.Fatal(err) + } + if found.ID != item.ID { + t.Fatalf("found announcement ID = %d, want %d", found.ID, item.ID) + } + if err = repo.DeleteByIDs(ctx, nil); err != nil { + t.Fatalf("empty bulk delete must be a successful no-op: %v", err) + } + if err = repo.Delete(ctx, "not-a-number"); err != nil { + t.Fatalf("raw invalid ID must be delegated to the storage query: %v", err) + } + + var count int64 + if err = data.gormDB.WithContext(ctx).Model(&announcementPO{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("raw invalid ID removed rows: count=%d", count) + } +} diff --git a/internal/data/api.go b/internal/data/api.go index 05e0067..c833096 100644 --- a/internal/data/api.go +++ b/internal/data/api.go @@ -11,6 +11,7 @@ import ( "github.com/casbin/casbin/v3" casbinmodel "github.com/casbin/casbin/v3/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type apiRepo struct{ data *Data } @@ -167,11 +168,11 @@ func (r *apiRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]* } } var pos []apiPO - maxSize := 100 - if q != nil && q.StrictAll { - maxSize = 0 + query := db.Order(order) + if q == nil || !q.StrictAll { + query = applyRequiredPagination(query, page, size, 100) } - if err := applyPagination(db.Order(order), page, size, maxSize).Find(&pos).Error; err != nil { + if err := query.Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.API, 0, len(pos)) @@ -248,7 +249,10 @@ func (r *apiRepo) PolicyPaths(ctx context.Context, aid uint) ([]*biz.API, error) if err != nil { return nil, err } - out := make([]*biz.API, 0, len(rows)) + var out []*biz.API + if len(rows) > 0 { + out = make([]*biz.API, 0, len(rows)) + } for _, row := range rows { out = append(out, &biz.API{Path: row.V1, Method: row.V2}) } @@ -264,46 +268,64 @@ func (r *apiRepo) SetPolicyPaths(ctx context.Context, aid uint, paths []*biz.API if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { return err } - if authority.ParentID != nil && *authority.ParentID != 0 { - allowed, err := r.PolicyPaths(ctx, actor.AuthorityID) + var registered []apiPO + if err := r.data.gormDB.WithContext(ctx).Find(®istered).Error; err != nil { + return err + } + allowedSet := make(map[string]bool, len(registered)) + if authority.ParentID == nil || *authority.ParentID == 0 { + for _, item := range registered { + allowedSet[item.Path+"\x00"+item.Method] = true + } + } else { + policies, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), actor.AuthorityID) if err != nil { return err } - allowedSet := make(map[string]bool, len(allowed)) - for _, item := range allowed { - allowedSet[item.Path+"\x00"+item.Method] = true + policySet := make(map[string]bool, len(policies)) + for _, item := range policies { + policySet[item.V1+"\x00"+item.V2] = true } - for _, item := range paths { - if !allowedSet[item.Path+"\x00"+item.Method] { - return errors.New("存在api不在权限列表中") + for _, item := range registered { + key := item.Path + "\x00" + item.Method + if policySet[key] { + allowedSet[key] = true } } } - } - 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 - } - rules := make([]casbinRulePO, 0, len(paths)) - seen := make(map[string]struct{}, len(paths)) - for _, path := range paths { - key := path.Path + "\x00" + path.Method - if _, ok := seen[key]; ok { - continue + for _, item := range paths { + if !allowedSet[item.Path+"\x00"+item.Method] { + return errors.New("存在api不在权限列表中") } - seen[key] = struct{}{} - rules = append(rules, newPolicyRule(aid, path.Path, path.Method)) } - if len(rules) > 0 { - return tx.Create(&rules).Error + } + db := r.data.gormDB.WithContext(ctx) + // The reference enforcer removes the old authority policies before it + // attempts to add the replacement set. Keep that ordering visible even + // though Kra reads policies directly from the database rather than through + // an in-memory enforcer. + if err := deletePoliciesForAuthority(db, aid); err != nil { + return err + } + // Keep the legacy relation table clean for upgraded installations. It is + // not consulted for authorization anymore. + if err := db.Where("authority_id = ?", aid).Delete(&authorityAPIPO{}).Error; err != nil { + return err + } + rules := make([]casbinRulePO, 0, len(paths)) + seen := make(map[string]struct{}) + for _, path := range paths { + key := strconv.FormatUint(uint64(aid), 10) + path.Path + path.Method + if _, ok := seen[key]; ok { + continue } - return nil - }) + seen[key] = struct{}{} + rules = append(rules, newPolicyRule(aid, path.Path, path.Method)) + } + if len(rules) > 0 { + return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rules).Error + } + return nil } func (r *apiRepo) IgnoredAPIs(ctx context.Context) ([]*biz.API, error) { diff --git a/internal/data/api_policy_test.go b/internal/data/api_policy_test.go new file mode 100644 index 0000000..dc43a17 --- /dev/null +++ b/internal/data/api_policy_test.go @@ -0,0 +1,114 @@ +package data + +import ( + "context" + "strings" + "testing" + + "kra/internal/biz" + "kra/internal/conf" +) + +func newPolicyTestData(t *testing.T) *Data { + t.Helper() + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&apiPO{}, &casbinRulePO{}, &authorityPO{}, &authorityAPIPO{}); err != nil { + t.Fatal(err) + } + data := &Data{ + gormDB: newReloadableDB(db, nil), + runtime: conf.NewRuntime( + &conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, + &conf.AdminBackend{System: &conf.AdminBackend_System{UseStrictAuth: true}}, + ), + } + t.Cleanup(func() { data.gormDB.close() }) + return data +} + +func apiPolicyUintPointer(value uint) *uint { return &value } + +func TestSetPolicyPathsStrictRootRequiresRegisteredAPI(t *testing.T) { + data := newPolicyTestData(t) + ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: 888}) + if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{ + {AuthorityID: 888, ParentID: apiPolicyUintPointer(0)}, + {AuthorityID: 999, ParentID: apiPolicyUintPointer(888)}, + }).Error; err != nil { + t.Fatal(err) + } + if err := data.gormDB.WithContext(ctx).Create(&apiPO{Path: "/known", Method: "POST"}).Error; err != nil { + t.Fatal(err) + } + + err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 999, []*biz.API{{Path: "/unknown", Method: "POST"}}) + if err == nil || !strings.Contains(err.Error(), "存在api不在权限列表中") { + t.Fatalf("unregistered root assignment error = %v", err) + } + if err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 999, []*biz.API{{Path: "/known", Method: "POST"}}); err != nil { + t.Fatal(err) + } +} + +func TestSetPolicyPathsStrictChildRejectsStalePolicy(t *testing.T) { + data := newPolicyTestData(t) + ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: 1001}) + if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{ + {AuthorityID: 888, ParentID: apiPolicyUintPointer(0)}, + {AuthorityID: 1001, ParentID: apiPolicyUintPointer(888)}, + {AuthorityID: 1002, ParentID: apiPolicyUintPointer(1001)}, + }).Error; err != nil { + t.Fatal(err) + } + if err := data.gormDB.WithContext(ctx).Create(&apiPO{Path: "/known", Method: "POST"}).Error; err != nil { + t.Fatal(err) + } + if err := data.gormDB.WithContext(ctx).Create(&[]casbinRulePO{ + newPolicyRule(1001, "/known", "POST"), + newPolicyRule(1001, "/stale", "POST"), + }).Error; err != nil { + t.Fatal(err) + } + + err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 1002, []*biz.API{{Path: "/stale", Method: "POST"}}) + if err == nil || !strings.Contains(err.Error(), "存在api不在权限列表中") { + t.Fatalf("stale child assignment error = %v", err) + } + if err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 1002, []*biz.API{{Path: "/known", Method: "POST"}}); err != nil { + t.Fatal(err) + } +} + +func TestSetPolicyPathsUsesCompatibleDedupeKey(t *testing.T) { + data := newPolicyTestData(t) + ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: 888}) + if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{ + {AuthorityID: 888, ParentID: apiPolicyUintPointer(0)}, + {AuthorityID: 999, ParentID: apiPolicyUintPointer(888)}, + }).Error; err != nil { + t.Fatal(err) + } + if err := data.gormDB.WithContext(ctx).Create(&[]apiPO{ + {Path: "/a", Method: "BC"}, + {Path: "/aB", Method: "C"}, + }).Error; err != nil { + t.Fatal(err) + } + + if err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 999, []*biz.API{ + {Path: "/a", Method: "BC"}, + {Path: "/aB", Method: "C"}, + }); err != nil { + t.Fatal(err) + } + rows, err := policyRowsForAuthority(data.gormDB.WithContext(ctx), 999) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].V1 != "/a" || rows[0].V2 != "BC" { + t.Fatalf("deduplicated policies = %#v, want only the first concatenated-key match", rows) + } +} diff --git a/internal/data/api_token.go b/internal/data/api_token.go index 696de04..85242a6 100644 --- a/internal/data/api_token.go +++ b/internal/data/api_token.go @@ -80,7 +80,7 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID return nil, 0, err } var pos []apiTokenPO - if err := applyPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := applyRequiredPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } userIDs := make([]uint, 0, len(pos)) diff --git a/internal/data/authority.go b/internal/data/authority.go index 1ca49d1..73bdbac 100644 --- a/internal/data/authority.go +++ b/internal/data/authority.go @@ -77,11 +77,12 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au } } return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var count int64 - if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil { - return err - } - if count > 0 { + // The administration contract checks existence with First and treats every result other than + // ErrRecordNotFound as a duplicate-role error. Keep that precedence so + // duplicate IDs win over strict-tree validation and the API message stays + // compatible. + var existing authorityPO + if err := tx.Where("authority_id = ?", value.AuthorityID).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("存在相同角色id") } if value.DefaultRouter == "" { @@ -92,6 +93,8 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au return err } value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt + value.DataScope, value.DefaultRouter = po.DataScope, po.DefaultRouter + value.Menus = []*biz.Menu{{ID: 1, Path: "dashboard", Name: "dashboard", Component: "view/dashboard/index.vue", Sort: 1, Title: "仪表盘", Icon: "setting"}} 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 { @@ -113,10 +116,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-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. + // Reject a duplicate target ID before performing the + // hierarchy/Casbin checks that happen later in UpdateCasbin. + var existing authorityPO + if err := tx.Where("authority_id = ?", value.AuthorityID).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("存在相同角色id") + } + + // UpdateCasbin checks that the newly-created role is in the actor's + // strict-authority subtree. Before inserting it, the equivalent check is + // that its requested parent is the actor or one of the actor's allowed + // descendants. This preserves the compatible error and prevents a transient role + // from being visible when the check is guaranteed to fail. strict := hasActor && config != nil && config.System != nil && config.System.UseStrictAuth if strict { allowed, err := r.strictAuthorityIDs(ctx, actor.AuthorityID) @@ -128,18 +139,12 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, return errors.New("您提交的角色ID不合法") } } - var count int64 - if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil { - return err - } - if count != 0 { - return errors.New("存在相同角色id") - } 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 + value.DataScope, value.DefaultRouter = po.DataScope, po.DefaultRouter copyLinks := func(table string, destination any, columns map[string]any) error { return tx.Table(table).Where(columns).Find(destination).Error } @@ -155,6 +160,20 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, return err } } + if len(menus) > 0 { + menuIDs := make([]uint, 0, len(menus)) + for _, menu := range menus { + menuIDs = append(menuIDs, menu.SysBaseMenuID) + } + var copiedMenus []menuPO + if err := tx.Where("id IN ?", menuIDs).Order("sort").Find(&copiedMenus).Error; err != nil { + return err + } + value.Menus = make([]*biz.Menu, 0, len(copiedMenus)) + for _, menu := range copiedMenus { + value.Menus = append(value.Menus, menuFromPO(menu)) + } + } apis, err := policyRowsForAuthority(tx, sourceID) if err != nil { return err @@ -163,7 +182,7 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, seenPolicies := make(map[string]struct{}, len(apis)) for _, api := range apis { if strict { - allowed, err := policyExists(tx, actor.AuthorityID, api.V1, api.V2) + allowed, err := r.copyPolicyAllowed(ctx, tx, actor.AuthorityID, api.V1, api.V2) if err != nil { return err } @@ -198,6 +217,28 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, return nil }) } + +// copyPolicyAllowed mirrors the administration API-list and policy validation. +// A policy must refer to a currently registered API. Root roles +// may copy any registered API, while non-root roles may copy only APIs present +// in their own Casbin policy set. +func (r *authorityAccessRepo) copyPolicyAllowed(ctx context.Context, tx *gorm.DB, actorID uint, path, method string) (bool, error) { + var actor authorityPO + if err := tx.WithContext(ctx).Where("authority_id = ?", actorID).First(&actor).Error; err != nil { + return false, err + } + var apiCount int64 + if err := tx.WithContext(ctx).Model(&apiPO{}).Where("path = ? AND method = ?", path, method).Count(&apiCount).Error; err != nil { + return false, err + } + if apiCount == 0 { + return false, nil + } + if actor.ParentID == nil || *actor.ParentID == 0 { + return true, nil + } + return policyExists(tx.WithContext(ctx), actorID, path, method) +} func (r *authorityAccessRepo) UpdateAuthority(ctx context.Context, value *biz.Authority) error { db := r.data.gormDB.WithContext(ctx) var current authorityPO @@ -217,11 +258,21 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro return err } var users, children int64 - if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Count(&users).Error; err != nil { + // Associated-user checks only block on users that still + // exist (and are not soft-deleted). Counting raw join rows would make an + // orphaned association impossible to clean up, which is observably + // different from the administration contract. + var linkedUserIDs []uint + if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_user_id", &linkedUserIDs).Error; err != nil { return err } - if users > 0 { - return errors.New("此角色有用户正在使用禁止删除") + if len(linkedUserIDs) > 0 { + if err := tx.Model(&userPO{}).Where("id IN ?", linkedUserIDs).Count(&users).Error; err != nil { + return err + } + if users > 0 { + return errors.New("此角色有用户正在使用禁止删除") + } } if err := tx.Model(&userPO{}).Where("authority_id = ?", id).Count(&users).Error; err != nil { return err @@ -235,6 +286,9 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro if children > 0 { return errors.New("此角色存在子角色不允许删除") } + if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil { + return err + } if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { return err } @@ -253,18 +307,28 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro }) } func (r *authorityAccessRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) { - var pos []authorityPO - if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil { - return nil, err - } + db := r.data.gormDB.WithContext(ctx) var allowed map[uint]bool config := r.data.runtime.Admin() - if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { - var err error - allowed, err = r.strictAuthorityIDs(ctx, actor.AuthorityID) - if err != nil { + if actor, ok := biz.ActorFromContext(ctx); ok { + // The current authority is loaded even when strict mode is disabled; + // an invalid token authority therefore fails the list request instead of + // exposing the complete role tree. + var current authorityPO + if err := db.Where("authority_id = ?", actor.AuthorityID).First(¤t).Error; err != nil { return nil, err } + if config != nil && config.System != nil && config.System.UseStrictAuth { + var err error + allowed, err = r.strictAuthorityIDs(ctx, actor.AuthorityID) + if err != nil { + return nil, err + } + } + } + var pos []authorityPO + if err := db.Find(&pos).Error; err != nil { + return nil, err } out := make([]*biz.Authority, 0, len(pos)) for _, po := range pos { @@ -285,15 +349,6 @@ func (r *authorityAccessRepo) SetAuthorityUsers(ctx context.Context, id uint, id if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil { return err } - links := make([]userAuthorityPO, 0, len(ids)) - for _, uid := range ids { - links = append(links, userAuthorityPO{SysUserID: uid, SysAuthorityAuthorityID: id}) - } - if len(links) > 0 { - if err := tx.Create(&links).Error; err != nil { - return err - } - } selected := map[uint]bool{} for _, uid := range ids { selected[uid] = true @@ -305,16 +360,25 @@ func (r *authorityAccessRepo) SetAuthorityUsers(ctx context.Context, id uint, id var user userPO if err := tx.First(&user, uid).Error; err == nil && user.AuthorityID == id { var another userAuthorityPO - if err = tx.Where("sys_user_id = ?", uid).First(&another).Error; errors.Is(err, gorm.ErrRecordNotFound) { + if err = tx.Where("sys_user_id = ?", uid).First(&another).Error; err != nil { + // Missing users and association lookup errors are ignored + // while choosing a replacement role. continue - } else if err != nil { - return err } if err = tx.Model(&user).Update("authority_id", another.SysAuthorityAuthorityID).Error; err != nil { return err } } } + links := make([]userAuthorityPO, 0, len(ids)) + for _, uid := range ids { + links = append(links, userAuthorityPO{SysUserID: uid, SysAuthorityAuthorityID: id}) + } + if len(links) > 0 { + if err := tx.Create(&links).Error; err != nil { + return err + } + } return nil }) } @@ -328,7 +392,7 @@ func (r *authorityAccessRepo) SetDataScope(ctx context.Context, id uint, scope i if err := tx.Model(&authorityPO{}).Where("authority_id = ?", id).Update("data_scope", scope).Error; err != nil { return err } - if err := tx.Where("authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil { + if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil { return err } if scope == 5 { @@ -345,7 +409,7 @@ func (r *authorityAccessRepo) SetDataScope(ctx context.Context, id uint, scope i } func (r *authorityAccessRepo) DataScopeDepartmentIDs(ctx context.Context, id uint) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&authorityDepartmentPO{}).Where("authority_id = ?", id).Pluck("department_id", &ids).Error + err := r.data.gormDB.WithContext(ctx).Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_department_id", &ids).Error return ids, err } func (r *authorityAccessRepo) ResolveDataScope(ctx context.Context, authorityID, userID uint) (biz.DataScope, error) { diff --git a/internal/data/authority_test.go b/internal/data/authority_test.go new file mode 100644 index 0000000..50a9946 --- /dev/null +++ b/internal/data/authority_test.go @@ -0,0 +1,179 @@ +package data + +import ( + "context" + "errors" + "testing" + + "kra/internal/biz" + "kra/internal/conf" + + "gorm.io/gorm" +) + +func authorityUintPointer(value uint) *uint { return &value } + +func enableStrictAuthorityTestMode(data *Data) { + currentData, _ := data.runtime.Values() + data.runtime.Replace(currentData, &conf.AdminBackend{System: &conf.AdminBackend_System{UseStrictAuth: true}}) +} + +func TestCopyAuthorityStrictPolicyValidationMatchesAdministrationContract(t *testing.T) { + data := newTransactionTestData(t) + enableStrictAuthorityTestMode(data) + db := data.gormDB.WithContext(context.Background()) + rootID := uint(888) + if err := db.Create(&[]authorityPO{ + {AuthorityID: rootID, AuthorityName: "root", ParentID: authorityUintPointer(0)}, + {AuthorityID: 900, AuthorityName: "source", ParentID: &rootID}, + {AuthorityID: 901, AuthorityName: "stale-source", ParentID: &rootID}, + }).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&apiPO{Path: "/registered", Method: "GET"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&[]casbinRulePO{ + newPolicyRule(900, "/registered", "GET"), + newPolicyRule(901, "/stale", "POST"), + // A root role is allowed to grant registered APIs without already + // holding the policy, but a stale policy is still not a registered API. + newPolicyRule(rootID, "/stale", "POST"), + }).Error; err != nil { + t.Fatal(err) + } + + repo := &authorityAccessRepo{data: data} + ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: rootID}) + created := &biz.Authority{AuthorityID: 910, AuthorityName: "copy", ParentID: &rootID} + if err := repo.CopyAuthority(ctx, 900, created); err != nil { + t.Fatalf("root copy of a registered API failed: %v", err) + } + if exists, err := policyExists(db, created.AuthorityID, "/registered", "GET"); err != nil || !exists { + t.Fatalf("copied policy exists = %v, err = %v", exists, err) + } + + staleCopy := &biz.Authority{AuthorityID: 911, AuthorityName: "stale-copy", ParentID: &rootID} + if err := repo.CopyAuthority(ctx, 901, staleCopy); err == nil || err.Error() != "存在api不在权限列表中" { + t.Fatalf("stale API copy error = %v", err) + } + var count int64 + if err := db.Unscoped().Model(&authorityPO{}).Where("authority_id = ?", staleCopy.AuthorityID).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("failed copy persisted target authority: count=%d", count) + } +} + +func TestCopyAuthorityDuplicateIDWinsOverStrictParentValidation(t *testing.T) { + data := newTransactionTestData(t) + enableStrictAuthorityTestMode(data) + db := data.gormDB.WithContext(context.Background()) + rootID := uint(888) + if err := db.Create(&[]authorityPO{ + {AuthorityID: rootID, AuthorityName: "root", ParentID: authorityUintPointer(0)}, + {AuthorityID: 920, AuthorityName: "existing", ParentID: &rootID}, + }).Error; err != nil { + t.Fatal(err) + } + invalidParent := uint(999999) + ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: rootID}) + err := (&authorityAccessRepo{data: data}).CopyAuthority(ctx, 0, &biz.Authority{AuthorityID: 920, AuthorityName: "duplicate", ParentID: &invalidParent}) + if err == nil || err.Error() != "存在相同角色id" { + t.Fatalf("duplicate copy error = %v", err) + } +} + +func TestListAuthoritiesRequiresCurrentAuthorityOutsideStrictMode(t *testing.T) { + data := newTransactionTestData(t) + ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: 999999}) + if _, err := (&authorityAccessRepo{data: data}).ListAuthorities(ctx); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("missing current authority error = %v", err) + } +} + +func TestDeleteAuthorityIgnoresOrphanedUserAssociation(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + db := data.gormDB.WithContext(ctx) + if err := db.Create(&authorityPO{AuthorityID: 930, AuthorityName: "orphan-links"}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&userAuthorityPO{SysUserID: 999999, SysAuthorityAuthorityID: 930}).Error; err != nil { + t.Fatal(err) + } + if err := (&authorityAccessRepo{data: data}).DeleteAuthority(ctx, 930); err != nil { + t.Fatalf("delete with orphaned user association: %v", err) + } + var authority authorityPO + if err := db.Unscoped().Where("authority_id = ?", 930).First(&authority).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("deleted authority lookup error = %v", err) + } + var links int64 + if err := db.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", 930).Count(&links).Error; err != nil { + t.Fatal(err) + } + if links != 0 { + t.Fatalf("orphaned user associations were not removed: %d", links) + } +} + +func TestSetAuthorityUsersKeepsPrimaryRoleFallbackBehavior(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + db := data.gormDB.WithContext(ctx) + if err := db.Create(&[]authorityPO{{AuthorityID: 940, AuthorityName: "removed"}, {AuthorityID: 941, AuthorityName: "fallback"}}).Error; err != nil { + t.Fatal(err) + } + users := []userPO{ + {Username: "with-fallback", Password: "hash", AuthorityID: 940, Enable: 1}, + {Username: "without-fallback", Password: "hash", AuthorityID: 940, Enable: 1}, + } + if err := db.Create(&users).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&[]userAuthorityPO{ + {SysUserID: users[0].ID, SysAuthorityAuthorityID: 940}, + {SysUserID: users[0].ID, SysAuthorityAuthorityID: 941}, + {SysUserID: users[1].ID, SysAuthorityAuthorityID: 940}, + }).Error; err != nil { + t.Fatal(err) + } + if err := (&authorityAccessRepo{data: data}).SetAuthorityUsers(ctx, 940, nil); err != nil { + t.Fatal(err) + } + var persisted []userPO + if err := db.Where("id IN ?", []uint{users[0].ID, users[1].ID}).Order("id").Find(&persisted).Error; err != nil { + t.Fatal(err) + } + if len(persisted) != 2 || persisted[0].AuthorityID != 941 || persisted[1].AuthorityID != 940 { + t.Fatalf("primary authority fallback = %+v", persisted) + } +} + +func TestAuthorityCustomDataScopeUsesCompatibleColumns(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + repo := &authorityAccessRepo{data: data} + if err := repo.SetDataScope(ctx, 950, 5, []uint{7, 7, 8}); err != nil { + t.Fatal(err) + } + ids, err := repo.DataScopeDepartmentIDs(ctx, 950) + if err != nil { + t.Fatal(err) + } + if len(ids) != 3 || ids[0] != 7 || ids[1] != 7 || ids[2] != 8 { + t.Fatalf("custom department IDs = %v", ids) + } + if err = repo.SetDataScope(ctx, 950, 3, []uint{9}); err != nil { + t.Fatal(err) + } + ids, err = repo.DataScopeDepartmentIDs(ctx, 950) + if err != nil { + t.Fatal(err) + } + if len(ids) != 0 { + t.Fatalf("non-custom scope retained departments: %v", ids) + } +} diff --git a/internal/data/config_store.go b/internal/data/config_store.go index 519c702..8a6a457 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -172,11 +172,12 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con return writeConfigDocument(configPath, &document) } -// persistDatabaseConfig writes only the database selected on the init page. -// Initialization must not serialize the partially populated runtime config -// back over the template: doing so removes all omitted/default settings from -// data and admin and leaves the next startup with no visible configuration. -func (d *Data) persistDatabaseConfig(database *conf.Data_Database) error { +// persistDatabaseConfig writes only the database selected on the init page and +// the freshly generated JWT signing key. Initialization must not serialize the +// partially populated runtime config back over the template: doing so removes +// all omitted/default settings from data and admin and leaves the next startup +// with no visible configuration. +func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey string) error { d.configMu.Lock() defer d.configMu.Unlock() @@ -215,6 +216,11 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database) error { if err = setYAMLMapping(&document, "data", map[string]any{"database": databaseValue}); err != nil { return err } + if signingKey != "" { + if err = setYAMLMapping(&document, "admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil { + return err + } + } return writeConfigDocument(configPath, &document) } diff --git a/internal/data/data.go b/internal/data/data.go index ca7e014..8293739 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -96,8 +96,8 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro c = &conf.Data{} } if c.Database == nil { - // An empty database block is the Kratos equivalent of GVA's empty - // Dbname: the service starts on the bootstrap database so /init/checkdb + // An empty database block starts the service on the bootstrap database + // so /init/checkdb // and /init/initdb remain available. c.Database = &conf.Data_Database{} } diff --git a/internal/data/data_access_log.go b/internal/data/data_access_log.go index 2f7572d..98194d1 100644 --- a/internal/data/data_access_log.go +++ b/internal/data/data_access_log.go @@ -43,7 +43,7 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q * return nil, 0, err } var pos []dataAccessLogPO - if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.DataAccessLog, 0, len(pos)) diff --git a/internal/data/department.go b/internal/data/department.go index 54c1bf5..d6fc7c5 100644 --- a/internal/data/department.go +++ b/internal/data/department.go @@ -32,15 +32,17 @@ type departmentPO struct { func (departmentPO) TableName() string { return "sys_departments" } type userDepartmentPO struct { - UserID uint `gorm:"primaryKey;column:sys_user_id"` - DepartmentID uint `gorm:"primaryKey;column:sys_department_id"` + // Match GVA's join model: no composite primary key, so repeated IDs in a + // request are persisted in the same order/shape as the reference service. + UserID uint `gorm:"column:sys_user_id"` + DepartmentID uint `gorm:"column:sys_department_id"` } func (userDepartmentPO) TableName() string { return "sys_user_departments" } type authorityDepartmentPO struct { - AuthorityID uint `gorm:"primaryKey;column:authority_id"` - DepartmentID uint `gorm:"primaryKey;column:department_id"` + AuthorityID uint `gorm:"column:sys_authority_authority_id;index"` + DepartmentID uint `gorm:"column:sys_department_id"` } func (authorityDepartmentPO) TableName() string { return "sys_authority_departments" } @@ -152,6 +154,12 @@ func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*b items := make([]*biz.Department, 0, len(pos)) for _, po := range pos { item := deptFromPO(po) + if name == "" { + // Recursive GORM child queries initialize leaf collections to an empty + // slice. Preserve that tree response shape while search and single-item + // queries keep the model's nil children value. + item.Children = []*biz.Department{} + } nodes[po.ID] = item items = append(items, item) } @@ -161,7 +169,7 @@ func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*b if name != "" { return items, nil } - var roots []*biz.Department + roots := make([]*biz.Department, 0) for _, po := range pos { n := nodes[po.ID] if p := nodes[po.ParentID]; p != nil { diff --git a/internal/data/dictionary.go b/internal/data/dictionary.go index 2bbee16..42bd6bf 100644 --- a/internal/data/dictionary.go +++ b/internal/data/dictionary.go @@ -75,6 +75,10 @@ func (r *dictionaryRepo) CreateDictionary(ctx context.Context, v *biz.Dictionary } func (r *dictionaryRepo) ImportDictionary(ctx context.Context, dictionary *biz.Dictionary, details []*biz.DictionaryDetail) error { + var existing dictionaryPO + if err := r.data.gormDB.WithContext(ctx).Where("type = ?", dictionary.Type).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("存在相同的type,不允许导入") + } return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { po := dictionaryPO{Name: dictionary.Name, Type: dictionary.Type, Status: dictionary.Status, Desc: dictionary.Desc, ParentID: dictionary.ParentID} if err := tx.Create(&po).Error; err != nil { @@ -183,15 +187,13 @@ func (r *dictionaryRepo) FindDictionary(ctx context.Context, id uint, typ string } out := dictionaryFromPO(po) if details { - out.Details, _, err = r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: out.ID}) - if err == nil { - filtered := out.Details[:0] - for _, item := range out.Details { - if item.Status != nil && *item.Status { - filtered = append(filtered, item) - } - } - out.Details = filtered + var detailPOs []dictionaryDetailPO + if err = db.Where("sys_dictionary_id = ? AND status = ?", out.ID, true).Order("sort").Find(&detailPOs).Error; err != nil { + return nil, err + } + out.Details = make([]*biz.DictionaryDetail, 0, len(detailPOs)) + for _, detail := range detailPOs { + out.Details = append(out.Details, detailFromPO(detail)) } } return out, err @@ -203,11 +205,14 @@ func (r *dictionaryRepo) ExportDictionary(ctx context.Context, id uint) (*biz.Di return nil, err } value := dictionaryFromPO(po) - details, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: id}) - if err != nil { + var detailPOs []dictionaryDetailPO + if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", id).Order("sort").Find(&detailPOs).Error; err != nil { return nil, err } - value.Details = details + value.Details = make([]*biz.DictionaryDetail, 0, len(detailPOs)) + for _, detail := range detailPOs { + value.Details = append(value.Details, detailFromPO(detail)) + } return value, nil } func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, name, typ string, details bool) ([]*biz.Dictionary, int64, error) { @@ -221,19 +226,26 @@ func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, n return nil, 0, err } var pos []dictionaryPO - if err := applyPagination(db.Order("id"), page, size, 0).Find(&pos).Error; err != nil { + // Both dictionary-list endpoints are intentionally unpaged. The page and + // size parameters remain in the repository seam for existing callers, but + // the administration contract ignores them. + if err := db.Find(&pos).Error; err != nil { return nil, 0, err } parentIDs := make([]uint, 0, len(pos)) dictionaryIDs := make([]uint, 0, len(pos)) for _, po := range pos { - parentIDs = append(parentIDs, po.ID) dictionaryIDs = append(dictionaryIDs, po.ID) + // The reference list-with-details endpoint only preloads details; + // its ordinary list endpoint preloads one level of dictionary children. + if !details { + parentIDs = append(parentIDs, po.ID) + } } childrenByParent := make(map[uint][]dictionaryPO) - if len(parentIDs) > 0 { + if !details && len(parentIDs) > 0 { var children []dictionaryPO - if err := r.data.gormDB.WithContext(ctx).Where("parent_id IN ?", parentIDs).Order("id").Find(&children).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Where("parent_id IN ?", parentIDs).Find(&children).Error; err != nil { return nil, 0, err } for _, child := range children { @@ -255,13 +267,16 @@ func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, n out := make([]*biz.Dictionary, 0, len(pos)) for _, po := range pos { v := dictionaryFromPO(po) - for _, child := range childrenByParent[po.ID] { - v.Children = append(v.Children, dictionaryFromPO(child)) - } if details { + v.Details = make([]*biz.DictionaryDetail, 0, len(detailsByDictionary[v.ID])) for _, detail := range detailsByDictionary[v.ID] { v.Details = append(v.Details, detailFromPO(detail)) } + } else { + v.Children = make([]*biz.Dictionary, 0, len(childrenByParent[po.ID])) + for _, child := range childrenByParent[po.ID] { + v.Children = append(v.Children, dictionaryFromPO(child)) + } } out = append(out, v) } @@ -309,28 +324,25 @@ func (r *dictionaryRepo) UpdateDictionaryDetail(ctx context.Context, v *biz.Dict level = parent.Level + 1 path = strings.Trim(strings.Join([]string{parent.Path, fmt.Sprint(parent.ID)}, ","), ",") } - 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 r.updateDictionaryDetailChildren(tx, v.ID, level, path) - }) + if err := db.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 r.updateDictionaryDetailChildren(db, 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 { +// updateDictionaryDetailChildren recomputes descendants after a move. Updating +// level as well as path matters when a subtree is moved beneath a different depth. +func (r *dictionaryRepo) updateDictionaryDetailChildren(db *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 { + if err := db.Where("parent_id = ?", parentID).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 { + if err := db.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 { + if err := r.updateDictionaryDetailChildren(db, child.ID, parentLevel+1, childPath); err != nil { return err } } @@ -378,7 +390,7 @@ func (r *dictionaryRepo) ListDictionaryDetails(ctx context.Context, page, size i return nil, 0, err } var pos []dictionaryDetailPO - if err := applyPagination(db.Order("sort,id"), page, size, 100).Find(&pos).Error; err != nil { + if err := applyRequiredPagination(db.Order("sort,id"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.DictionaryDetail, 0, len(pos)) @@ -398,22 +410,57 @@ func (r *dictionaryRepo) DictionaryDetailTree(ctx context.Context, dictionaryID } dictionaryID = dictionary.ID } - items, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: dictionaryID}) - if err != nil { + var pos []dictionaryDetailPO + if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ? AND parent_id IS NULL", dictionaryID).Order("sort").Find(&pos).Error; err != nil { return nil, err } - byID := map[uint]*biz.DictionaryDetail{} - for _, item := range items { - item.Children = []*biz.DictionaryDetail{} - byID[item.ID] = item - } - roots := []*biz.DictionaryDetail{} - for _, item := range items { - if item.ParentID != nil && byID[*item.ParentID] != nil { - byID[*item.ParentID].Children = append(byID[*item.ParentID].Children, item) - } else if item.ParentID == nil { - roots = append(roots, item) + roots := make([]*biz.DictionaryDetail, 0, len(pos)) + for _, po := range pos { + item := detailFromPO(po) + if err := r.loadDictionaryDetailChildren(ctx, item); err != nil { + return nil, err } + roots = append(roots, item) } return roots, nil } + +func (r *dictionaryRepo) DictionaryDetailsByParent(ctx context.Context, dictionaryID uint, parentID *uint, includeChildren bool) ([]*biz.DictionaryDetail, error) { + db := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", dictionaryID) + if parentID == nil { + db = db.Where("parent_id IS NULL") + } else { + db = db.Where("parent_id = ?", *parentID) + } + var pos []dictionaryDetailPO + if err := db.Order("sort").Find(&pos).Error; err != nil { + return nil, err + } + items := make([]*biz.DictionaryDetail, 0, len(pos)) + for _, po := range pos { + item := detailFromPO(po) + if includeChildren { + if err := r.loadDictionaryDetailChildren(ctx, item); err != nil { + return nil, err + } + } + items = append(items, item) + } + return items, nil +} + +func (r *dictionaryRepo) loadDictionaryDetailChildren(ctx context.Context, parent *biz.DictionaryDetail) error { + var pos []dictionaryDetailPO + if err := r.data.gormDB.WithContext(ctx).Where("parent_id = ?", parent.ID).Order("sort").Find(&pos).Error; err != nil { + return err + } + parent.Children = make([]*biz.DictionaryDetail, 0, len(pos)) + for _, po := range pos { + child := detailFromPO(po) + if err := r.loadDictionaryDetailChildren(ctx, child); err != nil { + return err + } + parent.Children = append(parent.Children, child) + } + return nil +} diff --git a/internal/data/dictionary_department_parity_test.go b/internal/data/dictionary_department_parity_test.go new file mode 100644 index 0000000..9288e57 --- /dev/null +++ b/internal/data/dictionary_department_parity_test.go @@ -0,0 +1,188 @@ +package data + +import ( + "context" + "testing" + + "kra/internal/biz" +) + +func TestDictionaryListPreservesPreloadCollectionShapes(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + repo := &dictionaryRepo{data: data} + + if err := repo.CreateDictionary(ctx, &biz.Dictionary{Name: "status", Type: "status"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateDictionary(ctx, &biz.Dictionary{Name: "kind", Type: "kind"}); err != nil { + t.Fatal(err) + } + + plain, _, err := repo.ListDictionaries(ctx, 1, 1, "", "", false) + if err != nil { + t.Fatal(err) + } + if len(plain) != 2 || plain[0].Children == nil || len(plain[0].Children) != 0 { + t.Fatalf("ordinary dictionary list children = %#v, want an empty preloaded slice", plain) + } + if plain[0].Details != nil { + t.Fatalf("ordinary dictionary list details = %#v, want nil", plain[0].Details) + } + + withDetails, _, err := repo.ListDictionaries(ctx, 1, 1, "", "", true) + if err != nil { + t.Fatal(err) + } + if len(withDetails) != 2 || withDetails[0].Details == nil || len(withDetails[0].Details) != 0 { + t.Fatalf("dictionary details preload = %#v, want an empty preloaded slice", withDetails) + } + if withDetails[0].Children != nil { + t.Fatalf("details-only dictionary list children = %#v, want nil", withDetails[0].Children) + } +} + +func TestImportDictionaryRejectsDuplicateType(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + repo := &dictionaryRepo{data: data} + + if err := repo.CreateDictionary(ctx, &biz.Dictionary{Name: "first", Type: "duplicate"}); err != nil { + t.Fatal(err) + } + if err := repo.ImportDictionary(ctx, &biz.Dictionary{Name: "second", Type: "duplicate"}, nil); err == nil || err.Error() != "存在相同的type,不允许导入" { + t.Fatalf("duplicate import error = %v", err) + } + + var count int64 + if err := data.gormDB.WithContext(ctx).Model(&dictionaryPO{}).Where("type = ?", "duplicate").Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("duplicate dictionary count = %d, want 1", count) + } +} + +func TestDictionaryTreeAndParentQueriesPreserveLoadedChildrenShape(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + repo := &dictionaryRepo{data: data} + + dictionary := &biz.Dictionary{Name: "tree", Type: "tree"} + if err := repo.CreateDictionary(ctx, dictionary); err != nil { + t.Fatal(err) + } + parent := &biz.DictionaryDetail{Label: "parent", Value: "parent", DictionaryID: dictionary.ID, Sort: 2} + if err := repo.CreateDictionaryDetail(ctx, parent); err != nil { + t.Fatal(err) + } + parentID := parent.ID + child := &biz.DictionaryDetail{Label: "child", Value: "child", DictionaryID: dictionary.ID, ParentID: &parentID, Sort: 1} + if err := repo.CreateDictionaryDetail(ctx, child); err != nil { + t.Fatal(err) + } + + tree, err := repo.DictionaryDetailTree(ctx, dictionary.ID, "") + if err != nil { + t.Fatal(err) + } + if len(tree) != 1 || len(tree[0].Children) != 1 || tree[0].Children[0].Children == nil { + t.Fatalf("dictionary tree = %#v, want recursively loaded child slices", tree) + } + + flat, err := repo.DictionaryDetailsByParent(ctx, dictionary.ID, &parentID, false) + if err != nil { + t.Fatal(err) + } + if len(flat) != 1 || flat[0].Children != nil { + t.Fatalf("flat parent query = %#v, want nil children", flat) + } + + loaded, err := repo.DictionaryDetailsByParent(ctx, dictionary.ID, &parentID, true) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 1 || loaded[0].Children == nil || len(loaded[0].Children) != 0 { + t.Fatalf("recursive parent query = %#v, want an empty loaded child slice", loaded) + } +} + +func TestDictionaryDetailListAppliesZeroLimit(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + repo := &dictionaryRepo{data: data} + + dictionary := &biz.Dictionary{Name: "limited", Type: "limited"} + if err := repo.CreateDictionary(ctx, dictionary); err != nil { + t.Fatal(err) + } + if err := repo.CreateDictionaryDetail(ctx, &biz.DictionaryDetail{Label: "one", Value: "one", DictionaryID: dictionary.ID}); err != nil { + t.Fatal(err) + } + + items, total, err := repo.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: dictionary.ID}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(items) != 0 { + t.Fatalf("zero-limit detail list: total=%d items=%#v", total, items) + } +} + +func TestUserListAppliesZeroLimitForNegativePageSize(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + if err := data.gormDB.WithContext(ctx).Create(&userPO{Username: "limited", Password: "hash", AuthorityID: 888, Enable: 1}).Error; err != nil { + t.Fatal(err) + } + + items, total, err := (&userRepo{data: data}).ListUsers(ctx, 1, -1, &biz.UserListFilter{}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(items) != 0 { + t.Fatalf("negative-size user list: total=%d items=%#v", total, items) + } +} + +func TestDepartmentTreeAndFlatResultsPreserveChildrenShape(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + repo := &departmentRepo{data: data} + + root := &biz.Department{Name: "root"} + if err := repo.CreateDepartment(ctx, root); err != nil { + t.Fatal(err) + } + var rootPO departmentPO + if err := data.gormDB.WithContext(ctx).Where("name = ?", "root").First(&rootPO).Error; err != nil { + t.Fatal(err) + } + if err := repo.CreateDepartment(ctx, &biz.Department{Name: "child", ParentID: rootPO.ID}); err != nil { + t.Fatal(err) + } + + tree, err := repo.ListDepartments(ctx, "") + if err != nil { + t.Fatal(err) + } + if len(tree) != 1 || len(tree[0].Children) != 1 || tree[0].Children[0].Children == nil || len(tree[0].Children[0].Children) != 0 { + t.Fatalf("department tree children = %#v, want every loaded node to expose a slice", tree) + } + + flat, err := repo.ListDepartments(ctx, "child") + if err != nil { + t.Fatal(err) + } + if len(flat) != 1 || flat[0].Children != nil { + t.Fatalf("flat department search children = %#v, want nil", flat) + } + + found, err := repo.FindDepartment(ctx, rootPO.ID) + if err != nil { + t.Fatal(err) + } + if found.Children != nil { + t.Fatalf("single department children = %#v, want nil", found.Children) + } +} diff --git a/internal/data/error_record.go b/internal/data/error_record.go index 9747989..a27f380 100644 --- a/internal/data/error_record.go +++ b/internal/data/error_record.go @@ -30,7 +30,7 @@ func errorFromPO(v errorRecordPO) *biz.ErrorRecord { } func (r *auditRecorderRepo) CreateError(ctx context.Context, v *biz.ErrorRecord) error { if !r.data.databaseReady.Load() { - // GVA silently ignores error records before GVA_DB is initialized. + // Silently ignore error records before the database is initialized. return nil } if v.Status == "" { diff --git a/internal/data/error_record_test.go b/internal/data/error_record_test.go new file mode 100644 index 0000000..045fa1a --- /dev/null +++ b/internal/data/error_record_test.go @@ -0,0 +1,140 @@ +package data + +import ( + "context" + "testing" + "time" + + "kra/internal/biz" +) + +func errorString(value string) *string { return &value } + +func newErrorRecordRepos(t *testing.T) (*auditRecorderRepo, *auditQueryRepo) { + t.Helper() + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&errorRecordPO{}); err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + data := &Data{gormDB: newReloadableDB(db, nil)} + data.databaseReady.Store(true) + return &auditRecorderRepo{data: data}, &auditQueryRepo{data: data} +} + +func TestCreateErrorBeforeDatabaseInitializationIsNoop(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + recorder := &auditRecorderRepo{data: &Data{gormDB: newReloadableDB(db, nil)}} + form := "后端" + if err = recorder.CreateError(context.Background(), &biz.ErrorRecord{Form: &form}); err != nil { + t.Fatalf("uninitialized error write = %v, want nil", err) + } +} + +func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) { + recorder, query := newErrorRecordRepos(t) + createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC) + form := "前端" + if err := recorder.CreateError(context.Background(), &biz.ErrorRecord{ + ID: 41, + CreatedAt: createdAt, + Form: &form, + Level: "error", + }); err != nil { + t.Fatal(err) + } + + got, err := query.FindError(context.Background(), 41) + if err != nil { + t.Fatal(err) + } + if got.Form == nil || *got.Form != form { + t.Fatalf("form = %#v, want %q", got.Form, form) + } + if got.Info != nil || got.Solution != nil { + t.Fatalf("nullable fields = (%#v, %#v), want nil", got.Info, got.Solution) + } + if got.Status != "未处理" { + t.Fatalf("status = %q, want 未处理", got.Status) + } + if !got.CreatedAt.Equal(createdAt) { + t.Fatalf("created_at = %v, want %v", got.CreatedAt, createdAt) + } +} + +func TestUpdateErrorDistinguishesOmittedAndExplicitEmptyFields(t *testing.T) { + recorder, query := newErrorRecordRepos(t) + form, info, solution := "后端", "错误内容", "解决方案" + if err := recorder.CreateError(context.Background(), &biz.ErrorRecord{ + Form: &form, + Info: &info, + Solution: &solution, + Level: "error", + RequestID: "request-old", + TraceID: "trace-old", + Status: "处理中", + }); err != nil { + t.Fatal(err) + } + + items, _, err := query.ListErrors(context.Background(), 1, 10, nil) + if err != nil || len(items) != 1 { + t.Fatalf("list before update = (%d, %v)", len(items), err) + } + id := items[0].ID + newForm := "服务端" + if err = query.UpdateError(context.Background(), &biz.ErrorRecord{ID: id, Form: &newForm}); err != nil { + t.Fatal(err) + } + got, err := query.FindError(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if got.Info == nil || *got.Info != info || got.Solution == nil || *got.Solution != solution { + t.Fatalf("omitted fields changed: info=%#v solution=%#v", got.Info, got.Solution) + } + + changedCreatedAt := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + if err = query.UpdateError(context.Background(), &biz.ErrorRecord{ + ID: id, + CreatedAt: changedCreatedAt, + Form: errorString(""), + Info: errorString(""), + Solution: errorString(""), + RequestID: "request-new", + TraceID: "trace-new", + }); err != nil { + t.Fatal(err) + } + got, err = query.FindError(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if got.Form == nil || *got.Form != "" || got.Info == nil || *got.Info != "" || got.Solution == nil || *got.Solution != "" { + t.Fatalf("explicit empty fields were not persisted: form=%#v info=%#v solution=%#v", got.Form, got.Info, got.Solution) + } + if got.Level != "error" || got.Status != "处理中" { + t.Fatalf("zero-value fields changed: level=%q status=%q", got.Level, got.Status) + } + if got.RequestID != "request-new" || got.TraceID != "trace-new" { + t.Fatalf("trace fields = (%q, %q), want updated values", got.RequestID, got.TraceID) + } + if !got.CreatedAt.Equal(changedCreatedAt) { + t.Fatalf("created_at = %v, want %v", got.CreatedAt, changedCreatedAt) + } +} diff --git a/internal/data/export.go b/internal/data/export.go index 1ab6a5d..aba522a 100644 --- a/internal/data/export.go +++ b/internal/data/export.go @@ -62,9 +62,15 @@ type exportRepo struct{ data *Data } 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} + if conditions != nil { + v.Conditions = make([]biz.ExportCondition, 0, len(conditions)) + } for _, x := range conditions { 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}) } + if joins != nil { + v.Joins = make([]biz.ExportJoin, 0, len(joins)) + } for _, x := range joins { 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}) } @@ -73,12 +79,14 @@ func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []e 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, resetIDs bool) error { - if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil { - return err - } - if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportJoinPO{}).Error; err != nil { - return err +func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs, replace, forceTemplateID bool) error { + if replace { + if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil { + return err + } + if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportJoinPO{}).Error; err != nil { + return err + } } conditions := make([]exportConditionPO, 0, len(v.Conditions)) for _, x := range v.Conditions { @@ -86,7 +94,16 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs if resetIDs { id = 0 } - conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator}) + templateID := x.TemplateID + // GORM's has-many association on Create always writes the parent's + // TemplateID into the child foreign key (references:TemplateID). + // Preserve that observable behavior for creates; updates intentionally + // keep the request's value after the compatible update deletes and + // recreates relations. + if forceTemplateID || 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 { create := tx @@ -105,7 +122,11 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs if resetIDs { id = 0 } - joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, Join: x.Join, Table: x.Table, On: x.On}) + templateID := x.TemplateID + if forceTemplateID || 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 { create := tx @@ -123,7 +144,7 @@ func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *biz.ExportTemp return err } v.ID = po.ID - return r.saveRelations(tx, v, false) + return r.saveRelations(tx, v, false, false, true) }) } func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemplate) error { @@ -132,7 +153,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, true) + return r.saveRelations(tx, v, true, true, false) }) } func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error { @@ -142,10 +163,10 @@ func (r *exportRepo) FindExportTemplate(ctx context.Context, id uint, tid string var po exportTemplatePO db := r.data.gormDB.WithContext(ctx) var err error - if id != 0 { - err = db.First(&po, id).Error - } else { + if tid != "" { err = db.Where("template_id = ?", tid).First(&po).Error + } else { + err = db.Where("id = ?", id).First(&po).Error } if err != nil { return nil, err @@ -181,7 +202,12 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q return nil, 0, err } var pos []exportTemplatePO - if err := applyPagination(db, page, size, 0).Find(&pos).Error; err != nil { + // This module applies pageSize and its raw offset formula verbatim instead + // of using the shared PageInfo normalization. + if size != 0 { + db = db.Limit(size).Offset(size * (page - 1)) + } + if err := db.Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.ExportTemplate, 0, len(pos)) diff --git a/internal/data/login_log.go b/internal/data/login_log.go index c316663..8e17d38 100644 --- a/internal/data/login_log.go +++ b/internal/data/login_log.go @@ -25,7 +25,7 @@ func (loginLogPO) TableName() string { return "sys_login_logs" } func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *biz.LoginLog) error { if !r.data.databaseReady.Load() { // The login endpoint remains reachable before database initialization; - // GVA skips this audit write while GVA_DB is nil. + // skip the audit write until storage is ready. return nil } 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 @@ -48,7 +48,7 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *biz. return nil, 0, err } var pos []loginLogPO - if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } ids := make([]uint, 0, len(pos)) diff --git a/internal/data/media.go b/internal/data/media.go index db451f6..107c4ba 100644 --- a/internal/data/media.go +++ b/internal/data/media.go @@ -73,12 +73,6 @@ func (r *mediaRepo) FindMediaByHash(ctx context.Context, userID uint, hash strin return mediaFromPO(po), nil } func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*biz.MediaFile, int64, error) { - if filter.Page < 1 { - filter.Page = 1 - } - if filter.PageSize < 1 { - filter.PageSize = 10 - } db := r.data.gormDB.WithContext(ctx).Model(&mediaPO{}) if filter.Keyword != "" { db = db.Where("name LIKE ?", "%"+filter.Keyword+"%") @@ -110,7 +104,19 @@ func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*b if filter.Desc { orderKey += " desc" } - if err := applyPagination(db.Order(orderKey), filter.Page, filter.PageSize, 100).Find(&pos).Error; err != nil { + limit := filter.PageSize + if limit > 100 { + limit = 100 + } + offset := 0 + if limit > 0 { + page := filter.Page + if page <= 0 { + page = 1 + } + offset = limit * (page - 1) + } + if err := db.Limit(limit).Offset(offset).Order(orderKey).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.MediaFile, 0, len(pos)) @@ -141,9 +147,6 @@ func (r *mediaRepo) CreateMediaBatch(ctx context.Context, items []*biz.MediaFile for _, v := range items { pos = append(pos, mediaToPO(v)) } - if len(pos) == 0 { - return nil - } return r.data.gormDB.WithContext(ctx).Create(&pos).Error } func (r *mediaRepo) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) error { @@ -178,7 +181,7 @@ func (r *mediaRepo) DeleteCategory(ctx context.Context, id uint) error { } func (r *mediaRepo) ListCategories(ctx context.Context) ([]*biz.AttachmentCategory, error) { var pos []categoryPO - if err := r.data.gormDB.WithContext(ctx).Order("id").Find(&pos).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil { return nil, err } byID := map[uint]*biz.AttachmentCategory{} diff --git a/internal/data/media_test.go b/internal/data/media_test.go new file mode 100644 index 0000000..93ac6df --- /dev/null +++ b/internal/data/media_test.go @@ -0,0 +1,33 @@ +package data + +import ( + "context" + "testing" + + "kra/internal/biz" +) + +func TestMediaListKeepsZeroPageSizeContract(t *testing.T) { + data := newTransactionTestData(t) + repo := &mediaRepo{data: data} + ctx := context.Background() + if err := repo.CreateMedia(ctx, &biz.MediaFile{Name: "one.txt"}); err != nil { + t.Fatal(err) + } + + items, total, err := repo.ListMedia(ctx, biz.MediaFilter{}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(items) != 0 { + t.Fatalf("zero-size media page = %d items with total %d, want 0/1", len(items), total) + } +} + +func TestMediaBatchImportRejectsEmptySlice(t *testing.T) { + data := newTransactionTestData(t) + err := (&mediaRepo{data: data}).CreateMediaBatch(context.Background(), nil) + if err == nil { + t.Fatal("empty media import unexpectedly succeeded") + } +} diff --git a/internal/data/menu.go b/internal/data/menu.go index 3b97636..9312c12 100644 --- a/internal/data/menu.go +++ b/internal/data/menu.go @@ -24,35 +24,35 @@ func menuFromPO(po menuPO) *biz.Menu { } func menuToPO(v *biz.Menu) menuPO { - return menuPO{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType} + return menuPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType} } func replaceMenuRelations(tx *gorm.DB, menu *biz.Menu) error { - // 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 err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuParameterPO{}).Error; err != nil { return err } - buttons := make([]menuButtonPO, 0, len(menu.Buttons)) - for _, button := range menu.Buttons { - buttons = append(buttons, menuButtonPO{Name: button.Name, Description: button.Description, MenuID: menu.ID}) + if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuButtonPO{}).Error; err != nil { + return err } - if len(buttons) > 0 { - if err := tx.Create(&buttons).Error; err != nil { + return createMenuRelations(tx, menu) +} + +func createMenuRelations(tx *gorm.DB, menu *biz.Menu) error { + parameters := make([]menuParameterPO, 0, len(menu.Parameters)) + for _, parameter := range menu.Parameters { + parameters = append(parameters, menuParameterPO{ID: parameter.ID, CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + } + if len(parameters) > 0 { + if err := tx.Create(¶meters).Error; err != nil { return err } } - parameters := make([]menuParameterPO, 0, len(menu.Parameters)) - for _, parameter := range menu.Parameters { - parameters = append(parameters, menuParameterPO{MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + buttons := make([]menuButtonPO, 0, len(menu.Buttons)) + for _, button := range menu.Buttons { + buttons = append(buttons, menuButtonPO{ID: button.ID, CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, Name: button.Name, Description: button.Description, MenuID: menu.ID}) } - if len(parameters) > 0 { - return tx.Create(¶meters).Error + if len(buttons) > 0 { + return tx.Create(&buttons).Error } return nil } @@ -94,7 +94,7 @@ func (r *menuRepo) Create(ctx context.Context, v *biz.Menu) error { return err } v.ID = po.ID - return replaceMenuRelations(tx, v) + return createMenuRelations(tx, v) }) } @@ -140,29 +140,25 @@ func (r *menuRepo) Delete(ctx context.Context, id uint) error { if count > 0 { return errors.New("此菜单有角色正在作为首页,不可删除") } - var buttonIDs []uint - if err := tx.Model(&menuButtonPO{}).Where("sys_base_menu_id = ?", id).Pluck("id", &buttonIDs).Error; err != nil { - return err - } - if err := tx.Where("sys_menu_id = ?", id).Delete(&authorityButtonPO{}).Error; err != nil { - return err - } - if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuButtonPO{}).Error; err != nil { + if err := tx.Delete(&menuPO{}, id).Error; err != nil { return err } if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuParameterPO{}).Error; err != nil { return err } - if err := tx.Where("sys_base_menu_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { + if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuButtonPO{}).Error; err != nil { return err } - return tx.Delete(&menuPO{}, id).Error + if err := tx.Where("sys_menu_id = ?", id).Delete(&authorityButtonPO{}).Error; err != nil { + return err + } + return tx.Where("sys_base_menu_id = ?", id).Delete(&authorityMenuPO{}).Error }) } func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error { var parameters []menuParameterPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Order("id").Find(¶meters).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(¶meters).Error; err != nil { return err } menu.Parameters = make([]*biz.MenuParameter, 0, len(parameters)) @@ -170,7 +166,7 @@ func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error { 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 { + if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(&buttons).Error; err != nil { return err } menu.Buttons = make([]*biz.MenuButton, 0, len(buttons)) @@ -194,7 +190,7 @@ func (r *menuRepo) Find(ctx context.Context, id uint) (*biz.Menu, error) { func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { var pos []menuPO - db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).Order("sort,id") + db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).Order("sort") config := r.data.runtime.Admin() if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { var authority authorityPO @@ -224,7 +220,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { menu.Buttons = []*biz.MenuButton{} } var parameters []menuParameterPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Order("id").Find(¶meters).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(¶meters).Error; err != nil { return nil, err } for _, parameter := range parameters { @@ -232,7 +228,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { 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 { + if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(&buttons).Error; err != nil { return nil, err } for _, button := range buttons { @@ -264,6 +260,9 @@ func (r *menuRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) e if err := (&authorityAccessRepo{data: r.data}).checkAuthorityIDAuth(ctx, id); err != nil { return err } + if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", id).First(&authorityPO{}).Error; err != nil { + return err + } config := r.data.runtime.Admin() if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { var authority authorityPO @@ -307,6 +306,29 @@ func (r *menuRepo) AuthorityMenuIDs(ctx context.Context, id uint) ([]uint, error return ids, err } +// ListAuthorityMenus reads the authority-menu query. It reads the +// target authority's relation rows first and then fetches only those base +// menus. In particular, it deliberately does not call List: List applies the +// current request actor's strict-auth filter, which would incorrectly hide +// menus when an administrator inspects a different (child) authority. +func (r *menuRepo) ListAuthorityMenus(ctx context.Context, authorityID uint) ([]*biz.Menu, error) { + var pos []menuPO + db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}). + Where("id IN (?)", r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}). + Select("sys_base_menu_id").Where("sys_authority_authority_id = ?", authorityID)). + Order("sort") + if err := db.Find(&pos).Error; err != nil { + return nil, err + } + out := make([]*biz.Menu, 0, len(pos)) + for _, po := range pos { + // This endpoint does not preload Parameters or MenuBtn. Keep + // both relations nil so the response shape remains compatible. + out = append(out, menuFromPO(po)) + } + return out, nil +} + func (r *menuRepo) MenuRoleIDs(ctx context.Context, id uint) ([]uint, error) { var ids []uint err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_base_menu_id = ?", id).Pluck("sys_authority_authority_id", &ids).Error diff --git a/internal/data/menu_test.go b/internal/data/menu_test.go new file mode 100644 index 0000000..86d966f --- /dev/null +++ b/internal/data/menu_test.go @@ -0,0 +1,141 @@ +package data + +import ( + "context" + "testing" + "time" + + "kra/internal/biz" + "kra/internal/conf" +) + +func newMenuTestData(t *testing.T) *Data { + t.Helper() + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&menuPO{}, &menuParameterPO{}, &menuButtonPO{}, &authorityButtonPO{}, &authorityPO{}, &authorityMenuPO{}); err != nil { + t.Fatal(err) + } + data := &Data{ + gormDB: newReloadableDB(db, nil), + runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{}), + } + t.Cleanup(func() { data.gormDB.close() }) + return data +} + +func TestMenuCreatePreservesSubmittedTimestamps(t *testing.T) { + data := newMenuTestData(t) + ctx := context.Background() + createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC) + updatedAt := time.Date(2025, time.February, 3, 4, 5, 6, 0, time.UTC) + menu := &biz.Menu{CreatedAt: createdAt, UpdatedAt: updatedAt, Name: "created", Path: "created", Component: "view/created.vue", Title: "Created"} + + if err := (&menuRepo{data: data}).Create(ctx, menu); err != nil { + t.Fatal(err) + } + var stored menuPO + if err := data.gormDB.WithContext(ctx).First(&stored, menu.ID).Error; err != nil { + t.Fatal(err) + } + if !stored.CreatedAt.Equal(createdAt) || !stored.UpdatedAt.Equal(updatedAt) { + t.Fatalf("stored timestamps = (%v, %v), want (%v, %v)", stored.CreatedAt, stored.UpdatedAt, createdAt, updatedAt) + } +} + +func TestSetAuthorityMenusRejectsMissingAuthority(t *testing.T) { + data := newMenuTestData(t) + err := (&menuRepo{data: data}).SetAuthorityMenus(context.Background(), 999, nil) + if err == nil { + t.Fatal("SetAuthorityMenus missing authority succeeded") + } +} + +func TestSetMenuRolesPreservesDuplicateAuthorityIDs(t *testing.T) { + data := newMenuTestData(t) + ctx := context.Background() + if err := (&menuRepo{data: data}).SetMenuRoles(ctx, 10, []uint{888, 888}); err != nil { + t.Fatal(err) + } + var count int64 + if err := data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}). + Where("sys_base_menu_id = ? AND sys_authority_authority_id = ?", 10, 888). + Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("authority-menu rows = %d, want duplicate role IDs preserved", count) + } +} + +func TestMenuUpdateRebuildsRelationsWithoutChangingRequestedIdentity(t *testing.T) { + data := newMenuTestData(t) + ctx := context.Background() + createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC) + updatedAt := time.Date(2025, time.April, 5, 6, 7, 8, 0, time.UTC) + + if err := data.gormDB.WithContext(ctx).Create(&menuPO{ID: 10, Name: "before", Path: "before", Component: "view/before.vue", Title: "Before"}).Error; err != nil { + t.Fatal(err) + } + if err := data.gormDB.WithContext(ctx).Create(&menuParameterPO{ID: 21, CreatedAt: createdAt, UpdatedAt: updatedAt, MenuID: 10, Type: "query", Key: "old", Value: "old"}).Error; err != nil { + t.Fatal(err) + } + if err := data.gormDB.WithContext(ctx).Create(&menuButtonPO{ID: 31, CreatedAt: createdAt, UpdatedAt: updatedAt, MenuID: 10, Name: "edit", Description: "old"}).Error; err != nil { + t.Fatal(err) + } + if err := data.gormDB.WithContext(ctx).Create(&authorityButtonPO{AuthorityID: 888, MenuID: 10, ButtonID: 31}).Error; err != nil { + t.Fatal(err) + } + + repo := &menuRepo{data: data} + err := repo.Update(ctx, &biz.Menu{ + ID: 10, + Name: "after", + Path: "after", + Component: "view/after.vue", + Title: "After", + Parameters: []*biz.MenuParameter{{ + ID: 21, CreatedAt: createdAt, UpdatedAt: updatedAt, Type: "params", Key: "new", Value: "value", + }}, + Buttons: []*biz.MenuButton{{ + ID: 31, CreatedAt: createdAt, UpdatedAt: updatedAt, Name: "edit", Description: "new", + }}, + }) + if err != nil { + t.Fatal(err) + } + + var parameter menuParameterPO + if err = data.gormDB.WithContext(ctx).First(¶meter, 21).Error; err != nil { + t.Fatal(err) + } + if parameter.MenuID != 10 || parameter.Type != "params" || parameter.Key != "new" || parameter.Value != "value" { + t.Fatalf("parameter = %+v", parameter) + } + if !parameter.CreatedAt.Equal(createdAt) || !parameter.UpdatedAt.Equal(updatedAt) { + t.Fatalf("parameter timestamps = (%v, %v), want (%v, %v)", parameter.CreatedAt, parameter.UpdatedAt, createdAt, updatedAt) + } + + var button menuButtonPO + if err = data.gormDB.WithContext(ctx).First(&button, 31).Error; err != nil { + t.Fatal(err) + } + if button.MenuID != 10 || button.Name != "edit" || button.Description != "new" { + t.Fatalf("button = %+v", button) + } + if !button.CreatedAt.Equal(createdAt) || !button.UpdatedAt.Equal(updatedAt) { + t.Fatalf("button timestamps = (%v, %v), want (%v, %v)", button.CreatedAt, button.UpdatedAt, createdAt, updatedAt) + } + + var links int64 + if err = data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}). + Where("authority_id = ? AND sys_menu_id = ? AND sys_base_menu_btn_id = ?", 888, 10, 31). + Count(&links).Error; err != nil { + t.Fatal(err) + } + if links != 1 { + t.Fatalf("authority-button links = %d, want 1", links) + } +} diff --git a/internal/data/migrations.go b/internal/data/migrations.go index d0fbbd9..3395d39 100644 --- a/internal/data/migrations.go +++ b/internal/data/migrations.go @@ -2,6 +2,7 @@ package data import ( "fmt" + "strings" "time" "gorm.io/gorm" @@ -11,6 +12,9 @@ func migrateAll(db *gorm.DB) error { if err := migrateLegacyIgnoreAPITable(db); err != nil { return err } + if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil { + return err + } if err := db.AutoMigrate( &userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{}, &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{}, @@ -35,6 +39,134 @@ func migrateAll(db *gorm.DB) error { return reconcileReferenceIndexes(db) } +// migrateLegacyAuthorityDepartmentColumns preserves data created by early +// Kra builds, which used shortened join-column names and a composite primary +// key. The administration connection model has neither a primary key nor a +// uniqueness constraint, so rebuild the small table before AutoMigrate. +func migrateLegacyAuthorityDepartmentColumns(db *gorm.DB) error { + clean := db.Session(&gorm.Session{NewDB: true}) + const ( + table = "sys_authority_departments" + backup = "sys_authority_departments_kra_legacy" + ) + // MySQL and Oracle auto-commit DDL. If a prior process stopped between the + // rename and cleanup steps, restore the untouched backup first and retry the + // migration from a known state. + if clean.Migrator().HasTable(backup) { + if clean.Migrator().HasTable(table) { + if err := clean.Migrator().DropTable(table); err != nil { + return fmt.Errorf("remove incomplete authority-department table: %w", err) + } + } + if err := clean.Migrator().RenameTable(backup, table); err != nil { + return fmt.Errorf("restore authority-department backup: %w", err) + } + } + if !clean.Migrator().HasTable(table) { + return nil + } + + authorityColumn := "sys_authority_authority_id" + if !tableHasColumn(clean, table, authorityColumn) { + if !tableHasColumn(clean, table, "authority_id") { + return fmt.Errorf("authority-department table has no authority column") + } + authorityColumn = "authority_id" + } + departmentColumn := "sys_department_id" + if !tableHasColumn(clean, table, departmentColumn) { + if !tableHasColumn(clean, table, "department_id") { + return fmt.Errorf("authority-department table has no department column") + } + departmentColumn = "department_id" + } + hasPrimaryKey, err := tableHasPrimaryKey(clean, table) + if err != nil { + return err + } + if authorityColumn == "sys_authority_authority_id" && departmentColumn == "sys_department_id" && !hasPrimaryKey { + return nil + } + + type relation struct { + AuthorityID uint `gorm:"column:authority_id"` + DepartmentID uint `gorm:"column:department_id"` + } + var rows []relation + selectColumns := authorityColumn + " AS authority_id, " + departmentColumn + " AS department_id" + if err := clean.Table(table).Select(selectColumns).Scan(&rows).Error; err != nil { + return fmt.Errorf("read legacy authority-department rows: %w", err) + } + + rebuild := func(tx *gorm.DB) error { + if err := tx.Migrator().RenameTable(table, backup); err != nil { + return fmt.Errorf("rename legacy authority-department table: %w", err) + } + if err := tx.AutoMigrate(&authorityDepartmentPO{}); err != nil { + return fmt.Errorf("create authority-department table: %w", err) + } + if len(rows) > 0 { + items := make([]authorityDepartmentPO, 0, len(rows)) + for _, row := range rows { + items = append(items, authorityDepartmentPO{AuthorityID: row.AuthorityID, DepartmentID: row.DepartmentID}) + } + if err := tx.Create(&items).Error; err != nil { + return fmt.Errorf("copy authority-department rows: %w", err) + } + } + var count int64 + if err := tx.Model(&authorityDepartmentPO{}).Count(&count).Error; err != nil { + return fmt.Errorf("verify authority-department rows: %w", err) + } + if count != int64(len(rows)) { + return fmt.Errorf("verify authority-department rows: got %d want %d", count, len(rows)) + } + if err := tx.Migrator().DropTable(backup); err != nil { + return fmt.Errorf("drop legacy authority-department table: %w", err) + } + return nil + } + + switch clean.Dialector.Name() { + case "mysql", "oracle": + if err := rebuild(clean); err != nil { + restoreErr := restoreAuthorityDepartmentBackup(clean, table, backup) + if restoreErr != nil { + return fmt.Errorf("%v; restore authority-department backup: %w", err, restoreErr) + } + return err + } + return nil + default: + return clean.Transaction(rebuild) + } +} + +func restoreAuthorityDepartmentBackup(db *gorm.DB, table, backup string) error { + if db.Migrator().HasTable(table) { + if err := db.Migrator().DropTable(table); err != nil { + return err + } + } + if db.Migrator().HasTable(backup) { + return db.Migrator().RenameTable(backup, table) + } + return nil +} + +func tableHasPrimaryKey(db *gorm.DB, table string) (bool, error) { + columns, err := db.Migrator().ColumnTypes(table) + if err != nil { + return false, err + } + for _, column := range columns { + if primary, ok := column.PrimaryKey(); ok && primary { + return true, nil + } + } + return false, nil +} + // Older builds used a status label outside the administration page's supported // state set, so normalize existing rows during migration. func normalizeErrorRecordStatuses(db *gorm.DB) error { @@ -55,18 +187,32 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error { Path string Method string } - query := clean.Table("sys_authority_apis sa"). - Select("sa.authority_id, a.path, a.method"). - Joins("JOIN sys_apis a ON a.id = sa.api_id") + baseQuery := func() *gorm.DB { + return clean.Table("sys_authority_apis sa"). + Select("sa.authority_id, a.path, a.method"). + Joins("JOIN sys_apis a ON a.id = sa.api_id") + } + query := baseQuery() // Early Kra schemas stored sys_apis without soft-delete timestamps. The // legacy-policy migration must run before assuming that column exists; // otherwise an upgrade from those schemas cannot start on MySQL. - if clean.Migrator().HasColumn(&apiPO{}, "deleted_at") { + if tableHasColumn(clean, "sys_apis", "deleted_at") { query = query.Where("a.deleted_at IS NULL") } var rows []legacyPolicy if err := query.Find(&rows).Error; err != nil { - return err + // A few MySQL-compatible drivers report stale/incomplete metadata from + // INFORMATION_SCHEMA during startup. If the optional soft-delete column + // was reported present but the join still rejects it, retry using only + // columns shared by every legacy schema. This migration must never block + // startup of an older database solely because deleted_at is absent. + if strings.Contains(strings.ToLower(err.Error()), "unknown column") && strings.Contains(strings.ToLower(err.Error()), "deleted_at") { + if retryErr := baseQuery().Find(&rows).Error; retryErr != nil { + return retryErr + } + } else { + return err + } } for _, row := range rows { exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method) @@ -83,6 +229,23 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error { return nil } +// tableHasColumn deliberately inspects the physical table rather than the +// model schema. Legacy databases may predate soft-delete columns even though +// the current PO includes gorm.DeletedAt. Metadata inspection failures are +// treated as "unknown" so callers use the portable query shape. +func tableHasColumn(db *gorm.DB, table, column string) bool { + columns, err := db.Migrator().ColumnTypes(table) + if err != nil { + return false + } + for _, item := range columns { + if strings.EqualFold(item.Name(), column) { + return true + } + } + return false +} + // 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 diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 807173b..eaa1bd1 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -98,3 +98,60 @@ func TestReconcileRootAuthorityAPIsUsesIndependentQueries(t *testing.T) { t.Fatalf("root rules = %#v, want only GET /allowed", rules) } } + +func TestMigrateLegacyAuthorityDepartmentColumns(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + + for _, statement := range []string{ + `CREATE TABLE sys_authority_departments (authority_id integer, department_id integer, PRIMARY KEY (authority_id, department_id))`, + `INSERT INTO sys_authority_departments (authority_id, department_id) VALUES (888, 7)`, + } { + if err = db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + if err = migrateLegacyAuthorityDepartmentColumns(db); err != nil { + t.Fatalf("migrate legacy authority-department columns: %v", err) + } + if err = db.AutoMigrate(&authorityDepartmentPO{}); err != nil { + t.Fatal(err) + } + + var relation authorityDepartmentPO + if err = db.First(&relation).Error; err != nil { + t.Fatal(err) + } + if relation.AuthorityID != 888 || relation.DepartmentID != 7 { + t.Fatalf("migrated relation = %#v", relation) + } + for _, column := range []string{"sys_authority_authority_id", "sys_department_id"} { + if !tableHasColumn(db, "sys_authority_departments", column) { + t.Fatalf("missing compatible column %s", column) + } + } + hasPrimaryKey, err := tableHasPrimaryKey(db, "sys_authority_departments") + if err != nil { + t.Fatal(err) + } + if hasPrimaryKey { + t.Fatal("authority-department table retained the legacy primary key") + } + if err = db.Create(&authorityDepartmentPO{AuthorityID: 888, DepartmentID: 7}).Error; err != nil { + t.Fatalf("duplicate administration relation was rejected: %v", err) + } + var count int64 + if err = db.Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ? AND sys_department_id = ?", 888, 7).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("duplicate relation count = %d, want 2", count) + } +} diff --git a/internal/data/operation_log.go b/internal/data/operation_log.go index 7f51890..a5a4ee2 100644 --- a/internal/data/operation_log.go +++ b/internal/data/operation_log.go @@ -53,7 +53,7 @@ func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q * return nil, 0, err } var pos []operationPO - if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } users := auditUsers(ctx, r.data.gormDB.WithContext(ctx), operationUserIDs(pos)) diff --git a/internal/data/organization_test.go b/internal/data/organization_test.go new file mode 100644 index 0000000..9215b45 --- /dev/null +++ b/internal/data/organization_test.go @@ -0,0 +1,51 @@ +package data + +import ( + "context" + "testing" +) + +func TestDepartmentTreeAndSearchPreserveCollectionShapes(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + root := departmentPO{Name: "root", ParentID: 0, Ancestors: "0"} + if err := data.gormDB.WithContext(ctx).Create(&root).Error; err != nil { + t.Fatal(err) + } + child := departmentPO{Name: "child", ParentID: root.ID, Ancestors: "0"} + if err := data.gormDB.WithContext(ctx).Create(&child).Error; err != nil { + t.Fatal(err) + } + repo := &departmentRepo{data: data} + + tree, err := repo.ListDepartments(ctx, "") + if err != nil { + t.Fatal(err) + } + if len(tree) != 1 || len(tree[0].Children) != 1 || tree[0].Children[0].Children == nil { + t.Fatalf("tree collection shape = %+v", tree) + } + + search, err := repo.ListDepartments(ctx, "child") + if err != nil { + t.Fatal(err) + } + if len(search) != 1 || search[0].Children != nil { + t.Fatalf("search collection shape = %+v", search) + } +} + +func TestPositionListKeepsZeroLimitBehavior(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + if err := data.gormDB.WithContext(ctx).Create(&positionPO{Name: "operator"}).Error; err != nil { + t.Fatal(err) + } + items, total, err := (&positionRepo{data: data}).ListPositions(ctx, 0, 0, nil) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(items) != 0 { + t.Fatalf("zero-limit result: total=%d items=%+v", total, items) + } +} diff --git a/internal/data/pagination.go b/internal/data/pagination.go index 40c8c71..2bd96af 100644 --- a/internal/data/pagination.go +++ b/internal/data/pagination.go @@ -2,15 +2,29 @@ package data import "gorm.io/gorm" -func applyPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB { - if size <= 0 { - return db - } +func paginationLimitOffset(page, size, maxSize int) (limit, offset int) { + limit = size if maxSize > 0 && size > maxSize { - size = maxSize + limit = maxSize + } + if limit <= 0 { + return 0, 0 } if page <= 0 { page = 1 } - return db.Offset((page - 1) * size).Limit(size) + return limit, (page - 1) * limit +} + +func applyPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB { + limit, offset := paginationLimitOffset(page, size, maxSize) + if limit == 0 { + return db + } + return db.Offset(offset).Limit(limit) +} + +func applyRequiredPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB { + limit, offset := paginationLimitOffset(page, size, maxSize) + return db.Offset(offset).Limit(limit) } diff --git a/internal/data/parameter.go b/internal/data/parameter.go index bdcd350..b88cc40 100644 --- a/internal/data/parameter.go +++ b/internal/data/parameter.go @@ -28,31 +28,34 @@ type parameterPO struct { func (parameterPO) TableName() string { return "sys_params" } func (r *parameterRepo) CreateParameter(ctx context.Context, v *biz.SystemParameter) error { - po := parameterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc} + po := parameterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc} if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { return err } - v.ID = po.ID + v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt return nil } func (r *parameterRepo) UpdateParameter(ctx context.Context, v *biz.SystemParameter) 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 + return r.data.gormDB.WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(¶meterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, 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 -} -func (r *parameterRepo) FindParameter(ctx context.Context, id uint, key string) (*biz.SystemParameter, error) { - var po parameterPO - db := r.data.gormDB.WithContext(ctx) - var err error - if id != 0 { - err = db.First(&po, id).Error - } else { - err = db.Where(map[string]any{"key": key}).First(&po).Error +func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []string) error { + if len(ids) == 1 { + return r.data.gormDB.WithContext(ctx).Delete(¶meterPO{}, "id = ?", ids[0]).Error } - if err != nil { + return r.data.gormDB.WithContext(ctx).Delete(&[]parameterPO{}, "id in ?", ids).Error +} +func (r *parameterRepo) FindParameterByID(ctx context.Context, id string) (*biz.SystemParameter, error) { + var po parameterPO + if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { + return nil, err + } + return parameterFromPO(po), nil +} +func (r *parameterRepo) FindParameterByKey(ctx context.Context, key string) (*biz.SystemParameter, error) { + var po parameterPO + if err := r.data.gormDB.WithContext(ctx).Where(parameterPO{Key: key}).First(&po).Error; err != nil { return nil, err } return parameterFromPO(po), nil diff --git a/internal/data/parameter_test.go b/internal/data/parameter_test.go new file mode 100644 index 0000000..0a0af27 --- /dev/null +++ b/internal/data/parameter_test.go @@ -0,0 +1,68 @@ +package data + +import ( + "context" + "testing" + "time" + + "kra/internal/biz" +) + +func TestParameterRepositoryPreservesModelMetadata(t *testing.T) { + data := newTransactionTestData(t) + repo := ¶meterRepo{data: data} + createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.Local) + value := &biz.SystemParameter{ID: 41, CreatedAt: createdAt, Name: "name", Key: "key", Value: "value"} + + if err := repo.CreateParameter(context.Background(), value); err != nil { + t.Fatal(err) + } + if value.ID != 41 || !value.CreatedAt.Equal(createdAt) { + t.Fatalf("created parameter metadata = ID %d, CreatedAt %v", value.ID, value.CreatedAt) + } + + nextCreatedAt := createdAt.Add(time.Hour) + value.CreatedAt = nextCreatedAt + value.Name = "updated" + if err := repo.UpdateParameter(context.Background(), value); err != nil { + t.Fatal(err) + } + var persisted parameterPO + if err := data.gormDB.WithContext(context.Background()).First(&persisted, 41).Error; err != nil { + t.Fatal(err) + } + if persisted.Name != "updated" || !persisted.CreatedAt.Equal(nextCreatedAt) { + t.Fatalf("updated parameter = %+v", persisted) + } +} + +func TestParameterRepositoryKeepsReferenceEmptyQuerySemantics(t *testing.T) { + data := newTransactionTestData(t) + repo := ¶meterRepo{data: data} + ctx := context.Background() + first := &biz.SystemParameter{Name: "first", Key: "first", Value: "1"} + if err := repo.CreateParameter(ctx, first); err != nil { + t.Fatal(err) + } + + byEmptyKey, err := repo.FindParameterByKey(ctx, "") + if err != nil { + t.Fatal(err) + } + if byEmptyKey.ID != first.ID { + t.Fatalf("empty key returned ID %d, want first row %d", byEmptyKey.ID, first.ID) + } + if _, err = repo.FindParameterByID(ctx, ""); err == nil { + t.Fatal("empty ID unexpectedly matched a parameter") + } + if err = repo.DeleteParameters(ctx, nil); err != nil { + t.Fatalf("empty bulk delete must be a successful no-op: %v", err) + } + var count int64 + if err = data.gormDB.WithContext(ctx).Model(¶meterPO{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("empty bulk delete removed rows: count=%d", count) + } +} diff --git a/internal/data/permission.go b/internal/data/permission.go index 7fc3504..cded5c8 100644 --- a/internal/data/permission.go +++ b/internal/data/permission.go @@ -2,6 +2,7 @@ package data import ( "context" + "errors" "time" "kra/internal/biz" @@ -26,9 +27,9 @@ type menuButtonPO struct { func (menuButtonPO) TableName() string { return "sys_base_menu_btns" } type authorityButtonPO struct { - AuthorityID uint `gorm:"primaryKey;column:authority_id"` - MenuID uint `gorm:"primaryKey;column:sys_menu_id"` - ButtonID uint `gorm:"primaryKey;column:sys_base_menu_btn_id"` + AuthorityID uint `gorm:"column:authority_id"` + MenuID uint `gorm:"column:sys_menu_id"` + ButtonID uint `gorm:"column:sys_base_menu_btn_id"` } func (authorityButtonPO) TableName() string { return "sys_authority_btns" } @@ -62,9 +63,15 @@ func (r *permissionRepo) SetAuthorityButtons(ctx context.Context, aid uint, butt }) } func (r *permissionRepo) SelectedButtons(ctx context.Context, aid, menuID uint) ([]uint, error) { + var rows []authorityButtonPO + if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Find(&rows).Error; err != nil { + return nil, err + } var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Pluck("sys_base_menu_btn_id", &ids).Error - return ids, err + for _, row := range rows { + ids = append(ids, row.ButtonID) + } + return ids, nil } func (r *permissionRepo) SetSelectedButtons(ctx context.Context, aid, menuID uint, ids []uint) error { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -81,10 +88,16 @@ func (r *permissionRepo) SetSelectedButtons(ctx context.Context, aid, menuID uin return nil }) } -func (r *permissionRepo) CanRemoveButton(ctx context.Context, id uint) (bool, error) { - var count int64 - err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("sys_base_menu_btn_id = ?", id).Count(&count).Error - return count == 0, err +func (r *permissionRepo) CanRemoveButton(ctx context.Context, id string) (bool, error) { + var row authorityButtonPO + err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_btn_id = ?", id).First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return true, nil + } + if err != nil { + return false, errors.New("此按钮正在被使用无法删除") + } + return false, nil } func (r *permissionRepo) AuthorityButtonIDs(ctx context.Context, aid uint) ([]uint, error) { var ids []uint diff --git a/internal/data/permission_test.go b/internal/data/permission_test.go new file mode 100644 index 0000000..5e8c91d --- /dev/null +++ b/internal/data/permission_test.go @@ -0,0 +1,83 @@ +package data + +import ( + "context" + "testing" + + "kra/internal/conf" +) + +func TestPermissionSelectedButtonsEmptyShape(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&authorityButtonPO{}); err != nil { + t.Fatal(err) + } + data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})} + t.Cleanup(func() { data.gormDB.close() }) + + ids, err := (&permissionRepo{data: data}).SelectedButtons(context.Background(), 1, 2) + if err != nil { + t.Fatal(err) + } + if ids != nil { + t.Fatalf("selected buttons = %#v, want nil for an empty compatible response", ids) + } +} + +func TestPermissionCanRemoveButtonUsesRawQueryID(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&authorityButtonPO{}); err != nil { + t.Fatal(err) + } + data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})} + t.Cleanup(func() { data.gormDB.close() }) + repo := &permissionRepo{data: data} + if err := data.gormDB.WithContext(context.Background()).Create(&authorityButtonPO{AuthorityID: 888, MenuID: 10, ButtonID: 31}).Error; err != nil { + t.Fatal(err) + } + + allowed, err := repo.CanRemoveButton(context.Background(), "31") + if err != nil { + t.Fatal(err) + } + if allowed { + t.Fatal("assigned button was reported removable") + } + allowed, err = repo.CanRemoveButton(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if !allowed { + t.Fatal("empty query ID should not match an assigned button") + } +} + +func TestPermissionSetSelectedButtonsPreservesDuplicates(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&authorityButtonPO{}); err != nil { + t.Fatal(err) + } + data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})} + t.Cleanup(func() { data.gormDB.close() }) + repo := &permissionRepo{data: data} + + if err := repo.SetSelectedButtons(context.Background(), 888, 10, []uint{31, 31}); err != nil { + t.Fatal(err) + } + selected, err := repo.SelectedButtons(context.Background(), 888, 10) + if err != nil { + t.Fatal(err) + } + if len(selected) != 2 || selected[0] != 31 || selected[1] != 31 { + t.Fatalf("selected = %#v, want duplicate button IDs preserved", selected) + } +} diff --git a/internal/data/position.go b/internal/data/position.go index 27a4922..970ede1 100644 --- a/internal/data/position.go +++ b/internal/data/position.go @@ -29,8 +29,10 @@ type positionPO struct { func (positionPO) TableName() string { return "sys_positions" } type userPositionPO struct { - UserID uint `gorm:"primaryKey;column:sys_user_id"` - PositionID uint `gorm:"primaryKey;column:sys_position_id"` + // GVA does not declare a key on this explicit join model. Do not let a + // database uniqueness constraint change duplicate-assignment behavior. + UserID uint `gorm:"column:sys_user_id"` + PositionID uint `gorm:"column:sys_position_id"` } func (userPositionPO) TableName() string { return "sys_user_positions" } @@ -82,7 +84,10 @@ func (r *positionRepo) ListPositions(ctx context.Context, page, size int, q *biz return nil, 0, err } var pos []positionPO - if err := applyPagination(db.Order("sort"), page, size, 0).Find(&pos).Error; err != nil { + // The compatible position endpoint applies the supplied LIMIT/OFFSET + // directly (it does not normalize missing page values through the shared + // pagination helper). + if err := db.Order("sort").Limit(size).Offset(size * (page - 1)).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.Position, 0, len(pos)) diff --git a/internal/data/runtime_settings.go b/internal/data/runtime_settings.go index 4930234..e7f7415 100644 --- a/internal/data/runtime_settings.go +++ b/internal/data/runtime_settings.go @@ -7,6 +7,8 @@ import ( "kra/internal/biz" "kra/internal/conf" "kra/pkg/adminauth" + + jwt "github.com/golang-jwt/jwt/v5" ) type runtimeSettings struct{ runtime *conf.Runtime } @@ -94,6 +96,34 @@ func (i *tokenIssuer) IssueToken(user *biz.User, authorityID uint, mustChangePas return &biz.IssuedToken{Value: token, ExpiresAt: claims.ExpiresAt.Time, TTL: expires}, nil } +func (i *tokenIssuer) ReissueToken(source *biz.AuthClaims, authorityID uint) (*biz.IssuedToken, error) { + if source == nil { + return nil, errors.New("nil JWT claims") + } + settings := i.settings.JWTSettings() + claims := &adminauth.Claims{ + UUID: source.UUID, + ID: source.ID, + Username: source.Username, + NickName: source.NickName, + AuthorityID: authorityID, + BufferTime: int64(source.BufferTime / time.Second), + UserType: source.UserType, + MustChangePwd: source.MustChangePwd, + RegisteredClaims: jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings(append([]string(nil), source.Audience...)), + Issuer: source.Issuer, + NotBefore: jwt.NewNumericDate(source.NotBefore), + ExpiresAt: jwt.NewNumericDate(source.ExpiresAt), + }, + } + token, err := adminauth.Sign(settings.SigningKey, claims) + if err != nil { + return nil, err + } + return &biz.IssuedToken{Value: token, ExpiresAt: source.ExpiresAt, TTL: time.Until(source.ExpiresAt)}, nil +} + func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) { claims, err := adminauth.Parse(token, i.settings.JWTSettings().SigningKey) if err != nil { @@ -110,5 +140,7 @@ func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) { return nil, biz.ErrTokenInvalid } } - return &biz.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, UserType: claims.UserType, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, Issuer: claims.Issuer, ExpiresAt: claims.ExpiresAt.Time}, nil + audience := make([]string, len(claims.Audience)) + copy(audience, claims.Audience) + return &biz.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, UserType: claims.UserType, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, Issuer: claims.Issuer, Audience: audience, NotBefore: claims.NotBefore.Time, ExpiresAt: claims.ExpiresAt.Time}, nil } diff --git a/internal/data/security.go b/internal/data/security.go index 7fd3af0..430a73b 100644 --- a/internal/data/security.go +++ b/internal/data/security.go @@ -19,25 +19,25 @@ type securityConfigPO struct { CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` - CaptchaOpen int - CaptchaTimeout int - KeyLong int - ImgWidth int - ImgHeight int - PwdMinLength int - PwdRequireUpper bool - PwdRequireLower bool - PwdRequireDigit bool - PwdRequireSpecial bool - LimitEnable bool - LimitWindow int - LimitCount int - LockEnable bool - LockThreshold int - LockDuration int - PwdExpireEnable bool - PwdExpireDays int - ForceNewUserChangePassword bool + CaptchaOpen int `gorm:"default:0"` + CaptchaTimeout int `gorm:"default:3600"` + KeyLong int `gorm:"default:6"` + ImgWidth int `gorm:"default:240"` + ImgHeight int `gorm:"default:80"` + PwdMinLength int `gorm:"default:8"` + PwdRequireUpper bool `gorm:"default:false"` + PwdRequireLower bool `gorm:"default:false"` + PwdRequireDigit bool `gorm:"default:false"` + PwdRequireSpecial bool `gorm:"default:false"` + LimitEnable bool `gorm:"default:false"` + LimitWindow int `gorm:"default:60"` + LimitCount int `gorm:"default:30"` + LockEnable bool `gorm:"default:false"` + LockThreshold int `gorm:"default:5"` + LockDuration int `gorm:"default:30"` + PwdExpireEnable bool `gorm:"default:false"` + PwdExpireDays int `gorm:"default:90"` + ForceNewUserChangePassword bool `gorm:"default:false"` } func (securityConfigPO) TableName() string { return "sys_security_config" } @@ -54,6 +54,7 @@ func securityToPO(v *biz.SecurityConfig) securityConfigPO { func (r *securityRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig, error) { if !r.data.databaseReady.Load() { po := defaultSecurityConfig() + po.ID = 0 return securityFromPO(po), errors.New("数据库未初始化") } var po securityConfigPO diff --git a/internal/data/system.go b/internal/data/system.go index 5bd4e9b..ede6306 100644 --- a/internal/data/system.go +++ b/internal/data/system.go @@ -67,15 +67,18 @@ type menuPO struct { func (menuPO) TableName() string { return "sys_base_menus" } type userAuthorityPO struct { - SysUserID uint `gorm:"primaryKey;column:sys_user_id"` - SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"` + // GVA's explicit join model deliberately has no primary key or uniqueness + // constraint. Keep duplicate IDs representable; association replacement + // and validation are handled by the service transaction instead. + SysUserID uint `gorm:"column:sys_user_id"` + SysAuthorityAuthorityID uint `gorm:"column:sys_authority_authority_id"` } func (userAuthorityPO) TableName() string { return "sys_user_authority" } type authorityMenuPO struct { - SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"` - SysBaseMenuID uint `gorm:"primaryKey;column:sys_base_menu_id"` + SysAuthorityAuthorityID uint `gorm:"column:sys_authority_authority_id"` + SysBaseMenuID uint `gorm:"column:sys_base_menu_id"` } func (authorityMenuPO) TableName() string { return "sys_authority_menus" } diff --git a/internal/data/system_init.go b/internal/data/system_init.go index c2ebe40..48e527a 100644 --- a/internal/data/system_init.go +++ b/internal/data/system_init.go @@ -232,10 +232,20 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database }); err != nil { return err } - if err := r.data.persistDatabaseConfig(config); err != nil { + signingKey := uuid.NewString() + if err := r.data.persistDatabaseConfig(config, signingKey); err != nil { return fmt.Errorf("persist database configuration: %w", err) } r.data.activateDatabase(candidate, config) + currentData, currentAdmin := r.data.runtime.Values() + if currentAdmin == nil { + currentAdmin = &conf.AdminBackend{} + } + if currentAdmin.Jwt == nil { + currentAdmin.Jwt = &conf.AdminBackend_JWT{} + } + currentAdmin.Jwt.SigningKey = signingKey + r.data.runtime.Replace(currentData, currentAdmin) activated = true return nil } diff --git a/internal/data/system_init_state_test.go b/internal/data/system_init_state_test.go index c319e72..31c3e56 100644 --- a/internal/data/system_init_state_test.go +++ b/internal/data/system_init_state_test.go @@ -2,9 +2,14 @@ package data import ( "context" + "os" + "path/filepath" + "strings" "testing" "kra/internal/conf" + + "gopkg.in/yaml.v3" ) func TestDatabaseConnectionConfigured(t *testing.T) { @@ -49,3 +54,58 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) { t.Fatal("an active configured database must not require initialization") } } + +func TestPersistDatabaseConfigPreservesTemplateAndRotatesJWTKey(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "config.yaml") + original := `server: + http: + addr: 0.0.0.0:8000 +data: + database: + driver: mysql + name: old + redis: + addr: 127.0.0.1:6379 +admin: + jwt: + signing_key: old-key + issuer: kra + zap: + director: logs +extension: + preserved: true +` + if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{ConfigPath: configPath}) + data := &Data{runtime: runtime} + if err := data.persistDatabaseConfig(&conf.Data_Database{Driver: "mysql", Host: "db", Port: "3306", User: "root", Password: "secret", Name: "kra", Config: "parseTime=True"}, "new-key"); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var value map[string]any + if err = yaml.Unmarshal(raw, &value); err != nil { + t.Fatal(err) + } + admin := value["admin"].(map[string]any) + jwt := admin["jwt"].(map[string]any) + if jwt["signing_key"] != "new-key" || jwt["issuer"] != "kra" { + t.Fatalf("unexpected jwt config: %#v", jwt) + } + dataConfig := value["data"].(map[string]any) + database := dataConfig["database"].(map[string]any) + if database["name"] != "kra" || database["password"] != "secret" { + t.Fatalf("unexpected database config: %#v", database) + } + if _, ok := dataConfig["redis"]; !ok { + t.Fatalf("redis config was removed: %#v", dataConfig) + } + if _, ok := value["extension"]; !ok || !strings.Contains(string(raw), "preserved: true") { + t.Fatalf("extension config was removed: %s", raw) + } +} diff --git a/internal/data/transactions_test.go b/internal/data/transactions_test.go index 7f0ea0a..849de81 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/transactions_test.go @@ -41,25 +41,110 @@ func TestUserAuthorityWritesAreAtomic(t *testing.T) { t.Fatalf("authority links = %d, err = %v", links, err) } - created.NickName = "must rollback" - if err = repo.UpdateUserWithAuthorities(ctx, created, []uint{888, 123456}); err == nil { - t.Fatal("expected invalid authority update to fail") + // With strict authority checks disabled, GVA writes the supplied join rows + // without first requiring every role record to exist. + created.NickName = "compatible update" + if err = repo.UpdateUserWithAuthorities(ctx, created, []uint{888, 123456}); err != nil { + t.Fatalf("missing authority ID was rejected in non-strict mode: %v", err) } var persisted userPO if err = data.gormDB.WithContext(ctx).First(&persisted, created.ID).Error; err != nil { t.Fatal(err) } - if persisted.NickName != "before" { - t.Fatalf("user update was not rolled back: %q", persisted.NickName) + if persisted.NickName != "compatible update" { + t.Fatalf("user update was not persisted: %q", persisted.NickName) + } + var missingAuthorityLinks int64 + if err = data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}). + Where("sys_user_id = ? AND sys_authority_authority_id = ?", created.ID, 123456). + Count(&missingAuthorityLinks).Error; err != nil || missingAuthorityLinks != 1 { + t.Fatalf("missing-authority link count = %d, err = %v", missingAuthorityLinks, err) } - invalid := &biz.User{Username: "invalid", Password: "hash", AuthorityID: 888, Enable: 1} - if _, err = repo.CreateUserWithAuthorities(ctx, invalid, []uint{888, 123456}); err == nil { - t.Fatal("expected invalid authority create to fail") + withNewAuthority := &biz.User{Username: "with-new-authority", Password: "hash", AuthorityID: 888, Enable: 1} + createdWithNewAuthority, err := repo.CreateUserWithAuthorities(ctx, withNewAuthority, []uint{888, 123456}) + if err != nil { + t.Fatal(err) } var users int64 - if err = data.gormDB.WithContext(ctx).Model(&userPO{}).Where("username = ?", "invalid").Count(&users).Error; err != nil || users != 0 { - t.Fatalf("partial user persisted: count=%d err=%v", users, err) + if err = data.gormDB.WithContext(ctx).Model(&userPO{}).Where("username = ?", "with-new-authority").Count(&users).Error; err != nil || users != 1 { + t.Fatalf("created user count=%d err=%v", users, err) + } + var generated authorityPO + if err = data.gormDB.WithContext(ctx).First(&generated, "authority_id = ?", 123456).Error; err != nil { + t.Fatal(err) + } + if generated.DataScope != 1 || generated.DefaultRouter != "dashboard" { + t.Fatalf("generated authority defaults = %+v", generated) + } + links = 0 + if err = data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_user_id = ?", createdWithNewAuthority.ID).Count(&links).Error; err != nil || links != 2 { + t.Fatalf("generated authority links = %d, err = %v", links, err) + } +} + +func TestUserLoadMatchesIndependentAuthorityAndDepartmentPreloads(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + department := departmentPO{Name: "primary-only"} + if err := data.gormDB.WithContext(ctx).Create(&department).Error; err != nil { + t.Fatal(err) + } + po := userPO{UUID: "00000000-0000-0000-0000-000000000001", Username: "orphaned-role", Password: "hash", AuthorityID: 700001, DeptID: department.ID, Enable: 1} + if err := data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + t.Fatal(err) + } + + loaded, err := (&userRepo{data: data}).FindUserByID(ctx, po.ID) + if err != nil { + t.Fatal(err) + } + if loaded.Authority.AuthorityID != 0 { + t.Fatalf("missing primary authority should preload as zero value: %+v", loaded.Authority) + } + if loaded.Department == nil || loaded.Department.ID != department.ID { + t.Fatalf("primary department was not independently loaded: %+v", loaded.Department) + } + if len(loaded.Departments) != 0 { + t.Fatalf("primary-only department leaked into many-to-many departments: %+v", loaded.Departments) + } +} + +func TestUserRegistrationDoesNotAddPrimaryAuthorityToRequestedAssociations(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{{AuthorityID: 888, AuthorityName: "primary"}, {AuthorityID: 999, AuthorityName: "requested"}}).Error; err != nil { + t.Fatal(err) + } + created, err := (&userRepo{data: data}).CreateUserWithAuthorities(ctx, &biz.User{Username: "association-shape", Password: "hash", AuthorityID: 888, Enable: 1}, []uint{999}) + if err != nil { + t.Fatal(err) + } + var links []userAuthorityPO + if err = data.gormDB.WithContext(ctx).Where("sys_user_id = ?", created.ID).Find(&links).Error; err != nil { + t.Fatal(err) + } + if len(links) != 1 || links[0].SysAuthorityAuthorityID != 999 { + t.Fatalf("registration authority links = %+v", links) + } +} + +func TestDefaultRouterLookupAcceptsAssignedOrphanMenu(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + menu := menuPO{Name: "orphan-default", ParentID: 999999} + if err := data.gormDB.WithContext(ctx).Create(&menu).Error; err != nil { + t.Fatal(err) + } + if err := data.gormDB.WithContext(ctx).Create(&authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: menu.ID}).Error; err != nil { + t.Fatal(err) + } + hasMenu, err := (&userRepo{data: data}).HasAuthorityMenu(ctx, 888, "orphan-default") + if err != nil { + t.Fatal(err) + } + if !hasMenu { + t.Fatal("assigned default menu was not found") } } diff --git a/internal/data/user.go b/internal/data/user.go index e417954..79f76b2 100644 --- a/internal/data/user.go +++ b/internal/data/user.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" ) func (r *userRepo) FindUserByUsername(ctx context.Context, username string) (*biz.User, error) { @@ -36,9 +37,20 @@ func (r *userRepo) FindUserByID(ctx context.Context, id uint) (*biz.User, error) return r.loadUser(ctx, &po) } +func (r *userRepo) FindUserByUUID(ctx context.Context, value string) (*biz.User, error) { + var po userPO + if err := r.data.gormDB.WithContext(ctx).Where("uuid = ?", value).First(&po).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, biz.ErrUserNotFound + } + return nil, err + } + return r.loadUser(ctx, &po) +} + func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error) { var authority authorityPO - if err := r.data.gormDB.WithContext(ctx).First(&authority, "authority_id = ?", po.AuthorityID).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).First(&authority, "authority_id = ?", po.AuthorityID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } var authorityPOs []authorityPO @@ -58,13 +70,17 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error) return nil, err } departments := make([]biz.Department, 0, len(departmentPOs)) - var primary *biz.Department for _, item := range departmentPOs { value := deptFromPO(item) departments = append(departments, *value) - if item.ID == po.DeptID { - copy := *value - primary = © + } + var primary *biz.Department + if po.DeptID != 0 { + var primaryPO departmentPO + if err := r.data.gormDB.WithContext(ctx).First(&primaryPO, po.DeptID).Error; err == nil { + primary = deptFromPO(primaryPO) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err } } var positionPOs []positionPO @@ -200,12 +216,21 @@ func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*biz.User, er return users, nil } +func (r *userRepo) HasAuthorityMenu(ctx context.Context, authorityID uint, name string) (bool, error) { + var count int64 + err := r.data.gormDB.WithContext(ctx).Model(&menuPO{}). + Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id"). + Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.name = ?", authorityID, name). + Count(&count).Error + return count > 0, err +} + func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*biz.Menu, error) { var pos []menuPO err := r.data.gormDB.WithContext(ctx).Table("sys_base_menus"). Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id"). Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.deleted_at IS NULL", authorityID). - Order("sys_base_menus.sort asc, sys_base_menus.id asc").Scan(&pos).Error + Order("sys_base_menus.sort").Scan(&pos).Error if err != nil { return nil, err } @@ -214,15 +239,15 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b for _, po := range pos { menu := menuFromPO(po) menu.MenuID = po.ID - menu.Children = []*biz.Menu{} + menu.Children = nil menu.Parameters = []*biz.MenuParameter{} - menu.Buttons = []*biz.MenuButton{} + menu.Buttons = nil byID[po.ID] = menu menuIDs = append(menuIDs, po.ID) } if len(menuIDs) > 0 { var parameters []menuParameterPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Order("id").Find(¶meters).Error; err != nil { + if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(¶meters).Error; err != nil { return nil, err } for _, parameter := range parameters { @@ -240,7 +265,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b menu.Buttons = append(menu.Buttons, value) } } - roots := make([]*biz.Menu, 0) + var roots []*biz.Menu for _, po := range pos { menu := byID[po.ID] if parent := byID[po.ParentID]; parent != nil { @@ -281,7 +306,7 @@ func (r *userRepo) ListUsers(ctx context.Context, page, pageSize int, filter *bi return nil, 0, err } var pos []userPO - if err := applyPagination(db.Order(order), page, pageSize, 100).Find(&pos).Error; err != nil { + if err := applyRequiredPagination(db.Order(order), page, pageSize, 100).Find(&pos).Error; err != nil { return nil, 0, err } users, err := r.loadUsers(ctx, pos) @@ -307,9 +332,7 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User } now := time.Now() po := userPO{UUID: user.UUID, Username: user.Username, Password: user.Password, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Phone: user.Phone, Email: user.Email, Enable: user.Enable, PasswordUpdatedAt: &now, MustChangePassword: user.MustChangePassword} - if len(authorityIDs) == 0 { - authorityIDs = []uint{user.AuthorityID} - } + var createdAuthorities []biz.Authority 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 @@ -321,34 +344,36 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } - if err := validateAuthorities(tx, authorityIDs); err != nil { - return err - } if err := tx.Create(&po).Error; err != nil { return err } + if len(authorityIDs) == 0 { + return nil + } links := make([]userAuthorityPO, 0, len(authorityIDs)) for _, authorityID := range authorityIDs { + // Creating a user through the compatible administration endpoint lets + // GORM save the many-to-many authority values supplied by the request. + // With an ID-only value this is an upsert: an existing role is left + // untouched, while a missing role is materialized with model defaults. + associated := authorityPO{AuthorityID: authorityID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&associated).Error; err != nil { + return err + } + createdAuthorities = append(createdAuthorities, toBizAuthority(associated)) links = append(links, userAuthorityPO{SysUserID: po.ID, SysAuthorityAuthorityID: authorityID}) } return tx.Create(&links).Error }); err != nil { return nil, err } - return r.FindUserByID(ctx, po.ID) -} - -func validateAuthorities(tx *gorm.DB, authorityIDs []uint) error { - for _, authorityID := range authorityIDs { - var count int64 - if err := tx.Model(&authorityPO{}).Where("authority_id = ?", authorityID).Count(&count).Error; err != nil { - return err - } - if count == 0 { - return errors.New("角色不存在") - } - } - return nil + // Registration returns the just-created entity, not a second hydrated + // query. The reference handler returns the request's authority IDs and + // leaves related authority/department records at their zero values; keep + // that response contract while retaining the persisted timestamps/UUID. + created := baseBizUser(&po) + created.Authorities = createdAuthorities + return created, nil } func (r *userRepo) UpdateUser(ctx context.Context, user *biz.User) error { @@ -416,14 +441,6 @@ func (r *userRepo) FillDepartmentNamePaths(ctx context.Context, user *biz.User) parts = append(parts, user.Departments[i].Name) user.Departments[i].NamePath = strings.Join(parts, "/") } - if user.Department != nil { - for i := range user.Departments { - if user.Departments[i].ID == user.Department.ID { - user.Department.NamePath = user.Departments[i].NamePath - break - } - } - } return nil } @@ -504,9 +521,6 @@ func setUserAuthorities(tx *gorm.DB, id uint, authorityIDs []uint) error { if len(authorityIDs) == 0 { return errors.New("角色不能为空") } - if err := validateAuthorities(tx, authorityIDs); err != nil { - return err - } if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil { return err } diff --git a/internal/data/version.go b/internal/data/version.go index 0fc3b74..ca723b6 100644 --- a/internal/data/version.go +++ b/internal/data/version.go @@ -14,10 +14,10 @@ type versionPO struct { CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` - Name string `gorm:"column:version_name"` - Code string `gorm:"column:version_code"` - Description string - Data string `gorm:"column:version_data;type:text"` + Name *string `gorm:"column:version_name"` + Code *string `gorm:"column:version_code"` + Description *string + Data *string `gorm:"column:version_data;type:text"` } func (versionPO) TableName() string { return "sys_versions" } diff --git a/internal/data/version_test.go b/internal/data/version_test.go new file mode 100644 index 0000000..4c06953 --- /dev/null +++ b/internal/data/version_test.go @@ -0,0 +1,24 @@ +package data + +import ( + "context" + "testing" +) + +func TestVersionRepositoryPreservesNullableFields(t *testing.T) { + data := newTransactionTestData(t) + ctx := context.Background() + if err := data.gormDB.WithContext(ctx).Exec( + "INSERT INTO sys_versions (created_at, updated_at, version_name, version_code, description, version_data) VALUES (CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, NULL)", + ).Error; err != nil { + t.Fatal(err) + } + + value, err := (&versionRepo{data: data}).FindVersion(ctx, 1) + if err != nil { + t.Fatal(err) + } + if value.Name != nil || value.Code != nil || value.Description != nil || value.Data != nil { + t.Fatalf("nullable version fields = %#v/%#v/%#v/%#v, want nil", value.Name, value.Code, value.Description, value.Data) + } +} diff --git a/internal/server/handler/announcement.go b/internal/server/handler/announcement.go index 2cd1c3b..319f19c 100644 --- a/internal/server/handler/announcement.go +++ b/internal/server/handler/announcement.go @@ -1,7 +1,6 @@ package handler import ( - "strconv" "time" "kra/internal/server/httpx" @@ -35,16 +34,7 @@ func (h *Announcement) Create(c *gin.Context) { } func (h *Announcement) Delete(c *gin.Context) { - 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 { + if err := h.service.Delete(c.Request.Context(), c.Query("ID")); err != nil { httpx.Fail(c, "删除失败") return } @@ -52,20 +42,7 @@ func (h *Announcement) Delete(c *gin.Context) { } func (h *Announcement) DeleteByIDs(c *gin.Context) { - values := c.QueryArray("IDs[]") - if len(values) == 0 { - values = c.QueryArray("IDs") - } - ids := make([]uint, 0, len(values)) - for _, value := range values { - id, err := strconv.ParseUint(value, 10, 64) - if err != nil || id == 0 { - httpx.Fail(c, "参数错误") - return - } - ids = append(ids, uint(id)) - } - if err := h.service.DeleteByIDs(c.Request.Context(), ids); err != nil { + if err := h.service.DeleteByIDs(c.Request.Context(), c.QueryArray("IDs[]")); err != nil { httpx.Fail(c, "批量删除失败") return } @@ -86,12 +63,7 @@ func (h *Announcement) Update(c *gin.Context) { } func (h *Announcement) Find(c *gin.Context) { - id, _, err := queryUintValue(c, "ID") - if err != nil { - httpx.Fail(c, err.Error()) - return - } - item, err := h.service.Find(c.Request.Context(), id) + item, err := h.service.Find(c.Request.Context(), c.Query("ID")) if err != nil { httpx.Fail(c, "查询失败") return diff --git a/internal/server/handler/audit.go b/internal/server/handler/audit.go index cb0745e..10af202 100644 --- a/internal/server/handler/audit.go +++ b/internal/server/handler/audit.go @@ -2,7 +2,6 @@ package handler import ( "errors" - "io" "log/slog" "strconv" "time" @@ -26,10 +25,23 @@ func NewAudit(service *service.AuditService, recorder *service.AuditRecorder, lo return &Audit{service: service, recorder: recorder, logs: logs, logger: logger} } -func page(c *gin.Context) (int, int) { - value, _ := strconv.Atoi(c.Query("page")) - size, _ := strconv.Atoi(c.Query("pageSize")) - return value, size +func page(c *gin.Context) (int, int, error) { + var value, size int + if raw, exists := c.GetQuery("page"); exists && raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + return 0, 0, err + } + value = parsed + } + if raw, exists := c.GetQuery("pageSize"); exists && raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + return 0, 0, err + } + size = parsed + } + return value, size, nil } func queryUintValue(c *gin.Context, key string) (uint, bool, error) { @@ -42,9 +54,6 @@ func queryUintValue(c *gin.Context, key string) (uint, bool, error) { } func IDsFromQuery(c *gin.Context) []uint { values := c.QueryArray("IDs[]") - if len(values) == 0 { - values = c.QueryArray("ids[]") - } ids := make([]uint, 0, len(values)) for _, value := range values { id, _ := strconv.ParseUint(value, 10, 64) @@ -55,33 +64,6 @@ func IDsFromQuery(c *gin.Context) []uint { return ids } -// auditID accepts both DELETE encodings supported by the administration API: -// the identifier may be carried in the query string or in a JSON body. -func auditID(c *gin.Context) (uint, error) { - if raw := c.Query("ID"); raw != "" { - value, err := strconv.ParseUint(raw, 10, 64) - 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 administration 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) { var req dto.OperationRecordSearchRequest if err := c.ShouldBindQuery(&req); err != nil { @@ -113,28 +95,24 @@ func (h *Audit) Operation(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysOperationRecord": item}, "查询成功") } func (h *Audit) DeleteOperation(c *gin.Context) { - id, err := auditID(c) - if err != nil { + var req dto.IDRequest + if err := c.ShouldBindJSON(&req); err != nil { httpx.Fail(c, err.Error()) return } - if id == 0 { - httpx.Fail(c, "删除失败") - return - } - if err := h.service.DeleteOperations(c.Request.Context(), []uint{id}); err != nil { + if err := h.service.DeleteOperations(c.Request.Context(), []uint{req.ID}); err != nil { httpx.Fail(c, "删除失败") return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Audit) DeleteOperations(c *gin.Context) { - ids, err := auditIDs(c) - if err != nil { + var req dto.IDsRequest + if err := c.ShouldBindJSON(&req); err != nil { httpx.Fail(c, err.Error()) return } - if err := h.service.DeleteOperations(c.Request.Context(), ids); err != nil { + if err := h.service.DeleteOperations(c.Request.Context(), req.IDs); err != nil { httpx.Fail(c, "批量删除失败") return } @@ -168,28 +146,24 @@ func (h *Audit) Login(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, item, "查询成功") } func (h *Audit) DeleteLogin(c *gin.Context) { - id, err := auditID(c) - if err != nil { + var req dto.IDRequest + if err := c.ShouldBindJSON(&req); err != nil { httpx.Fail(c, err.Error()) return } - if id == 0 { - httpx.Fail(c, "删除失败") - return - } - if err := h.service.DeleteLogins(c.Request.Context(), []uint{id}); err != nil { + if err := h.service.DeleteLogins(c.Request.Context(), []uint{req.ID}); err != nil { httpx.Fail(c, "删除失败") return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Audit) DeleteLogins(c *gin.Context) { - ids, err := auditIDs(c) - if err != nil { + var req dto.IDsRequest + if err := c.ShouldBindJSON(&req); err != nil { httpx.Fail(c, err.Error()) return } - if err := h.service.DeleteLogins(c.Request.Context(), ids); err != nil { + if err := h.service.DeleteLogins(c.Request.Context(), req.IDs); err != nil { httpx.Fail(c, "批量删除失败") return } @@ -298,24 +272,15 @@ func failLogViewer(c *gin.Context, err error, logger *slog.Logger) { } func (h *Audit) DeleteError(c *gin.Context) { - 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 { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + if err := h.service.DeleteErrors(c.Request.Context(), []uint{uint(id)}); err != nil { httpx.Fail(c, "删除失败:"+err.Error()) return } httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Audit) DeleteErrors(c *gin.Context) { - ids, err := auditIDs(c) - if err != nil { - httpx.Fail(c, err.Error()) - return - } - if err := h.service.DeleteErrors(c.Request.Context(), ids); err != nil { + if err := h.service.DeleteErrors(c.Request.Context(), IDsFromQuery(c)); err != nil { httpx.Fail(c, "批量删除失败:"+err.Error()) return } @@ -343,7 +308,11 @@ func (h *Audit) Error(c *gin.Context) { httpx.OKWithData(c, item) } func (h *Audit) Errors(c *gin.Context) { - p, size := page(c) + p, size, err := page(c) + if err != nil { + httpx.Fail(c, err.Error()) + return + } createdAtRange := make([]time.Time, 0, 2) for _, raw := range c.QueryArray("createdAtRange[]") { value, parseErr := time.Parse(time.RFC3339, raw) diff --git a/internal/server/handler/dictionary.go b/internal/server/handler/dictionary.go index 42fcfd9..5984b72 100644 --- a/internal/server/handler/dictionary.go +++ b/internal/server/handler/dictionary.go @@ -161,8 +161,20 @@ func (h *Dictionary) FindDetail(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysDictionaryDetail": item}, "查询成功") } func (h *Dictionary) Details(c *gin.Context) { - p, size := page(c) - dictionaryID, _ := strconv.ParseUint(c.Query("sysDictionaryID"), 10, 64) + p, size, err := page(c) + if err != nil { + httpx.Fail(c, err.Error()) + return + } + var dictionaryID uint64 + if raw, exists := c.GetQuery("sysDictionaryID"); exists && raw != "" { + var parseErr error + dictionaryID, parseErr = strconv.ParseUint(raw, 10, 0) + if parseErr != nil { + httpx.Fail(c, parseErr.Error()) + return + } + } filter := biz.DictionaryDetailFilter{DictionaryID: uint(dictionaryID), Label: c.Query("label"), Value: c.Query("value")} if raw, exists := c.GetQuery("status"); exists { value, parseErr := strconv.ParseBool(raw) @@ -199,7 +211,7 @@ func (h *Dictionary) Details(c *gin.Context) { func (h *Dictionary) Tree(byType bool) gin.HandlerFunc { return func(c *gin.Context) { rawID := c.Query("sysDictionaryID") - id, parseErr := strconv.ParseUint(rawID, 10, 64) + id, parseErr := strconv.ParseUint(rawID, 10, 32) typ := "" if byType { typ = c.Query("type") @@ -241,7 +253,7 @@ func (h *Dictionary) Path(c *gin.Context) { httpx.Fail(c, "字典详情ID不能为空") return } - id, parseErr := strconv.ParseUint(rawID, 10, 64) + id, parseErr := strconv.ParseUint(rawID, 10, 32) if parseErr != nil { httpx.Fail(c, "字典详情ID格式错误") return diff --git a/internal/server/handler/export.go b/internal/server/handler/export.go index 636ea65..caa9ac7 100644 --- a/internal/server/handler/export.go +++ b/internal/server/handler/export.go @@ -71,6 +71,12 @@ func (h *Export) Delete(c *gin.Context) { httpx.Fail(c, err.Error()) return } + // Deleting an empty model is rejected by GORM's global-update guard in the + // reference endpoint. Preserve the same externally visible failure. + if req.ID == 0 { + httpx.Fail(c, "删除失败") + return + } if err := h.service.Delete(c.Request.Context(), []uint{req.ID}); err != nil { httpx.Fail(c, "删除失败") return @@ -103,7 +109,11 @@ func (h *Export) Find(c *gin.Context) { httpx.OKWithData(c, gin.H{"resysExportTemplate": item}) } func (h *Export) List(c *gin.Context) { - p, size := page(c) + p, size, err := page(c) + if err != nil { + httpx.Fail(c, err.Error()) + return + } start, startErr := parseTime(c.Query("startCreatedAt")) end, endErr := parseTime(c.Query("endCreatedAt")) if startErr != nil || endErr != nil { diff --git a/internal/server/handler/media.go b/internal/server/handler/media.go index e446a2d..1ac83ed 100644 --- a/internal/server/handler/media.go +++ b/internal/server/handler/media.go @@ -67,7 +67,7 @@ func (h *Media) Delete(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") } func (h *Media) DeleteMany(c *gin.Context) { - var req dto.IDsRequest + var req dto.MediaBatchDeleteRequest if err := c.ShouldBindJSON(&req); err != nil { httpx.Fail(c, err.Error()) return diff --git a/internal/server/handler/menu.go b/internal/server/handler/menu.go index f21f255..631b753 100644 --- a/internal/server/handler/menu.go +++ b/internal/server/handler/menu.go @@ -106,7 +106,7 @@ func (h *Menu) Delete(c *gin.Context) { httpx.Fail(c, "ID值不能为空") return } - if err := h.service.Delete(c.Request.Context(), req.ID); err != nil { + if err := h.service.Delete(c.Request.Context(), uint(req.ID)); err != nil { httpx.Fail(c, "删除失败:"+err.Error()) return } @@ -123,7 +123,7 @@ func (h *Menu) Find(c *gin.Context) { httpx.Fail(c, "ID值不能为空") return } - item, err := h.service.Find(c.Request.Context(), req.ID) + item, err := h.service.Find(c.Request.Context(), uint(req.ID)) if err != nil { httpx.Fail(c, "获取失败") return diff --git a/internal/server/handler/parameter.go b/internal/server/handler/parameter.go index 282bd09..e9920c5 100644 --- a/internal/server/handler/parameter.go +++ b/internal/server/handler/parameter.go @@ -1,8 +1,6 @@ package handler import ( - "strconv" - "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" @@ -45,8 +43,7 @@ func (h *Parameter) Update(c *gin.Context) { } func (h *Parameter) Delete(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - if err := h.service.DeleteParameters(c.Request.Context(), []uint{uint(id)}); err != nil { + if err := h.service.DeleteParameters(c.Request.Context(), []string{c.Query("ID")}); err != nil { httpx.Fail(c, "删除失败:"+err.Error()) return } @@ -54,7 +51,7 @@ func (h *Parameter) Delete(c *gin.Context) { } func (h *Parameter) DeleteMany(c *gin.Context) { - ids := IDsFromQuery(c) + ids := c.QueryArray("IDs[]") if err := h.service.DeleteParameters(c.Request.Context(), ids); err != nil { httpx.Fail(c, "批量删除失败:"+err.Error()) return @@ -63,8 +60,7 @@ func (h *Parameter) DeleteMany(c *gin.Context) { } func (h *Parameter) Find(c *gin.Context) { - id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) - item, err := h.service.Parameter(c.Request.Context(), uint(id), "") + item, err := h.service.ParameterByID(c.Request.Context(), c.Query("ID")) if err != nil { httpx.Fail(c, "查询失败:"+err.Error()) return @@ -73,7 +69,7 @@ func (h *Parameter) Find(c *gin.Context) { } func (h *Parameter) Get(c *gin.Context) { - item, err := h.service.Parameter(c.Request.Context(), 0, c.Query("key")) + item, err := h.service.ParameterByKey(c.Request.Context(), c.Query("key")) if err != nil { httpx.Fail(c, "获取失败:"+err.Error()) return diff --git a/internal/server/handler/permission.go b/internal/server/handler/permission.go index 040f31b..d2253e6 100644 --- a/internal/server/handler/permission.go +++ b/internal/server/handler/permission.go @@ -1,8 +1,6 @@ package handler import ( - "strconv" - "kra/internal/server/httpx" "kra/internal/service" "kra/internal/service/dto" @@ -44,8 +42,7 @@ func (h *Permission) SetButtons(c *gin.Context) { } 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)) + allowed, err := h.service.CanRemoveButton(c.Request.Context(), c.Query("id")) if err != nil { httpx.Fail(c, err.Error()) return diff --git a/internal/server/handler/public.go b/internal/server/handler/public.go index 62e9338..6a19bee 100644 --- a/internal/server/handler/public.go +++ b/internal/server/handler/public.go @@ -91,7 +91,7 @@ func (h *Public) Login(c *gin.Context) { } return } - maxAge := int(time.Until(time.UnixMilli(result.ExpiresAt)).Seconds()) + maxAge := int(time.UnixMilli(result.ExpiresAt).Unix() - time.Now().Unix()) httpx.SetTokenCookie(c, result.Token, maxAge) httpx.Write(c, httpx.CodeSuccess, result, "登录成功") } diff --git a/internal/server/handler/task.go b/internal/server/handler/task.go index 5d30bb9..4abf861 100644 --- a/internal/server/handler/task.go +++ b/internal/server/handler/task.go @@ -125,7 +125,7 @@ func (h *Task) AlertStream(c *gin.Context) { httpx.Fail(c, "未获取到用户身份") return } - c.Header("Content-Type", "text/event-stream") + c.Header("Content-Type", "text/event-stream; charset=utf-8") c.Header("Cache-Control", "no-cache, no-transform") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") @@ -135,7 +135,9 @@ func (h *Task) AlertStream(c *gin.Context) { return } // Remove the normal server deadline for long-lived SSE connections. - _ = http.NewResponseController(c.Writer).SetWriteDeadline(time.Time{}) + controller := http.NewResponseController(c.Writer) + _ = controller.SetWriteDeadline(time.Time{}) + _ = controller.SetReadDeadline(time.Time{}) events := h.service.Subscribe(claims.ID) defer h.service.Unsubscribe(claims.ID, events) ticker := time.NewTicker(30 * time.Second) diff --git a/internal/server/handler/user.go b/internal/server/handler/user.go index d5d4a28..136d14a 100644 --- a/internal/server/handler/user.go +++ b/internal/server/handler/user.go @@ -71,7 +71,10 @@ func (h *User) Create(c *gin.Context) { httpx.Fail(c, err.Error()) return } - httpx.Write(c, httpx.CodeError, gin.H{"user": dto.UserResponse{}}, "注册失败") + if result == nil { + result = &dto.UserResponse{} + } + httpx.Write(c, httpx.CodeError, gin.H{"user": result}, "注册失败") return } httpx.Write(c, httpx.CodeSuccess, gin.H{"user": result}, "注册成功") @@ -115,7 +118,7 @@ func (h *User) UpdateSelf(c *gin.Context) { func (h *User) Delete(c *gin.Context) { var req dto.IDRequest - if err := c.ShouldBind(&req); err != nil { + if err := c.ShouldBindJSON(&req); err != nil { httpx.Fail(c, err.Error()) return } @@ -235,7 +238,7 @@ func (h *User) SwitchAuthority(c *gin.Context) { } c.Header("new-token", login.Token) c.Header("new-expires-at", strconv.FormatInt(login.ExpiresAt/1000, 10)) - maxAge := int(time.Until(time.UnixMilli(login.ExpiresAt)).Seconds()) + maxAge := int(time.UnixMilli(login.ExpiresAt).Unix() - time.Now().Unix()) httpx.SetTokenCookie(c, login.Token, maxAge) httpx.Write(c, httpx.CodeSuccess, gin.H{}, "修改成功") } @@ -246,7 +249,7 @@ func (h *User) Get(c *gin.Context) { httpx.NoAuth(c, "未登录或非法访问") return } - result, err := h.service.User(c.Request.Context(), claims.ID) + result, err := h.service.UserByUUID(c.Request.Context(), claims.UUID) if err != nil { httpx.Fail(c, "获取失败") return diff --git a/internal/server/handler/version.go b/internal/server/handler/version.go index 313eba2..c38a185 100644 --- a/internal/server/handler/version.go +++ b/internal/server/handler/version.go @@ -81,6 +81,7 @@ func (h *Version) List(c *gin.Context) { } 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 err := c.ShouldBindJSON(&req); err != nil { @@ -122,7 +123,7 @@ func (h *Version) Download(c *gin.Context) { 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)) + c.Data(http.StatusOK, "application/json", []byte(raw)) } func (h *Version) Import(c *gin.Context) { var req dto.ImportVersionRequest diff --git a/internal/service/announcement.go b/internal/service/announcement.go index 7796f96..8adac38 100644 --- a/internal/service/announcement.go +++ b/internal/service/announcement.go @@ -39,15 +39,15 @@ func (s *AnnouncementService) Update(ctx context.Context, in AnnouncementInput) return s.uc.Update(ctx, announcementDO(in)) } -func (s *AnnouncementService) Delete(ctx context.Context, id uint) error { +func (s *AnnouncementService) Delete(ctx context.Context, id string) error { return s.uc.Delete(ctx, id) } -func (s *AnnouncementService) DeleteByIDs(ctx context.Context, ids []uint) error { +func (s *AnnouncementService) DeleteByIDs(ctx context.Context, ids []string) error { return s.uc.DeleteByIDs(ctx, ids) } -func (s *AnnouncementService) Find(ctx context.Context, id uint) (*dto.AnnouncementResponse, error) { +func (s *AnnouncementService) Find(ctx context.Context, id string) (*dto.AnnouncementResponse, error) { item, err := s.uc.Find(ctx, id) if err != nil { return nil, err diff --git a/internal/service/api.go b/internal/service/api.go index e3fa952..22a6fed 100644 --- a/internal/service/api.go +++ b/internal/service/api.go @@ -71,7 +71,7 @@ func (s *APIService) Groups(ctx context.Context) ([]string, map[string]string, e return nil, nil, err } seen := make(map[string]bool) - groups := make([]string, 0) + var groups []string groupAPIMap := make(map[string]string) for _, item := range items { if !seen[item.APIGroup] { @@ -126,7 +126,10 @@ func (s *APIService) PolicyPathResponses(ctx context.Context, authorityID uint) if err != nil { return nil, err } - out := make([]dto.PolicyPathRequest, 0, len(items)) + var out []dto.PolicyPathRequest + if len(items) > 0 { + out = make([]dto.PolicyPathRequest, 0, len(items)) + } for _, item := range items { out = append(out, dto.PolicyPathRequest{Path: item.Path, Method: item.Method}) } diff --git a/internal/service/authority.go b/internal/service/authority.go index aca4159..88b9196 100644 --- a/internal/service/authority.go +++ b/internal/service/authority.go @@ -28,7 +28,11 @@ func authorityDomain(req *dto.AuthorityRequest) *biz.Authority { } func authorityResponse(value *biz.Authority) *dto.AuthorityResponse { - 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} + var menus []*dto.MenuResponse + if value.Menus != nil { + menus = menuResponses(value.Menus) + } + return &dto.AuthorityResponse{CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: value.DeletedAt, AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: nil, Menus: menus, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} } func (s *AuthorityService) Authorities(ctx context.Context) ([]*dto.AuthorityResponse, error) { @@ -56,7 +60,9 @@ func (s *AuthorityService) CopyAuthorityRequest(ctx context.Context, req *dto.Co if err := s.uc.CopyAuthority(ctx, req.OldAuthorityID, value); err != nil { return nil, err } - return authorityResponse(value), nil + result := authorityResponse(value) + result.Children = []*dto.AuthorityResponse{} + return result, nil } func (s *AuthorityService) UpdateAuthorityRequest(ctx context.Context, req *dto.AuthorityRequest) (*dto.AuthorityResponse, error) { diff --git a/internal/service/department.go b/internal/service/department.go index 7c50f84..f5d9402 100644 --- a/internal/service/department.go +++ b/internal/service/department.go @@ -14,9 +14,12 @@ func NewDepartmentService(uc *biz.DepartmentUsecase) *DepartmentService { } func departmentResponse(value *biz.Department) *dto.DepartmentResponse { - children := make([]*dto.DepartmentResponse, 0, len(value.Children)) - for _, child := range value.Children { - children = append(children, departmentResponse(child)) + var children []*dto.DepartmentResponse + if value.Children != nil { + children = make([]*dto.DepartmentResponse, 0, len(value.Children)) + for _, child := range value.Children { + children = append(children, departmentResponse(child)) + } } var leader any if value.Leader != nil { diff --git a/internal/service/department_test.go b/internal/service/department_test.go new file mode 100644 index 0000000..1efc873 --- /dev/null +++ b/internal/service/department_test.go @@ -0,0 +1,16 @@ +package service + +import ( + "testing" + + "kra/internal/biz" +) + +func TestDepartmentResponsePreservesNilAndEmptyChildren(t *testing.T) { + if got := departmentResponse(&biz.Department{}).Children; got != nil { + t.Fatalf("single/search department children = %#v, want nil", got) + } + if got := departmentResponse(&biz.Department{Children: []*biz.Department{}}).Children; got == nil || len(got) != 0 { + t.Fatalf("tree leaf children = %#v, want empty slice", got) + } +} diff --git a/internal/service/dictionary.go b/internal/service/dictionary.go index 4615204..262256c 100644 --- a/internal/service/dictionary.go +++ b/internal/service/dictionary.go @@ -66,7 +66,7 @@ func (s *DictionaryService) ExportDictionary(ctx context.Context, id uint) (*dto if err != nil { return nil, err } - details := make([]*dto.DictionaryDetailExportResponse, 0, len(v.Details)) + var details []*dto.DictionaryDetailExportResponse for _, detail := range v.Details { details = append(details, &dto.DictionaryDetailExportResponse{ Label: detail.Label, Value: detail.Value, Extend: detail.Extend, diff --git a/internal/service/dictionary_test.go b/internal/service/dictionary_test.go new file mode 100644 index 0000000..7a49645 --- /dev/null +++ b/internal/service/dictionary_test.go @@ -0,0 +1,28 @@ +package service + +import ( + "context" + "testing" + + "kra/internal/biz" +) + +type dictionaryExportRepo struct { + biz.DictionaryRepo + value *biz.Dictionary +} + +func (r dictionaryExportRepo) ExportDictionary(context.Context, uint) (*biz.Dictionary, error) { + return r.value, nil +} + +func TestExportDictionaryPreservesNilDetails(t *testing.T) { + service := NewDictionaryService(biz.NewDictionaryUsecase(dictionaryExportRepo{value: &biz.Dictionary{Name: "empty", Type: "empty"}})) + result, err := service.ExportDictionary(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if result.SysDictionaryDetails != nil { + t.Fatalf("export details = %#v, want nil", result.SysDictionaryDetails) + } +} diff --git a/internal/service/dto/audit_test.go b/internal/service/dto/audit_test.go new file mode 100644 index 0000000..22a1969 --- /dev/null +++ b/internal/service/dto/audit_test.go @@ -0,0 +1,38 @@ +package dto + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func bindErrorMutation(t *testing.T, body string) (ErrorRecordMutationRequest, error) { + t.Helper() + context, _ := gin.CreateTestContext(httptest.NewRecorder()) + context.Request = httptest.NewRequest("PUT", "/sysError/updateSysError", strings.NewReader(body)) + context.Request.Header.Set("Content-Type", "application/json") + var request ErrorRecordMutationRequest + return request, context.ShouldBindJSON(&request) +} + +func TestErrorRecordMutationAcceptsExplicitEmptyPointerFields(t *testing.T) { + request, err := bindErrorMutation(t, `{"ID":1,"form":"","info":"","solution":""}`) + if err != nil { + t.Fatal(err) + } + if request.Form == nil || *request.Form != "" || request.Info == nil || *request.Info != "" || request.Solution == nil || *request.Solution != "" { + t.Fatalf("explicit empty values lost: form=%#v info=%#v solution=%#v", request.Form, request.Info, request.Solution) + } +} + +func TestErrorRecordMutationRequiresFormButKeepsOtherFieldsOmittable(t *testing.T) { + request, err := bindErrorMutation(t, `{"ID":1}`) + if err == nil { + t.Fatal("missing form unexpectedly accepted") + } + if request.Info != nil || request.Solution != nil { + t.Fatalf("omitted fields = (%#v, %#v), want nil", request.Info, request.Solution) + } +} diff --git a/internal/service/dto/authority.go b/internal/service/dto/authority.go index e93a6d5..1353057 100644 --- a/internal/service/dto/authority.go +++ b/internal/service/dto/authority.go @@ -20,12 +20,12 @@ type DeleteAuthorityRequest struct { } type SetRoleUsersRequest struct { AuthorityID uint `json:"authorityId" form:"authorityId"` - UserIDs []uint `json:"userIds"` + UserIDs []uint `json:"userIds" form:"userIds"` } type SetDataScopeRequest struct { AuthorityID uint `json:"authorityId" form:"authorityId"` - DataScope int `json:"dataScope"` - DepartmentIDs []uint `json:"deptIds"` + DataScope int `json:"dataScope" form:"dataScope"` + DepartmentIDs []uint `json:"deptIds" form:"deptIds"` } type AuthorityResponse struct { @@ -36,7 +36,7 @@ type AuthorityResponse struct { AuthorityName string `json:"authorityName"` ParentID *uint `json:"parentId"` Children []*AuthorityResponse `json:"children"` - Menus []any `json:"menus"` + Menus []*MenuResponse `json:"menus"` DataScope int `json:"dataScope"` DefaultRouter string `json:"defaultRouter"` } diff --git a/internal/service/dto/media.go b/internal/service/dto/media.go index b902d81..d061b32 100644 --- a/internal/service/dto/media.go +++ b/internal/service/dto/media.go @@ -61,6 +61,9 @@ type IDRequest struct { type IDsRequest struct { IDs []uint `json:"ids"` } +type MediaBatchDeleteRequest struct { + IDs []uint `json:"ids" binding:"required"` +} type RenameMediaRequest struct { ID uint `json:"ID"` Name string `json:"name"` diff --git a/internal/service/dto/menu.go b/internal/service/dto/menu.go index a75db25..d9d7c31 100644 --- a/internal/service/dto/menu.go +++ b/internal/service/dto/menu.go @@ -13,19 +13,27 @@ type MenuMetaRequest struct { } type MenuButtonRequest struct { - ID uint `json:"ID"` - Name string `json:"name"` - Description string `json:"desc"` + 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 MenuParameterRequest struct { - ID uint `json:"ID"` - 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 MenuRequest struct { + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` ID uint `json:"ID"` ParentID uint `json:"parentId"` Path string `json:"path"` @@ -39,20 +47,16 @@ type MenuRequest struct { } type DeleteMenuRequest struct { - ID uint `json:"ID"` + ID int `json:"id"` } type GetMenuRequest struct { - ID uint `json:"id"` -} - -type AuthorityMenuItemRequest struct { - ID uint `json:"ID"` + ID int `json:"id"` } type SetAuthorityMenusRequest struct { - AuthorityID uint `json:"authorityId"` - Menus []AuthorityMenuItemRequest `json:"menus"` + AuthorityID uint `json:"authorityId"` + Menus []MenuRequest `json:"menus"` } type GetAuthorityMenusRequest struct { @@ -108,14 +112,32 @@ type MenuResponse struct { Children []*MenuResponse `json:"children"` Parameters []MenuParameterResponse `json:"parameters"` MenuButtons []MenuButtonResponse `json:"menuBtn"` + Authorities []*AuthorityResponse `json:"authoritys"` +} + +type DynamicMenuResponse struct { + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + ID uint `json:"ID"` + MenuID uint `json:"menuId"` + 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 MenuMetaResponse `json:"meta"` + Children []*DynamicMenuResponse `json:"children"` + Parameters []MenuParameterResponse `json:"parameters"` + MenuButtons []MenuButtonResponse `json:"menuBtn"` Buttons map[string]uint `json:"btns"` + Authorities []*AuthorityResponse `json:"authoritys"` } type AuthorityMenuResponse struct { // 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"` + *DynamicMenuResponse AuthorityID uint `json:"-"` } diff --git a/internal/service/dto/permission.go b/internal/service/dto/permission.go index e260fbc..68cf6c5 100644 --- a/internal/service/dto/permission.go +++ b/internal/service/dto/permission.go @@ -1,8 +1,9 @@ package dto type GetAuthorityButtonsRequest struct { - MenuID uint `json:"menuID"` - AuthorityID uint `json:"authorityId"` + MenuID uint `json:"menuID"` + AuthorityID uint `json:"authorityId"` + Selected []uint `json:"selected"` } type SetAuthorityButtonsRequest struct { diff --git a/internal/service/dto/settings.go b/internal/service/dto/settings.go index 1063832..57df6ac 100644 --- a/internal/service/dto/settings.go +++ b/internal/service/dto/settings.go @@ -31,11 +31,13 @@ type DictionaryDetailsByParentRequest struct { IncludeChildren bool `json:"includeChildren" form:"includeChildren"` } type SystemParameterRequest struct { - ID uint `json:"ID" form:"ID"` - 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"` + ID uint `json:"ID" form:"ID"` + CreatedAt time.Time `json:"CreatedAt" form:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt" form:"UpdatedAt"` + 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 { diff --git a/internal/service/dto/system.go b/internal/service/dto/system.go index 3f19f65..28fefc3 100644 --- a/internal/service/dto/system.go +++ b/internal/service/dto/system.go @@ -75,25 +75,26 @@ type LoginLogRequest struct { } type UserResponse struct { - ID uint `json:"ID"` - CreatedAt time.Time `json:"CreatedAt"` - UpdatedAt time.Time `json:"UpdatedAt"` - DeletedAt any `json:"-"` - UUID string `json:"uuid"` - Username string `json:"userName"` - NickName string `json:"nickName"` - HeaderImg string `json:"headerImg"` - AuthorityID uint `json:"authorityId"` - Authority *AuthorityResponse `json:"authority"` - Authorities []*AuthorityResponse `json:"authorities"` - DeptID uint `json:"deptId"` - Department any `json:"dept"` - Departments []*DepartmentResponse `json:"departments"` - Positions []*PositionResponse `json:"positions"` - Phone string `json:"phone"` - Email string `json:"email"` - Enable int `json:"enable"` - OriginSetting map[string]any `json:"originSetting"` + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + DeletedAt any `json:"-"` + UUID string `json:"uuid"` + Username string `json:"userName"` + NickName string `json:"nickName"` + HeaderImg string `json:"headerImg"` + AuthorityID uint `json:"authorityId"` + Authority *AuthorityResponse `json:"authority"` + Authorities []*AuthorityResponse `json:"authorities"` + DeptID uint `json:"deptId"` + Department any `json:"dept"` + Departments []*DepartmentResponse `json:"departments"` + Positions []*PositionResponse `json:"positions"` + Phone string `json:"phone"` + Email string `json:"email"` + Enable int `json:"enable"` + OriginSetting map[string]any `json:"originSetting"` + PasswordUpdatedAt *time.Time `json:"passwordUpdatedAt"` } type ServerInfoResponse struct { diff --git a/internal/service/dto/version.go b/internal/service/dto/version.go index 11ec334..3c33d70 100644 --- a/internal/service/dto/version.go +++ b/internal/service/dto/version.go @@ -7,10 +7,10 @@ type VersionResponse struct { CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` DeletedAt any `json:"-"` - VersionName string `json:"versionName"` - VersionCode string `json:"versionCode"` - Description string `json:"description"` - VersionData string `json:"versionData"` + VersionName *string `json:"versionName"` + VersionCode *string `json:"versionCode"` + Description *string `json:"description"` + VersionData *string `json:"versionData"` } type VersionSearchRequest struct { diff --git a/internal/service/menu.go b/internal/service/menu.go index 0e721d9..7e55c58 100644 --- a/internal/service/menu.go +++ b/internal/service/menu.go @@ -56,9 +56,10 @@ func (s *MenuService) AuthorityMenus(ctx context.Context, authorityID uint) ([]* if err != nil { return nil, err } - out := make([]*dto.AuthorityMenuResponse, 0, len(items)) + var out []*dto.AuthorityMenuResponse for _, item := range items { - out = append(out, &dto.AuthorityMenuResponse{MenuResponse: menuResponse(item), MenuID: item.ID, AuthorityID: authorityID}) + item.MenuID = item.ID + out = append(out, &dto.AuthorityMenuResponse{DynamicMenuResponse: dynamicMenuResponse(item), AuthorityID: authorityID}) } return out, nil } @@ -74,42 +75,86 @@ func (s *MenuService) SetRoles(ctx context.Context, req *dto.SetMenuRolesRequest } func menuDomain(v *dto.MenuRequest) *biz.Menu { - menu := &biz.Menu{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType} + menu := &biz.Menu{CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType} menu.Buttons = make([]*biz.MenuButton, 0, len(v.MenuButtons)) for _, button := range v.MenuButtons { - menu.Buttons = append(menu.Buttons, &biz.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID}) + menu.Buttons = append(menu.Buttons, &biz.MenuButton{CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID}) } menu.Parameters = make([]*biz.MenuParameter, 0, len(v.Parameters)) for _, parameter := range v.Parameters { - menu.Parameters = append(menu.Parameters, &biz.MenuParameter{ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) + menu.Parameters = append(menu.Parameters, &biz.MenuParameter{CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) } return menu } func menuResponse(menu *biz.Menu) *dto.MenuResponse { - children := make([]*dto.MenuResponse, 0, len(menu.Children)) - for _, child := range menu.Children { - children = append(children, menuResponse(child)) - } - 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{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 + var children []*dto.MenuResponse + if menu.Children != nil { + children = make([]*dto.MenuResponse, 0, len(menu.Children)) + for _, child := range menu.Children { + children = append(children, menuResponse(child)) } } - parameters := make([]dto.MenuParameterResponse, 0, len(menu.Parameters)) - for _, parameter := range menu.Parameters { - 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}) + var buttons []dto.MenuButtonResponse + if menu.Buttons != nil { + buttons = make([]dto.MenuButtonResponse, 0, len(menu.Buttons)) + for _, button := range menu.Buttons { + buttons = append(buttons, dto.MenuButtonResponse{CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, ID: button.ID, Name: button.Name, Description: button.Description, SysBaseMenuID: button.MenuID}) + } } - 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} + var parameters []dto.MenuParameterResponse + if menu.Parameters != nil { + parameters = make([]dto.MenuParameterResponse, 0, len(menu.Parameters)) + for _, parameter := range menu.Parameters { + 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{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} +} + +func dynamicMenuResponse(menu *biz.Menu) *dto.DynamicMenuResponse { + var children []*dto.DynamicMenuResponse + if menu.Children != nil { + children = make([]*dto.DynamicMenuResponse, 0, len(menu.Children)) + for _, child := range menu.Children { + children = append(children, dynamicMenuResponse(child)) + } + } + var parameters []dto.MenuParameterResponse + if menu.Parameters != nil { + parameters = make([]dto.MenuParameterResponse, 0, len(menu.Parameters)) + for _, parameter := range menu.Parameters { + 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}) + } + } + var buttonAuthorities map[string]uint + for _, button := range menu.Buttons { + if button.AuthorityID == 0 { + continue + } + if buttonAuthorities == nil { + buttonAuthorities = make(map[string]uint) + } + buttonAuthorities[button.Name] = button.AuthorityID + } + return &dto.DynamicMenuResponse{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: nil, Buttons: buttonAuthorities} } func menuResponses(items []*biz.Menu) []*dto.MenuResponse { + if items == nil { + return nil + } out := make([]*dto.MenuResponse, 0, len(items)) for _, item := range items { out = append(out, menuResponse(item)) } return out } + +func dynamicMenuResponses(items []*biz.Menu) []*dto.DynamicMenuResponse { + out := make([]*dto.DynamicMenuResponse, 0, len(items)) + for _, item := range items { + out = append(out, dynamicMenuResponse(item)) + } + return out +} diff --git a/internal/service/menu_test.go b/internal/service/menu_test.go new file mode 100644 index 0000000..3931d78 --- /dev/null +++ b/internal/service/menu_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "encoding/json" + "testing" + "time" + + "kra/internal/biz" + "kra/internal/service/dto" +) + +func TestMenuDomainPreservesRelationIdentity(t *testing.T) { + createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC) + updatedAt := time.Date(2025, time.April, 5, 6, 7, 8, 0, time.UTC) + menu := menuDomain(&dto.MenuRequest{ + CreatedAt: createdAt, + UpdatedAt: updatedAt, + ID: 10, + MenuButtons: []dto.MenuButtonRequest{{ + CreatedAt: createdAt, UpdatedAt: updatedAt, ID: 31, Name: "edit", Description: "编辑", + }}, + Parameters: []dto.MenuParameterRequest{{ + CreatedAt: createdAt, UpdatedAt: updatedAt, ID: 21, Type: "query", Key: "id", Value: "1", + }}, + }) + if !menu.CreatedAt.Equal(createdAt) || !menu.UpdatedAt.Equal(updatedAt) { + t.Fatalf("menu timestamps = (%v, %v), want (%v, %v)", menu.CreatedAt, menu.UpdatedAt, createdAt, updatedAt) + } + if len(menu.Buttons) != 1 || menu.Buttons[0].ID != 31 || menu.Buttons[0].MenuID != 10 || !menu.Buttons[0].CreatedAt.Equal(createdAt) || !menu.Buttons[0].UpdatedAt.Equal(updatedAt) { + t.Fatalf("button conversion = %+v", menu.Buttons) + } + if len(menu.Parameters) != 1 || menu.Parameters[0].ID != 21 || menu.Parameters[0].MenuID != 10 || !menu.Parameters[0].CreatedAt.Equal(createdAt) || !menu.Parameters[0].UpdatedAt.Equal(updatedAt) { + t.Fatalf("parameter conversion = %+v", menu.Parameters) + } +} + +func TestMenuResponseEmptyCollectionContracts(t *testing.T) { + if got := menuResponses(nil); got != nil { + t.Fatalf("base menu top-level nil = %#v, want nil", got) + } + if got := dynamicMenuResponses(nil); got == nil || len(got) != 0 { + t.Fatalf("dynamic menu top-level empty = %#v, want non-nil empty slice", got) + } + + base := menuResponse(&biz.Menu{Children: nil, Buttons: []*biz.MenuButton{}, Parameters: []*biz.MenuParameter{}}) + if base.Children != nil { + t.Fatalf("leaf children = %#v, want nil", base.Children) + } + if base.MenuButtons == nil || base.Parameters == nil { + t.Fatalf("preloaded empty relations = (%#v, %#v), want empty slices", base.MenuButtons, base.Parameters) + } + encoded, err := json.Marshal(base) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatal(err) + } + if value, ok := payload["authoritys"]; !ok || value != nil { + t.Fatalf("authoritys JSON = %#v (present=%v), want explicit null", value, ok) + } +} diff --git a/internal/service/parameter.go b/internal/service/parameter.go index 22fec7f..9c0633a 100644 --- a/internal/service/parameter.go +++ b/internal/service/parameter.go @@ -15,7 +15,7 @@ func NewParameterService(uc *biz.ParameterUsecase) *ParameterService { } func parameterDomain(value *dto.SystemParameterRequest) *biz.SystemParameter { - return &biz.SystemParameter{ID: value.ID, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description} + return &biz.SystemParameter{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description} } func (s *ParameterService) CreateParameterRequest(ctx context.Context, req *dto.SystemParameterRequest) error { return s.CreateParameter(ctx, parameterDomain(req)) @@ -40,8 +40,15 @@ func (s *ParameterService) Parameters(ctx context.Context, page, size int, q *bi } return out, total, nil } -func (s *ParameterService) Parameter(ctx context.Context, id uint, key string) (*dto.SystemParameterResponse, error) { - v, err := s.uc.FindParameter(ctx, id, key) +func (s *ParameterService) ParameterByID(ctx context.Context, id string) (*dto.SystemParameterResponse, error) { + v, err := s.uc.FindParameterByID(ctx, id) + if err != nil { + return nil, err + } + return parameterDTO(v), nil +} +func (s *ParameterService) ParameterByKey(ctx context.Context, key string) (*dto.SystemParameterResponse, error) { + v, err := s.uc.FindParameterByKey(ctx, key) if err != nil { return nil, err } @@ -53,6 +60,6 @@ func (s *ParameterService) CreateParameter(ctx context.Context, v *biz.SystemPar func (s *ParameterService) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error { return s.uc.UpdateParameter(ctx, v) } -func (s *ParameterService) DeleteParameters(ctx context.Context, ids []uint) error { +func (s *ParameterService) DeleteParameters(ctx context.Context, ids []string) error { return s.uc.DeleteParameters(ctx, ids) } diff --git a/internal/service/permission.go b/internal/service/permission.go index 7961c14..eae7542 100644 --- a/internal/service/permission.go +++ b/internal/service/permission.go @@ -20,6 +20,6 @@ func (s *PermissionService) SetSelectedButtons(ctx context.Context, authorityID, return s.uc.SetSelectedButtons(ctx, authorityID, menuID, ids) } -func (s *PermissionService) CanRemoveButton(ctx context.Context, id uint) (bool, error) { +func (s *PermissionService) CanRemoveButton(ctx context.Context, id string) (bool, error) { return s.uc.CanRemoveButton(ctx, id) } diff --git a/internal/service/security.go b/internal/service/security.go index cf2b189..71b5550 100644 --- a/internal/service/security.go +++ b/internal/service/security.go @@ -25,7 +25,7 @@ func (s *SecurityService) CurrentSecurity(ctx context.Context) (*biz.SecurityCon return s.uc.Current(ctx) } func (s *SecurityService) Security(ctx context.Context) (*dto.SecurityConfigResponse, error) { - value, err := s.CurrentSecurity(ctx) + value, err := s.uc.Security(ctx) if err != nil { return nil, err } diff --git a/internal/service/user.go b/internal/service/user.go index c5afa92..53009d5 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -45,12 +45,20 @@ func (s *UserService) User(ctx context.Context, id uint) (*dto.UserResponse, err return convertUser(value), nil } -func (s *UserService) Menus(ctx context.Context, authorityID uint) ([]*dto.MenuResponse, error) { +func (s *UserService) UserByUUID(ctx context.Context, uuid string) (*dto.UserResponse, error) { + value, err := s.uc.UserByUUID(ctx, uuid) + if err != nil { + return nil, err + } + return convertUser(value), nil +} + +func (s *UserService) Menus(ctx context.Context, authorityID uint) ([]*dto.DynamicMenuResponse, error) { menus, err := s.uc.Menus(ctx, authorityID) if err != nil { return nil, err } - return menuResponses(menus), nil + return dynamicMenuResponses(menus), nil } func (s *UserService) ListUsers(ctx context.Context, page, pageSize int, filter *biz.UserListFilter) ([]*dto.UserResponse, int64, error) { diff --git a/internal/service/user_conversion.go b/internal/service/user_conversion.go index af6e2f5..c6e3e00 100644 --- a/internal/service/user_conversion.go +++ b/internal/service/user_conversion.go @@ -6,24 +6,39 @@ import ( ) func convertAuthority(value biz.Authority) *dto.AuthorityResponse { - 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} + var menus []*dto.MenuResponse + if value.Menus != nil { + menus = menuResponses(value.Menus) + } + return &dto.AuthorityResponse{CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: value.DeletedAt, AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: nil, Menus: menus, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter} } func convertUser(user *biz.User) *dto.UserResponse { - authorities := make([]*dto.AuthorityResponse, 0, len(user.Authorities)) - for _, value := range user.Authorities { - authorities = append(authorities, convertAuthority(value)) + var authorities []*dto.AuthorityResponse + if user.Authorities != nil { + authorities = make([]*dto.AuthorityResponse, 0, len(user.Authorities)) + for _, value := range user.Authorities { + authorities = append(authorities, convertAuthority(value)) + } } - departments := make([]*dto.DepartmentResponse, 0, len(user.Departments)) - for i := range user.Departments { - departments = append(departments, departmentResponse(&user.Departments[i])) + var departments []*dto.DepartmentResponse + if user.Departments != nil { + departments = make([]*dto.DepartmentResponse, 0, len(user.Departments)) + for i := range user.Departments { + departments = append(departments, departmentResponse(&user.Departments[i])) + } } - positions := make([]*dto.PositionResponse, 0, len(user.Positions)) - for i := range user.Positions { - positions = append(positions, positionResponse(&user.Positions[i])) + var positions []*dto.PositionResponse + if user.Positions != nil { + positions = make([]*dto.PositionResponse, 0, len(user.Positions)) + for i := range user.Positions { + positions = append(positions, positionResponse(&user.Positions[i])) + } } - var department any = map[string]any{} + // The primary department is represented by a zero-value object when it is + // absent, preserving the response shape expected by the administration UI. + var department any = dto.DepartmentResponse{} if user.Department != nil { department = departmentResponse(user.Department) } - return &dto.UserResponse{ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, DeletedAt: nil, UUID: user.UUID, Username: user.Username, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Authority: convertAuthority(user.Authority), Authorities: authorities, DeptID: user.DeptID, Department: department, Departments: departments, Positions: positions, Phone: user.Phone, Email: user.Email, Enable: user.Enable, OriginSetting: user.OriginSetting} + return &dto.UserResponse{ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, DeletedAt: nil, UUID: user.UUID, Username: user.Username, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Authority: convertAuthority(user.Authority), Authorities: authorities, DeptID: user.DeptID, Department: department, Departments: departments, Positions: positions, Phone: user.Phone, Email: user.Email, Enable: user.Enable, OriginSetting: user.OriginSetting, PasswordUpdatedAt: user.PasswordUpdatedAt} } diff --git a/internal/service/version.go b/internal/service/version.go index f43d944..d551cac 100644 --- a/internal/service/version.go +++ b/internal/service/version.go @@ -132,6 +132,15 @@ func exportMenu(value *biz.Menu) *versionExportMenu { 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} } + +func versionValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func versionPointer(value string) *string { return &value } func (s *VersionService) Versions(ctx context.Context, page, size int, name, code string, createdAtRange []*time.Time) ([]*dto.VersionResponse, int64, error) { items, total, err := s.uc.ListVersions(ctx, page, size, name, code, createdAtRange) if err != nil { @@ -155,11 +164,11 @@ func (s *VersionService) VersionData(ctx context.Context, id uint) (string, stri if err != nil { return "", "", err } - if v.Data != "" { - return v.Data, v.Code, nil + if v.Data != nil && *v.Data != "" { + return *v.Data, versionValue(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")}, + Version: versionExportInfo{Name: versionValue(v.Name), Code: versionValue(v.Code), Description: versionValue(v.Description), ExportTime: v.CreatedAt.Format("2006-01-02 15:04:05")}, Menus: []*versionExportMenu{}, APIs: []versionExportAPI{}, } @@ -167,7 +176,7 @@ func (s *VersionService) VersionData(ctx context.Context, id uint) (string, stri if marshalErr != nil { return "", "", marshalErr } - return string(raw), v.Code, nil + return string(raw), versionValue(v.Code), nil } func (s *VersionService) DeleteVersions(ctx context.Context, ids []uint) error { return s.uc.DeleteVersions(ctx, ids) @@ -206,7 +215,7 @@ func (s *VersionService) Export(ctx context.Context, name, code, description str if err != nil { return &biz.VersionStageError{Stage: biz.VersionStageJSON, Err: err} } - if err = s.uc.CreateVersion(ctx, &biz.Version{Name: name, Code: code, Description: description, Data: string(raw)}); err != nil { + if err = s.uc.CreateVersion(ctx, &biz.Version{Name: versionPointer(name), Code: versionPointer(code), Description: versionPointer(description), Data: versionPointer(string(raw))}); err != nil { return &biz.VersionStageError{Stage: biz.VersionStageSave, Err: err} } return nil @@ -250,10 +259,10 @@ func (s *VersionService) ImportRequest(ctx context.Context, req *dto.ImportVersi } raw, _ := json.Marshal(req) _ = s.uc.CreateVersion(ctx, &biz.Version{ - Name: req.Version.Name, - Code: req.Version.Code + "_imported_" + time.Now().Format("20060102150405"), - Description: "导入版本: " + req.Version.Description, - Data: string(raw), + Name: versionPointer(req.Version.Name), + Code: versionPointer(req.Version.Code + "_imported_" + time.Now().Format("20060102150405")), + Description: versionPointer("导入版本: " + req.Version.Description), + Data: versionPointer(string(raw)), }) return nil } diff --git a/internal/worker/task_scheduler.go b/internal/worker/task_scheduler.go index be2e59b..371446e 100644 --- a/internal/worker/task_scheduler.go +++ b/internal/worker/task_scheduler.go @@ -55,7 +55,7 @@ func (s *TaskScheduler) Start(ctx context.Context) error { } } } - // GVA reports the number of rows read from the database, not only + // Report the number of rows read from the database, not only // the enabled rows that were scheduled successfully. s.logger.InfoContext(ctx, "定时任务加载完成", "task_count", len(items)) } else { @@ -222,6 +222,13 @@ func (s *TaskScheduler) Subscribe(userID uint) chan []byte { if s.subscribers[userID] == nil { s.subscribers[userID] = map[chan []byte]struct{}{} } + if len(s.subscribers[userID]) >= 10 { + for old := range s.subscribers[userID] { + delete(s.subscribers[userID], old) + close(old) + break + } + } s.subscribers[userID][ch] = struct{}{} s.subMu.Unlock() return ch diff --git a/internal/worker/task_scheduler_test.go b/internal/worker/task_scheduler_test.go index c2f1da6..644cf8e 100644 --- a/internal/worker/task_scheduler_test.go +++ b/internal/worker/task_scheduler_test.go @@ -23,3 +23,28 @@ func TestCloseSubscribersClosesEveryAlertStream(t *testing.T) { // closed and removed every channel. scheduler.Unsubscribe(1, first) } + +func TestSubscribeCapsConnectionsPerUser(t *testing.T) { + scheduler := &TaskScheduler{subscribers: map[uint]map[chan []byte]struct{}{}} + channels := make([]chan []byte, 0, 11) + for i := 0; i < 11; i++ { + channels = append(channels, scheduler.Subscribe(7)) + } + if got := len(scheduler.subscribers[7]); got != 10 { + t.Fatalf("subscriber count = %d, want 10", got) + } + closed := 0 + for _, ch := range channels { + select { + case _, open := <-ch: + if !open { + closed++ + } + default: + } + } + if closed != 1 { + t.Fatalf("evicted subscriber count = %d, want 1", closed) + } + scheduler.closeSubscribers() +} diff --git a/pkg/adminauth/token.go b/pkg/adminauth/token.go index 8959927..7fe2d63 100644 --- a/pkg/adminauth/token.go +++ b/pkg/adminauth/token.go @@ -33,10 +33,17 @@ func Generate(secret, issuer string, expires, buffer time.Duration, userID, auth } 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{"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)) + token, err := Sign(secret, claims) return token, claims, err } +func Sign(secret string, claims *Claims) (string, error) { + if secret == "" { + return "", errors.New("empty JWT signing key") + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) +} + func Parse(tokenString, secret string) (*Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { if token.Method != jwt.SigningMethodHS256 { diff --git a/web/src/view/systemTools/system/system.vue b/web/src/view/systemTools/system/system.vue index f7f7a81..5065a3e 100644 --- a/web/src/view/systemTools/system/system.vue +++ b/web/src/view/systemTools/system/system.vue @@ -17,11 +17,24 @@ + + + + + + + + + - + + + @@ -53,6 +66,9 @@ + + +
@@ -118,9 +134,11 @@ + + @@ -134,6 +152,7 @@ +
@@ -143,7 +162,7 @@ - + @@ -218,6 +237,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { getSystemConfig, reloadSystem, setSystemConfig } from '@/api/system' import { emailTest } from '@/api/email' + import { CreateUUID } from '@/utils/format' defineOptions({ name: 'Config' }) @@ -229,10 +249,10 @@ routerPrefix: '', jwt: { signingKey: '******', expiresTime: '168h', bufferTime: '24h', issuer: 'kra' }, captcha: { keyLong: 6, imgWidth: 240, imgHeight: 80, storeExpiration: '3m' }, - local: { storePath: 'uploads/file', pathPrefix: 'uploads/file' }, media: { sessionTtl: 24, maxFileSize: 0 }, + local: { storePath: 'uploads/file', pathPrefix: 'uploads/file' }, media: { sessionTtl: 24, maxFileSize: 0, chunkDir: 'uploads/chunks' }, zap: { level: 'info', prefix: '[kra] ', format: 'json', director: 'logs', encode_level: 'LowercaseLevelEncoder', stacktrace_key: 'stacktrace', show_line: true, log_in_console: true, retention_day: 7, access_req_body: true, access_resp_data: true, access_req_headers: true, access_log_max_bytes: 1024, file_only_modules: [] }, cors: { mode: 'whitelist', whitelist: [] }, app: { node: '', app_id: 'kra', env: 'development' }, - system: { useRedis: false, useMultipoint: false, useStrictAuth: false, disableAutoMigrate: false, useMongo: false }, + system: { useRedis: false, useMultipoint: false, useStrictAuth: false, disableAutoMigrate: false, useMongo: false, addr: 8000, iplimitCount: 0, iplimitTime: 0 }, storage: { type: 'local', qiniu: {}, aliyun_oss: {}, huawei_obs: {}, tencent_cos: {}, aws_s3: {}, cloudflare_r2: {}, minio: {} } }, email: { @@ -258,6 +278,8 @@ const databaseListText = ref('[]') const redisListText = ref('[]') const corsRulesText = ref('[]') + const diskListText = ref('[]') + const fileOnlyModulesText = ref('') const initForm = async () => { const res = await getSystemConfig() @@ -296,6 +318,8 @@ databaseListText.value = JSON.stringify(config.value.data.database_list || [], null, 2) redisListText.value = JSON.stringify(config.value.data.redis_list || [], null, 2) corsRulesText.value = JSON.stringify(config.value.admin.cors.whitelist || [], null, 2) + diskListText.value = JSON.stringify(config.value.admin.disk_list || [], null, 2) + fileOnlyModulesText.value = (config.value.admin.zap.file_only_modules || []).join('\n') } } @@ -308,8 +332,10 @@ config.value.data.database_list = JSON.parse(databaseListText.value || '[]') config.value.data.redis_list = JSON.parse(redisListText.value || '[]') config.value.admin.cors.whitelist = JSON.parse(corsRulesText.value || '[]') + config.value.admin.disk_list = JSON.parse(diskListText.value || '[]') + config.value.admin.zap.file_only_modules = fileOnlyModulesText.value.split('\n').map(item => item.trim()).filter(Boolean) } catch { - ElMessage.error('MongoDB 或多数据库 JSON 格式不正确') + ElMessage.error('JSON 配置格式不正确') return } const res = await setSystemConfig({ config: config.value }) @@ -322,8 +348,17 @@ } } + const generateSigningKey = () => { + config.value.admin.jwt.signingKey = CreateUUID() + } + const reload = async () => { - await ElMessageBox.confirm('确定要重载服务配置吗?', '提示') + try { + await ElMessageBox.confirm('确定要重载服务配置吗?', '提示') + } catch { + ElMessage.info('取消重载') + return + } const res = await reloadSystem() if (res.code === 0) ElMessage.success('配置已重载') } @@ -332,7 +367,12 @@ testing.value = true try { const res = await emailTest() - if (res.code === 0) ElMessage.success('测试邮件发送成功') + if (res.code === 0) { + ElMessage.success('测试邮件发送成功') + await initForm() + } else { + ElMessage.error('测试邮件发送失败') + } } finally { testing.value = false }