优化
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type Authority struct {
|
||||
DataScope int
|
||||
DefaultRouter string
|
||||
Children []*Authority
|
||||
Menus []*Menu
|
||||
}
|
||||
|
||||
type AuthorityUsecase struct{ AuthorityAccessRepo }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+10
-5
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+9
-24
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-28
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+57
-35
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
+106
-42
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+93
-46
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+40
-14
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
+14
-11
@@ -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{}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+56
-34
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+168
-5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+18
-15
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+20
-19
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
-42
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "登录成功")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+64
-19
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user