This commit is contained in:
2026-08-17 02:12:17 +08:00
parent 560ae46b82
commit 6a05803e08
96 changed files with 2888 additions and 685 deletions
+6 -6
View File
@@ -23,10 +23,10 @@ type AnnouncementFilter struct {
type AnnouncementRepo interface { type AnnouncementRepo interface {
Create(context.Context, *Announcement) error Create(context.Context, *Announcement) error
Delete(context.Context, uint) error Delete(context.Context, string) error
DeleteByIDs(context.Context, []uint) error DeleteByIDs(context.Context, []string) error
Update(context.Context, *Announcement) 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) List(context.Context, AnnouncementFilter) ([]*Announcement, int64, error)
UserOptions(context.Context) ([]UserOption, 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) 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) 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) 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) return uc.repo.Find(ctx, id)
} }
+7 -1
View File
@@ -79,7 +79,13 @@ func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff
for _, item := range stored { for _, item := range stored {
storedSet[key(item)] = item 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) added := make(map[string]bool)
for _, item := range routes { for _, item := range routes {
routeKey := key(item) routeKey := key(item)
+5 -8
View File
@@ -142,17 +142,14 @@ func (uc *AuthenticationUsecase) SwitchAuthority(ctx context.Context, claims *Au
if err := uc.users.SetUserAuthority(ctx, claims.ID, authorityID); err != nil { if err := uc.users.SetUserAuthority(ctx, claims.ID, authorityID); err != nil {
return nil, err return nil, err
} }
user, err := uc.users.User(ctx, claims.ID) // Role switching signs the current claims again after replacing only the
if err != nil { // authority ID. It must not reload mutable profile fields or reset JWT
return nil, err // timing/buffer claims as if this were a fresh login.
} issued, err := uc.issuer.ReissueToken(claims, authorityID)
// IssueToken receives the remaining lifetime rather than zero so the new
// token expires at the same instant as the token it replaced.
remaining := time.Until(claims.ExpiresAt)
issued, err := uc.issuer.IssueToken(user, authorityID, claims.MustChangePwd, remaining)
if err != nil { if err != nil {
return nil, err 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 return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: claims.MustChangePwd}, nil
} }
+60
View File
@@ -18,6 +18,10 @@ func (r *authenticationUserRepo) FindUserByUsername(context.Context, string) (*U
return r.user, nil return r.user, nil
} }
func (*authenticationUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) {
return true, nil
}
type authenticationSecurityRepo struct{ SecurityRepo } type authenticationSecurityRepo struct{ SecurityRepo }
func (*authenticationSecurityRepo) SecurityConfig(context.Context) (*SecurityConfig, error) { 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 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 { type authenticationAudit struct {
AuditRecordRepo AuditRecordRepo
logins []*LoginLog 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 { func (a *authenticationAudit) RecordLogin(_ context.Context, value *LoginLog) error {
a.logins = append(a.logins, value) a.logins = append(a.logins, value)
return nil return nil
@@ -77,3 +112,28 @@ func TestLoginRecordsSuccessBeforeMultipointCacheFailure(t *testing.T) {
t.Fatalf("expected successful login audit before cache failure, got %+v", audit.logins) 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)
}
}
+1
View File
@@ -31,6 +31,7 @@ type Authority struct {
DataScope int DataScope int
DefaultRouter string DefaultRouter string
Children []*Authority Children []*Authority
Menus []*Menu
} }
type AuthorityUsecase struct{ AuthorityAccessRepo } type AuthorityUsecase struct{ AuthorityAccessRepo }
+2 -34
View File
@@ -37,6 +37,7 @@ type DictionaryDetailRepo interface {
FindDictionaryDetail(context.Context, uint) (*DictionaryDetail, error) FindDictionaryDetail(context.Context, uint) (*DictionaryDetail, error)
ListDictionaryDetails(context.Context, int, int, DictionaryDetailFilter) ([]*DictionaryDetail, int64, error) ListDictionaryDetails(context.Context, int, int, DictionaryDetailFilter) ([]*DictionaryDetail, int64, error)
DictionaryDetailTree(context.Context, uint, string) ([]*DictionaryDetail, error) DictionaryDetailTree(context.Context, uint, string) ([]*DictionaryDetail, error)
DictionaryDetailsByParent(context.Context, uint, *uint, bool) ([]*DictionaryDetail, error)
} }
type Dictionary struct { 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) { 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}) return uc.DictionaryRepo.DictionaryDetailsByParent(ctx, dictionaryID, parentID, includeChildren)
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
} }
+3
View File
@@ -82,6 +82,8 @@ type AuthClaims struct {
BufferTime time.Duration BufferTime time.Duration
MustChangePwd bool MustChangePwd bool
Issuer string Issuer string
Audience []string
NotBefore time.Time
ExpiresAt time.Time ExpiresAt time.Time
} }
@@ -96,5 +98,6 @@ var (
type TokenIssuer interface { type TokenIssuer interface {
IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error) IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error)
ReissueToken(*AuthClaims, uint) (*IssuedToken, error)
ParseToken(string) (*AuthClaims, error) ParseToken(string) (*AuthClaims, error)
} }
+10 -5
View File
@@ -132,10 +132,11 @@ func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string,
if limit <= 0 { if limit <= 0 {
limit = 100 limit = 100
} }
// Multipart chunks are staged on the local filesystem, so listOssFiles // Multipart chunks are an internal staging detail. The compatible local
// never exposes them. Kra's storage abstraction may stage them through // implementation keeps them outside the configured object-storage root, so
// the selected backend; hide the internal prefix to preserve the API. // the public listing must not expose them even when the selected backend is
chunkRoot := ".chunks" // also used for staging.
chunkRoot := "uploads/chunks"
if uc.settings != nil { if uc.settings != nil {
if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" { if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" {
chunkRoot = configured chunkRoot = configured
@@ -145,7 +146,11 @@ func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string,
result := make([]*StoredFile, 0, limit) result := make([]*StoredFile, 0, limit)
nextCursor := cursor nextCursor := cursor
for { 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 { if err != nil {
return nil, "", false, err return nil, "", false, err
} }
+81
View File
@@ -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
View File
@@ -59,6 +59,11 @@ type MenuRepo interface {
List(context.Context) ([]*Menu, error) List(context.Context) ([]*Menu, error)
SetAuthorityMenus(context.Context, uint, []uint) error SetAuthorityMenus(context.Context, uint, []uint) error
AuthorityMenuIDs(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) MenuRoleIDs(context.Context, uint) ([]uint, error)
DefaultRouterRoleIDs(context.Context, uint) ([]uint, error) DefaultRouterRoleIDs(context.Context, uint) ([]uint, error)
SetMenuRoles(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)) byID := make(map[uint]*Menu, len(items))
for _, item := range items { for _, item := range items {
item.Children = []*Menu{} item.Children = nil
byID[item.ID] = item byID[item.ID] = item
} }
roots := make([]*Menu, 0) var roots []*Menu
for _, item := range items { for _, item := range items {
if parent := byID[item.ParentID]; parent != nil { if parent := byID[item.ParentID]; parent != nil {
parent.Children = append(parent.Children, item) 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) { 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 { if err != nil {
return nil, err return nil, err
} }
items, err := uc.repo.List(ctx) return items, nil
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
} }
func (uc *MenuUsecase) RoleIDs(ctx context.Context, menuID uint) ([]uint, error) { func (uc *MenuUsecase) RoleIDs(ctx context.Context, menuID uint) ([]uint, error) {
+3 -2
View File
@@ -20,8 +20,9 @@ type SystemParameter struct {
type ParameterRepo interface { type ParameterRepo interface {
CreateParameter(context.Context, *SystemParameter) error CreateParameter(context.Context, *SystemParameter) error
UpdateParameter(context.Context, *SystemParameter) error UpdateParameter(context.Context, *SystemParameter) error
DeleteParameters(context.Context, []uint) error DeleteParameters(context.Context, []string) error
FindParameter(context.Context, uint, string) (*SystemParameter, error) FindParameterByID(context.Context, string) (*SystemParameter, error)
FindParameterByKey(context.Context, string) (*SystemParameter, error)
ListParameters(context.Context, int, int, *SystemParameter) ([]*SystemParameter, int64, error) ListParameters(context.Context, int, int, *SystemParameter) ([]*SystemParameter, int64, error)
} }
+1 -1
View File
@@ -8,7 +8,7 @@ type PermissionRepo interface {
AuthorityButtonIDs(context.Context, uint) ([]uint, error) AuthorityButtonIDs(context.Context, uint) ([]uint, error)
SelectedButtons(context.Context, uint, uint) ([]uint, error) SelectedButtons(context.Context, uint, uint) ([]uint, error)
SetSelectedButtons(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 } type PermissionUsecase struct{ PermissionRepo }
+8 -1
View File
@@ -99,7 +99,7 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
uc.mu.RUnlock() uc.mu.RUnlock()
value, err := uc.repo.SecurityConfig(ctx) value, err := uc.repo.SecurityConfig(ctx)
if err != nil { 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 // error. Callers such as login/runtime policy consumers intentionally
// ignore the error and continue with that default, while the HTTP // ignore the error and continue with that default, while the HTTP
// settings endpoint still reports the failure. // settings endpoint still reports the failure.
@@ -115,6 +115,13 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
return value, nil 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 { func (uc *SecurityUsecase) ValidatePassword(value *SecurityConfig, password string) error {
if value.PwdMinLength > 0 && utf8.RuneCountInString(password) < value.PwdMinLength { if value.PwdMinLength > 0 && utf8.RuneCountInString(password) < value.PwdMinLength {
return fmt.Errorf("密码长度不能少于 %d 位", value.PwdMinLength) return fmt.Errorf("密码长度不能少于 %d 位", value.PwdMinLength)
+29
View File
@@ -81,3 +81,32 @@ func TestUpdateSecurityDoesNotBackfillWithoutDisabledToEnabledTransition(t *test
t.Fatalf("call order = %v, want [get save]", got) 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
View File
@@ -38,6 +38,8 @@ type UserListFilter struct {
type UserRepo interface { type UserRepo interface {
FindUserByUsername(context.Context, string) (*User, error) FindUserByUsername(context.Context, string) (*User, error)
FindUserByID(context.Context, uint) (*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) MenusByAuthority(context.Context, uint) ([]*Menu, error)
ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error) ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error)
CreateUser(context.Context, *User) (*User, 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 return user, nil
} }
func (uc *UserUsecase) fallbackDefaultRouter(ctx context.Context, user *User) { // UserByUUID resolves the identity from the UUID embedded in the JWT rather
if user == nil || user.Authority.DefaultRouter == "" { // than from the mutable numeric ID used by administrative records.
return func (uc *UserUsecase) UserByUUID(ctx context.Context, uuid string) (*User, error) {
} user, err := uc.repo.FindUserByUUID(ctx, uuid)
menus, err := uc.repo.MenusByAuthority(ctx, user.AuthorityID) if err != nil {
if err != nil || !menuNameExists(menus, user.Authority.DefaultRouter) { return nil, err
user.Authority.DefaultRouter = "404"
} }
_ = uc.repo.FillDepartmentNamePaths(ctx, user)
uc.fallbackDefaultRouter(ctx, user)
return user, nil
} }
func menuNameExists(menus []*Menu, name string) bool { func (uc *UserUsecase) fallbackDefaultRouter(ctx context.Context, user *User) {
for _, menu := range menus { if user == nil {
if menu.Name == name || menuNameExists(menu.Children, name) { return
return true }
} 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) { 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) return uc.repo.ListUsers(ctx, page, pageSize, filter)
} }
func (uc *UserUsecase) CreateUser(ctx context.Context, user *User, authorityIDs []uint) (*User, error) { 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) hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -132,17 +136,6 @@ func (uc *UserUsecase) UpdateSelfUser(ctx context.Context, user *User) error {
return uc.repo.UpdateSelfUser(ctx, user) 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 { func (uc *UserUsecase) DeleteUser(ctx context.Context, id uint) error {
return uc.repo.DeleteUser(ctx, id) return uc.repo.DeleteUser(ctx, id)
} }
+37
View File
@@ -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)
}
}
+4 -4
View File
@@ -30,10 +30,10 @@ type Version struct {
ID uint ID uint
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
Name string Name *string
Code string Code *string
Description string Description *string
Data string Data *string
} }
type VersionBundle struct { type VersionBundle struct {
Name string Name string
+6 -6
View File
@@ -44,12 +44,12 @@ func (r *announcementRepo) Create(ctx context.Context, item *biz.Announcement) e
return nil return nil
} }
func (r *announcementRepo) Delete(ctx context.Context, id uint) error { func (r *announcementRepo) Delete(ctx context.Context, id string) error {
return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, id).Error return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, "id = ?", id).Error
} }
func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []uint) error { func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []string) error {
return r.data.gormDB.WithContext(ctx).Where("id IN ?", ids).Delete(&announcementPO{}).Error return r.data.gormDB.WithContext(ctx).Delete(&[]announcementPO{}, "id IN ?", ids).Error
} }
func (r *announcementRepo) Update(ctx context.Context, item *biz.Announcement) 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 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 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 nil, err
} }
return announcementToBiz(po), nil return announcementToBiz(po), nil
+40
View File
@@ -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
View File
@@ -11,6 +11,7 @@ import (
"github.com/casbin/casbin/v3" "github.com/casbin/casbin/v3"
casbinmodel "github.com/casbin/casbin/v3/model" casbinmodel "github.com/casbin/casbin/v3/model"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type apiRepo struct{ data *Data } 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 var pos []apiPO
maxSize := 100 query := db.Order(order)
if q != nil && q.StrictAll { if q == nil || !q.StrictAll {
maxSize = 0 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 return nil, 0, err
} }
out := make([]*biz.API, 0, len(pos)) 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 { if err != nil {
return nil, err 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 { for _, row := range rows {
out = append(out, &biz.API{Path: row.V1, Method: row.V2}) 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 { if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil {
return err return err
} }
if authority.ParentID != nil && *authority.ParentID != 0 { var registered []apiPO
allowed, err := r.PolicyPaths(ctx, actor.AuthorityID) if err := r.data.gormDB.WithContext(ctx).Find(&registered).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 { if err != nil {
return err return err
} }
allowedSet := make(map[string]bool, len(allowed)) policySet := make(map[string]bool, len(policies))
for _, item := range allowed { for _, item := range policies {
allowedSet[item.Path+"\x00"+item.Method] = true policySet[item.V1+"\x00"+item.V2] = true
} }
for _, item := range paths { for _, item := range registered {
if !allowedSet[item.Path+"\x00"+item.Method] { key := item.Path + "\x00" + item.Method
return errors.New("存在api不在权限列表中") if policySet[key] {
allowedSet[key] = true
} }
} }
} }
} for _, item := range paths {
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if !allowedSet[item.Path+"\x00"+item.Method] {
if err := deletePoliciesForAuthority(tx, aid); err != nil { return errors.New("存在api不在权限列表中")
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
} }
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) { func (r *apiRepo) IgnoredAPIs(ctx context.Context) ([]*biz.API, error) {
+114
View File
@@ -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)
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID
return nil, 0, err return nil, 0, err
} }
var pos []apiTokenPO 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 return nil, 0, err
} }
userIDs := make([]uint, 0, len(pos)) userIDs := make([]uint, 0, len(pos))
+106 -42
View File
@@ -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 { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var count int64 // The administration contract checks existence with First and treats every result other than
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil { // ErrRecordNotFound as a duplicate-role error. Keep that precedence so
return err // duplicate IDs win over strict-tree validation and the API message stays
} // compatible.
if count > 0 { var existing authorityPO
if err := tx.Where("authority_id = ?", value.AuthorityID).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("存在相同角色id") return errors.New("存在相同角色id")
} }
if value.DefaultRouter == "" { if value.DefaultRouter == "" {
@@ -92,6 +93,8 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au
return err return err
} }
value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt 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 var dashboard menuPO
if err := tx.Where("name = ?", "dashboard").First(&dashboard).Error; err == nil { 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 { 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 { return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
config := r.data.runtime.Admin() config := r.data.runtime.Admin()
actor, hasActor := biz.ActorFromContext(ctx) actor, hasActor := biz.ActorFromContext(ctx)
// Strict-authority checks apply to every actor. A root actor is // Reject a duplicate target ID before performing the
// allowed to create/copy a role directly below itself; non-root actors // hierarchy/Casbin checks that happen later in UpdateCasbin.
// may target themselves' descendants. Do not special-case 888 here: var existing authorityPO
// the configured strict-auth switch is the source of truth. 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 strict := hasActor && config != nil && config.System != nil && config.System.UseStrictAuth
if strict { if strict {
allowed, err := r.strictAuthorityIDs(ctx, actor.AuthorityID) allowed, err := r.strictAuthorityIDs(ctx, actor.AuthorityID)
@@ -128,18 +139,12 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
return errors.New("您提交的角色ID不合法") 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} 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 { if err := tx.Create(&po).Error; err != nil {
return err return err
} }
value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt 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 { copyLinks := func(table string, destination any, columns map[string]any) error {
return tx.Table(table).Where(columns).Find(destination).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 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) apis, err := policyRowsForAuthority(tx, sourceID)
if err != nil { if err != nil {
return err return err
@@ -163,7 +182,7 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
seenPolicies := make(map[string]struct{}, len(apis)) seenPolicies := make(map[string]struct{}, len(apis))
for _, api := range apis { for _, api := range apis {
if strict { 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 { if err != nil {
return err return err
} }
@@ -198,6 +217,28 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
return nil 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 { func (r *authorityAccessRepo) UpdateAuthority(ctx context.Context, value *biz.Authority) error {
db := r.data.gormDB.WithContext(ctx) db := r.data.gormDB.WithContext(ctx)
var current authorityPO var current authorityPO
@@ -217,11 +258,21 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
return err return err
} }
var users, children int64 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 return err
} }
if users > 0 { if len(linkedUserIDs) > 0 {
return errors.New("此角色有用户正在使用禁止删除") 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 { if err := tx.Model(&userPO{}).Where("authority_id = ?", id).Count(&users).Error; err != nil {
return err return err
@@ -235,6 +286,9 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
if children > 0 { if children > 0 {
return errors.New("此角色存在子角色不允许删除") 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 { if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil {
return err 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) { func (r *authorityAccessRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) {
var pos []authorityPO db := r.data.gormDB.WithContext(ctx)
if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil {
return nil, err
}
var allowed map[uint]bool var allowed map[uint]bool
config := r.data.runtime.Admin() config := r.data.runtime.Admin()
if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { if actor, ok := biz.ActorFromContext(ctx); ok {
var err error // The current authority is loaded even when strict mode is disabled;
allowed, err = r.strictAuthorityIDs(ctx, actor.AuthorityID) // an invalid token authority therefore fails the list request instead of
if err != nil { // exposing the complete role tree.
var current authorityPO
if err := db.Where("authority_id = ?", actor.AuthorityID).First(&current).Error; err != nil {
return nil, err 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)) out := make([]*biz.Authority, 0, len(pos))
for _, po := range 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 { if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil {
return err 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{} selected := map[uint]bool{}
for _, uid := range ids { for _, uid := range ids {
selected[uid] = true selected[uid] = true
@@ -305,16 +360,25 @@ func (r *authorityAccessRepo) SetAuthorityUsers(ctx context.Context, id uint, id
var user userPO var user userPO
if err := tx.First(&user, uid).Error; err == nil && user.AuthorityID == id { if err := tx.First(&user, uid).Error; err == nil && user.AuthorityID == id {
var another userAuthorityPO 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 continue
} else if err != nil {
return err
} }
if err = tx.Model(&user).Update("authority_id", another.SysAuthorityAuthorityID).Error; err != nil { if err = tx.Model(&user).Update("authority_id", another.SysAuthorityAuthorityID).Error; err != nil {
return err 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 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 { if err := tx.Model(&authorityPO{}).Where("authority_id = ?", id).Update("data_scope", scope).Error; err != nil {
return err 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 return err
} }
if scope == 5 { 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) { func (r *authorityAccessRepo) DataScopeDepartmentIDs(ctx context.Context, id uint) ([]uint, error) {
var ids []uint 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 return ids, err
} }
func (r *authorityAccessRepo) ResolveDataScope(ctx context.Context, authorityID, userID uint) (biz.DataScope, error) { func (r *authorityAccessRepo) ResolveDataScope(ctx context.Context, authorityID, userID uint) (biz.DataScope, error) {
+179
View File
@@ -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)
}
}
+11 -5
View File
@@ -172,11 +172,12 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
return writeConfigDocument(configPath, &document) return writeConfigDocument(configPath, &document)
} }
// persistDatabaseConfig writes only the database selected on the init page. // persistDatabaseConfig writes only the database selected on the init page and
// Initialization must not serialize the partially populated runtime config // the freshly generated JWT signing key. Initialization must not serialize the
// back over the template: doing so removes all omitted/default settings from // partially populated runtime config back over the template: doing so removes
// data and admin and leaves the next startup with no visible configuration. // all omitted/default settings from data and admin and leaves the next startup
func (d *Data) persistDatabaseConfig(database *conf.Data_Database) error { // with no visible configuration.
func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey string) error {
d.configMu.Lock() d.configMu.Lock()
defer d.configMu.Unlock() 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 { if err = setYAMLMapping(&document, "data", map[string]any{"database": databaseValue}); err != nil {
return err 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) return writeConfigDocument(configPath, &document)
} }
+2 -2
View File
@@ -96,8 +96,8 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
c = &conf.Data{} c = &conf.Data{}
} }
if c.Database == nil { if c.Database == nil {
// An empty database block is the Kratos equivalent of GVA's empty // An empty database block starts the service on the bootstrap database
// Dbname: the service starts on the bootstrap database so /init/checkdb // so /init/checkdb
// and /init/initdb remain available. // and /init/initdb remain available.
c.Database = &conf.Data_Database{} c.Database = &conf.Data_Database{}
} }
+1 -1
View File
@@ -43,7 +43,7 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *
return nil, 0, err return nil, 0, err
} }
var pos []dataAccessLogPO 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 return nil, 0, err
} }
out := make([]*biz.DataAccessLog, 0, len(pos)) out := make([]*biz.DataAccessLog, 0, len(pos))
+13 -5
View File
@@ -32,15 +32,17 @@ type departmentPO struct {
func (departmentPO) TableName() string { return "sys_departments" } func (departmentPO) TableName() string { return "sys_departments" }
type userDepartmentPO struct { type userDepartmentPO struct {
UserID uint `gorm:"primaryKey;column:sys_user_id"` // Match GVA's join model: no composite primary key, so repeated IDs in a
DepartmentID uint `gorm:"primaryKey;column:sys_department_id"` // 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" } func (userDepartmentPO) TableName() string { return "sys_user_departments" }
type authorityDepartmentPO struct { type authorityDepartmentPO struct {
AuthorityID uint `gorm:"primaryKey;column:authority_id"` AuthorityID uint `gorm:"column:sys_authority_authority_id;index"`
DepartmentID uint `gorm:"primaryKey;column:department_id"` DepartmentID uint `gorm:"column:sys_department_id"`
} }
func (authorityDepartmentPO) TableName() string { return "sys_authority_departments" } 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)) items := make([]*biz.Department, 0, len(pos))
for _, po := range pos { for _, po := range pos {
item := deptFromPO(po) 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 nodes[po.ID] = item
items = append(items, item) items = append(items, item)
} }
@@ -161,7 +169,7 @@ func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*b
if name != "" { if name != "" {
return items, nil return items, nil
} }
var roots []*biz.Department roots := make([]*biz.Department, 0)
for _, po := range pos { for _, po := range pos {
n := nodes[po.ID] n := nodes[po.ID]
if p := nodes[po.ParentID]; p != nil { if p := nodes[po.ParentID]; p != nil {
+93 -46
View File
@@ -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 { 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 { 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} 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 { 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) out := dictionaryFromPO(po)
if details { if details {
out.Details, _, err = r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: out.ID}) var detailPOs []dictionaryDetailPO
if err == nil { if err = db.Where("sys_dictionary_id = ? AND status = ?", out.ID, true).Order("sort").Find(&detailPOs).Error; err != nil {
filtered := out.Details[:0] return nil, err
for _, item := range out.Details { }
if item.Status != nil && *item.Status { out.Details = make([]*biz.DictionaryDetail, 0, len(detailPOs))
filtered = append(filtered, item) for _, detail := range detailPOs {
} out.Details = append(out.Details, detailFromPO(detail))
}
out.Details = filtered
} }
} }
return out, err return out, err
@@ -203,11 +205,14 @@ func (r *dictionaryRepo) ExportDictionary(ctx context.Context, id uint) (*biz.Di
return nil, err return nil, err
} }
value := dictionaryFromPO(po) value := dictionaryFromPO(po)
details, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: id}) var detailPOs []dictionaryDetailPO
if err != nil { if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", id).Order("sort").Find(&detailPOs).Error; err != nil {
return nil, err 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 return value, nil
} }
func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, name, typ string, details bool) ([]*biz.Dictionary, int64, error) { 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 return nil, 0, err
} }
var pos []dictionaryPO 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 return nil, 0, err
} }
parentIDs := make([]uint, 0, len(pos)) parentIDs := make([]uint, 0, len(pos))
dictionaryIDs := make([]uint, 0, len(pos)) dictionaryIDs := make([]uint, 0, len(pos))
for _, po := range pos { for _, po := range pos {
parentIDs = append(parentIDs, po.ID)
dictionaryIDs = append(dictionaryIDs, 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) childrenByParent := make(map[uint][]dictionaryPO)
if len(parentIDs) > 0 { if !details && len(parentIDs) > 0 {
var children []dictionaryPO 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 return nil, 0, err
} }
for _, child := range children { 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)) out := make([]*biz.Dictionary, 0, len(pos))
for _, po := range pos { for _, po := range pos {
v := dictionaryFromPO(po) v := dictionaryFromPO(po)
for _, child := range childrenByParent[po.ID] {
v.Children = append(v.Children, dictionaryFromPO(child))
}
if details { if details {
v.Details = make([]*biz.DictionaryDetail, 0, len(detailsByDictionary[v.ID]))
for _, detail := range detailsByDictionary[v.ID] { for _, detail := range detailsByDictionary[v.ID] {
v.Details = append(v.Details, detailFromPO(detail)) 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) out = append(out, v)
} }
@@ -309,28 +324,25 @@ func (r *dictionaryRepo) UpdateDictionaryDetail(ctx context.Context, v *biz.Dict
level = parent.Level + 1 level = parent.Level + 1
path = strings.Trim(strings.Join([]string{parent.Path, fmt.Sprint(parent.ID)}, ","), ",") path = strings.Trim(strings.Join([]string{parent.Path, fmt.Sprint(parent.ID)}, ","), ",")
} }
return db.Transaction(func(tx *gorm.DB) error { 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 {
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 err }
} return r.updateDictionaryDetailChildren(db, v.ID, level, path)
return r.updateDictionaryDetailChildren(tx, v.ID, level, path)
})
} }
// updateDictionaryDetailChildren recomputes descendants after a move, as the // updateDictionaryDetailChildren recomputes descendants after a move. Updating
// The service does this recursively. Updating level as well as path matters when a // level as well as path matters when a subtree is moved beneath a different depth.
// subtree is moved beneath a different depth. func (r *dictionaryRepo) updateDictionaryDetailChildren(db *gorm.DB, parentID uint, parentLevel int, parentPath string) error {
func (r *dictionaryRepo) updateDictionaryDetailChildren(tx *gorm.DB, parentID uint, parentLevel int, parentPath string) error {
var children []dictionaryDetailPO 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 return err
} }
for _, child := range children { for _, child := range children {
childPath := strings.Trim(strings.Join([]string{parentPath, fmt.Sprint(parentID)}, ","), ",") 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 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 return err
} }
} }
@@ -378,7 +390,7 @@ func (r *dictionaryRepo) ListDictionaryDetails(ctx context.Context, page, size i
return nil, 0, err return nil, 0, err
} }
var pos []dictionaryDetailPO 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 return nil, 0, err
} }
out := make([]*biz.DictionaryDetail, 0, len(pos)) out := make([]*biz.DictionaryDetail, 0, len(pos))
@@ -398,22 +410,57 @@ func (r *dictionaryRepo) DictionaryDetailTree(ctx context.Context, dictionaryID
} }
dictionaryID = dictionary.ID dictionaryID = dictionary.ID
} }
items, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: dictionaryID}) var pos []dictionaryDetailPO
if err != nil { 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 return nil, err
} }
byID := map[uint]*biz.DictionaryDetail{} roots := make([]*biz.DictionaryDetail, 0, len(pos))
for _, item := range items { for _, po := range pos {
item.Children = []*biz.DictionaryDetail{} item := detailFromPO(po)
byID[item.ID] = item if err := r.loadDictionaryDetailChildren(ctx, item); err != nil {
} return nil, err
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 = append(roots, item)
} }
return roots, nil 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)
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ func errorFromPO(v errorRecordPO) *biz.ErrorRecord {
} }
func (r *auditRecorderRepo) CreateError(ctx context.Context, v *biz.ErrorRecord) error { func (r *auditRecorderRepo) CreateError(ctx context.Context, v *biz.ErrorRecord) error {
if !r.data.databaseReady.Load() { 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 return nil
} }
if v.Status == "" { if v.Status == "" {
+140
View File
@@ -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
View File
@@ -62,9 +62,15 @@ type exportRepo struct{ data *Data }
func NewExportRepo(data *Data) biz.ExportRepo { return &exportRepo{data: data} } func NewExportRepo(data *Data) biz.ExportRepo { return &exportRepo{data: data} }
func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []exportJoinPO) *biz.ExportTemplate { 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} 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 { 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}) 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 { 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}) 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 { 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} 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 { func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs, replace, forceTemplateID bool) error {
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil { if replace {
return err 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 if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportJoinPO{}).Error; err != nil {
return err
}
} }
conditions := make([]exportConditionPO, 0, len(v.Conditions)) conditions := make([]exportConditionPO, 0, len(v.Conditions))
for _, x := range v.Conditions { for _, x := range v.Conditions {
@@ -86,7 +94,16 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
if resetIDs { if resetIDs {
id = 0 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 { if len(conditions) > 0 {
create := tx create := tx
@@ -105,7 +122,11 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
if resetIDs { if resetIDs {
id = 0 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 { if len(joins) > 0 {
create := tx create := tx
@@ -123,7 +144,7 @@ func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *biz.ExportTemp
return err return err
} }
v.ID = po.ID 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 { 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 { if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil {
return err 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 { 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 var po exportTemplatePO
db := r.data.gormDB.WithContext(ctx) db := r.data.gormDB.WithContext(ctx)
var err error var err error
if id != 0 { if tid != "" {
err = db.First(&po, id).Error
} else {
err = db.Where("template_id = ?", tid).First(&po).Error err = db.Where("template_id = ?", tid).First(&po).Error
} else {
err = db.Where("id = ?", id).First(&po).Error
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -181,7 +202,12 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q
return nil, 0, err return nil, 0, err
} }
var pos []exportTemplatePO 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 return nil, 0, err
} }
out := make([]*biz.ExportTemplate, 0, len(pos)) out := make([]*biz.ExportTemplate, 0, len(pos))
+2 -2
View File
@@ -25,7 +25,7 @@ func (loginLogPO) TableName() string { return "sys_login_logs" }
func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *biz.LoginLog) error { func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *biz.LoginLog) error {
if !r.data.databaseReady.Load() { if !r.data.databaseReady.Load() {
// The login endpoint remains reachable before database initialization; // 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 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 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 return nil, 0, err
} }
var pos []loginLogPO 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 return nil, 0, err
} }
ids := make([]uint, 0, len(pos)) ids := make([]uint, 0, len(pos))
+14 -11
View File
@@ -73,12 +73,6 @@ func (r *mediaRepo) FindMediaByHash(ctx context.Context, userID uint, hash strin
return mediaFromPO(po), nil return mediaFromPO(po), nil
} }
func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*biz.MediaFile, int64, error) { 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{}) db := r.data.gormDB.WithContext(ctx).Model(&mediaPO{})
if filter.Keyword != "" { if filter.Keyword != "" {
db = db.Where("name LIKE ?", "%"+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 { if filter.Desc {
orderKey += " 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 return nil, 0, err
} }
out := make([]*biz.MediaFile, 0, len(pos)) 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 { for _, v := range items {
pos = append(pos, mediaToPO(v)) pos = append(pos, mediaToPO(v))
} }
if len(pos) == 0 {
return nil
}
return r.data.gormDB.WithContext(ctx).Create(&pos).Error return r.data.gormDB.WithContext(ctx).Create(&pos).Error
} }
func (r *mediaRepo) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) 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) { func (r *mediaRepo) ListCategories(ctx context.Context) ([]*biz.AttachmentCategory, error) {
var pos []categoryPO 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 return nil, err
} }
byID := map[uint]*biz.AttachmentCategory{} byID := map[uint]*biz.AttachmentCategory{}
+33
View File
@@ -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
View File
@@ -24,35 +24,35 @@ func menuFromPO(po menuPO) *biz.Menu {
} }
func menuToPO(v *biz.Menu) menuPO { 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 { 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 { if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuParameterPO{}).Error; err != nil {
return err return err
} }
buttons := make([]menuButtonPO, 0, len(menu.Buttons)) if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuButtonPO{}).Error; err != nil {
for _, button := range menu.Buttons { return err
buttons = append(buttons, menuButtonPO{Name: button.Name, Description: button.Description, MenuID: menu.ID})
} }
if len(buttons) > 0 { return createMenuRelations(tx, menu)
if err := tx.Create(&buttons).Error; err != nil { }
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(&parameters).Error; err != nil {
return err return err
} }
} }
parameters := make([]menuParameterPO, 0, len(menu.Parameters)) buttons := make([]menuButtonPO, 0, len(menu.Buttons))
for _, parameter := range menu.Parameters { for _, button := range menu.Buttons {
parameters = append(parameters, menuParameterPO{MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value}) 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 { if len(buttons) > 0 {
return tx.Create(&parameters).Error return tx.Create(&buttons).Error
} }
return nil return nil
} }
@@ -94,7 +94,7 @@ func (r *menuRepo) Create(ctx context.Context, v *biz.Menu) error {
return err return err
} }
v.ID = po.ID 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 { if count > 0 {
return errors.New("此菜单有角色正在作为首页,不可删除") return errors.New("此菜单有角色正在作为首页,不可删除")
} }
var buttonIDs []uint if err := tx.Delete(&menuPO{}, id).Error; err != nil {
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 {
return err return err
} }
if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuParameterPO{}).Error; err != nil { if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuParameterPO{}).Error; err != nil {
return err 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 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 { func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error {
var parameters []menuParameterPO var parameters []menuParameterPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Order("id").Find(&parameters).Error; err != nil { if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(&parameters).Error; err != nil {
return err return err
} }
menu.Parameters = make([]*biz.MenuParameter, 0, len(parameters)) 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)) menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter))
} }
var buttons []menuButtonPO 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 return err
} }
menu.Buttons = make([]*biz.MenuButton, 0, len(buttons)) 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) { func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) {
var pos []menuPO 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() config := r.data.runtime.Admin()
if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth {
var authority authorityPO var authority authorityPO
@@ -224,7 +220,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) {
menu.Buttons = []*biz.MenuButton{} menu.Buttons = []*biz.MenuButton{}
} }
var parameters []menuParameterPO var parameters []menuParameterPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Order("id").Find(&parameters).Error; err != nil { if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(&parameters).Error; err != nil {
return nil, err return nil, err
} }
for _, parameter := range parameters { 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)) menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter))
} }
var buttons []menuButtonPO 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 return nil, err
} }
for _, button := range buttons { 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 { if err := (&authorityAccessRepo{data: r.data}).checkAuthorityIDAuth(ctx, id); err != nil {
return err 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() config := r.data.runtime.Admin()
if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth {
var authority authorityPO var authority authorityPO
@@ -307,6 +306,29 @@ func (r *menuRepo) AuthorityMenuIDs(ctx context.Context, id uint) ([]uint, error
return ids, err 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) { func (r *menuRepo) MenuRoleIDs(ctx context.Context, id uint) ([]uint, error) {
var ids []uint var ids []uint
err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_base_menu_id = ?", id).Pluck("sys_authority_authority_id", &ids).Error err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_base_menu_id = ?", id).Pluck("sys_authority_authority_id", &ids).Error
+141
View File
@@ -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(&parameter, 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
View File
@@ -2,6 +2,7 @@ package data
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -11,6 +12,9 @@ func migrateAll(db *gorm.DB) error {
if err := migrateLegacyIgnoreAPITable(db); err != nil { if err := migrateLegacyIgnoreAPITable(db); err != nil {
return err return err
} }
if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil {
return err
}
if err := db.AutoMigrate( if err := db.AutoMigrate(
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{}, &userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{}, &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
@@ -35,6 +39,134 @@ func migrateAll(db *gorm.DB) error {
return reconcileReferenceIndexes(db) 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 // Older builds used a status label outside the administration page's supported
// state set, so normalize existing rows during migration. // state set, so normalize existing rows during migration.
func normalizeErrorRecordStatuses(db *gorm.DB) error { func normalizeErrorRecordStatuses(db *gorm.DB) error {
@@ -55,18 +187,32 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
Path string Path string
Method string Method string
} }
query := clean.Table("sys_authority_apis sa"). baseQuery := func() *gorm.DB {
Select("sa.authority_id, a.path, a.method"). return clean.Table("sys_authority_apis sa").
Joins("JOIN sys_apis a ON a.id = sa.api_id") 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 // Early Kra schemas stored sys_apis without soft-delete timestamps. The
// legacy-policy migration must run before assuming that column exists; // legacy-policy migration must run before assuming that column exists;
// otherwise an upgrade from those schemas cannot start on MySQL. // 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") query = query.Where("a.deleted_at IS NULL")
} }
var rows []legacyPolicy var rows []legacyPolicy
if err := query.Find(&rows).Error; err != nil { 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 { for _, row := range rows {
exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method) exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method)
@@ -83,6 +229,23 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
return nil 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 // migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key
// shape (path, method) to the compatible model shape (ID/timestamps/soft // shape (path, method) to the compatible model shape (ID/timestamps/soft
// delete). AutoMigrate can add columns but cannot replace an existing // delete). AutoMigrate can add columns but cannot replace an existing
+57
View File
@@ -98,3 +98,60 @@ func TestReconcileRootAuthorityAPIsUsesIndependentQueries(t *testing.T) {
t.Fatalf("root rules = %#v, want only GET /allowed", rules) 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)
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q *
return nil, 0, err return nil, 0, err
} }
var pos []operationPO 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 return nil, 0, err
} }
users := auditUsers(ctx, r.data.gormDB.WithContext(ctx), operationUserIDs(pos)) users := auditUsers(ctx, r.data.gormDB.WithContext(ctx), operationUserIDs(pos))
+51
View File
@@ -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)
}
}
+20 -6
View File
@@ -2,15 +2,29 @@ package data
import "gorm.io/gorm" import "gorm.io/gorm"
func applyPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB { func paginationLimitOffset(page, size, maxSize int) (limit, offset int) {
if size <= 0 { limit = size
return db
}
if maxSize > 0 && size > maxSize { if maxSize > 0 && size > maxSize {
size = maxSize limit = maxSize
}
if limit <= 0 {
return 0, 0
} }
if page <= 0 { if page <= 0 {
page = 1 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
View File
@@ -28,31 +28,34 @@ type parameterPO struct {
func (parameterPO) TableName() string { return "sys_params" } func (parameterPO) TableName() string { return "sys_params" }
func (r *parameterRepo) CreateParameter(ctx context.Context, v *biz.SystemParameter) error { 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 { if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil {
return err return err
} }
v.ID = po.ID v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
return nil return nil
} }
func (r *parameterRepo) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error { func (r *parameterRepo) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error {
// The compatible update uses a struct, so zero-value optional fields (notably desc) // The compatible update uses a struct, so zero-value optional fields (notably desc)
// are intentionally ignored rather than clearing an existing value. // are intentionally ignored rather than clearing an existing value.
return r.data.gormDB.WithContext(ctx).Model(&parameterPO{}).Where("id = ?", v.ID).Updates(&parameterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}).Error return r.data.gormDB.WithContext(ctx).Model(&parameterPO{}).Where("id = ?", v.ID).Updates(&parameterPO{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 { func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []string) error {
return r.data.gormDB.WithContext(ctx).Delete(&parameterPO{}, ids).Error if len(ids) == 1 {
} return r.data.gormDB.WithContext(ctx).Delete(&parameterPO{}, "id = ?", ids[0]).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
} }
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 nil, err
} }
return parameterFromPO(po), nil return parameterFromPO(po), nil
+68
View File
@@ -0,0 +1,68 @@
package data
import (
"context"
"testing"
"time"
"kra/internal/biz"
)
func TestParameterRepositoryPreservesModelMetadata(t *testing.T) {
data := newTransactionTestData(t)
repo := &parameterRepo{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 := &parameterRepo{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(&parameterPO{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("empty bulk delete removed rows: count=%d", count)
}
}
+22 -9
View File
@@ -2,6 +2,7 @@ package data
import ( import (
"context" "context"
"errors"
"time" "time"
"kra/internal/biz" "kra/internal/biz"
@@ -26,9 +27,9 @@ type menuButtonPO struct {
func (menuButtonPO) TableName() string { return "sys_base_menu_btns" } func (menuButtonPO) TableName() string { return "sys_base_menu_btns" }
type authorityButtonPO struct { type authorityButtonPO struct {
AuthorityID uint `gorm:"primaryKey;column:authority_id"` AuthorityID uint `gorm:"column:authority_id"`
MenuID uint `gorm:"primaryKey;column:sys_menu_id"` MenuID uint `gorm:"column:sys_menu_id"`
ButtonID uint `gorm:"primaryKey;column:sys_base_menu_btn_id"` ButtonID uint `gorm:"column:sys_base_menu_btn_id"`
} }
func (authorityButtonPO) TableName() string { return "sys_authority_btns" } 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) { 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 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 for _, row := range rows {
return ids, err ids = append(ids, row.ButtonID)
}
return ids, nil
} }
func (r *permissionRepo) SetSelectedButtons(ctx context.Context, aid, menuID uint, ids []uint) error { 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 { 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 return nil
}) })
} }
func (r *permissionRepo) CanRemoveButton(ctx context.Context, id uint) (bool, error) { func (r *permissionRepo) CanRemoveButton(ctx context.Context, id string) (bool, error) {
var count int64 var row authorityButtonPO
err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("sys_base_menu_btn_id = ?", id).Count(&count).Error err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_btn_id = ?", id).First(&row).Error
return count == 0, err 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) { func (r *permissionRepo) AuthorityButtonIDs(ctx context.Context, aid uint) ([]uint, error) {
var ids []uint var ids []uint
+83
View File
@@ -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)
}
}
+8 -3
View File
@@ -29,8 +29,10 @@ type positionPO struct {
func (positionPO) TableName() string { return "sys_positions" } func (positionPO) TableName() string { return "sys_positions" }
type userPositionPO struct { type userPositionPO struct {
UserID uint `gorm:"primaryKey;column:sys_user_id"` // GVA does not declare a key on this explicit join model. Do not let a
PositionID uint `gorm:"primaryKey;column:sys_position_id"` // 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" } 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 return nil, 0, err
} }
var pos []positionPO 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 return nil, 0, err
} }
out := make([]*biz.Position, 0, len(pos)) out := make([]*biz.Position, 0, len(pos))
+33 -1
View File
@@ -7,6 +7,8 @@ import (
"kra/internal/biz" "kra/internal/biz"
"kra/internal/conf" "kra/internal/conf"
"kra/pkg/adminauth" "kra/pkg/adminauth"
jwt "github.com/golang-jwt/jwt/v5"
) )
type runtimeSettings struct{ runtime *conf.Runtime } 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 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) { func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) {
claims, err := adminauth.Parse(token, i.settings.JWTSettings().SigningKey) claims, err := adminauth.Parse(token, i.settings.JWTSettings().SigningKey)
if err != nil { if err != nil {
@@ -110,5 +140,7 @@ func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) {
return nil, biz.ErrTokenInvalid 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
View File
@@ -19,25 +19,25 @@ type securityConfigPO struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
CaptchaOpen int CaptchaOpen int `gorm:"default:0"`
CaptchaTimeout int CaptchaTimeout int `gorm:"default:3600"`
KeyLong int KeyLong int `gorm:"default:6"`
ImgWidth int ImgWidth int `gorm:"default:240"`
ImgHeight int ImgHeight int `gorm:"default:80"`
PwdMinLength int PwdMinLength int `gorm:"default:8"`
PwdRequireUpper bool PwdRequireUpper bool `gorm:"default:false"`
PwdRequireLower bool PwdRequireLower bool `gorm:"default:false"`
PwdRequireDigit bool PwdRequireDigit bool `gorm:"default:false"`
PwdRequireSpecial bool PwdRequireSpecial bool `gorm:"default:false"`
LimitEnable bool LimitEnable bool `gorm:"default:false"`
LimitWindow int LimitWindow int `gorm:"default:60"`
LimitCount int LimitCount int `gorm:"default:30"`
LockEnable bool LockEnable bool `gorm:"default:false"`
LockThreshold int LockThreshold int `gorm:"default:5"`
LockDuration int LockDuration int `gorm:"default:30"`
PwdExpireEnable bool PwdExpireEnable bool `gorm:"default:false"`
PwdExpireDays int PwdExpireDays int `gorm:"default:90"`
ForceNewUserChangePassword bool ForceNewUserChangePassword bool `gorm:"default:false"`
} }
func (securityConfigPO) TableName() string { return "sys_security_config" } 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) { func (r *securityRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig, error) {
if !r.data.databaseReady.Load() { if !r.data.databaseReady.Load() {
po := defaultSecurityConfig() po := defaultSecurityConfig()
po.ID = 0
return securityFromPO(po), errors.New("数据库未初始化") return securityFromPO(po), errors.New("数据库未初始化")
} }
var po securityConfigPO var po securityConfigPO
+7 -4
View File
@@ -67,15 +67,18 @@ type menuPO struct {
func (menuPO) TableName() string { return "sys_base_menus" } func (menuPO) TableName() string { return "sys_base_menus" }
type userAuthorityPO struct { type userAuthorityPO struct {
SysUserID uint `gorm:"primaryKey;column:sys_user_id"` // GVA's explicit join model deliberately has no primary key or uniqueness
SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"` // 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" } func (userAuthorityPO) TableName() string { return "sys_user_authority" }
type authorityMenuPO struct { type authorityMenuPO struct {
SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"` SysAuthorityAuthorityID uint `gorm:"column:sys_authority_authority_id"`
SysBaseMenuID uint `gorm:"primaryKey;column:sys_base_menu_id"` SysBaseMenuID uint `gorm:"column:sys_base_menu_id"`
} }
func (authorityMenuPO) TableName() string { return "sys_authority_menus" } func (authorityMenuPO) TableName() string { return "sys_authority_menus" }
+11 -1
View File
@@ -232,10 +232,20 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
}); err != nil { }); err != nil {
return err 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) return fmt.Errorf("persist database configuration: %w", err)
} }
r.data.activateDatabase(candidate, config) 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 activated = true
return nil return nil
} }
+60
View File
@@ -2,9 +2,14 @@ package data
import ( import (
"context" "context"
"os"
"path/filepath"
"strings"
"testing" "testing"
"kra/internal/conf" "kra/internal/conf"
"gopkg.in/yaml.v3"
) )
func TestDatabaseConnectionConfigured(t *testing.T) { func TestDatabaseConnectionConfigured(t *testing.T) {
@@ -49,3 +54,58 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
t.Fatal("an active configured database must not require initialization") 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)
}
}
+95 -10
View File
@@ -41,25 +41,110 @@ func TestUserAuthorityWritesAreAtomic(t *testing.T) {
t.Fatalf("authority links = %d, err = %v", links, err) t.Fatalf("authority links = %d, err = %v", links, err)
} }
created.NickName = "must rollback" // With strict authority checks disabled, GVA writes the supplied join rows
if err = repo.UpdateUserWithAuthorities(ctx, created, []uint{888, 123456}); err == nil { // without first requiring every role record to exist.
t.Fatal("expected invalid authority update to fail") 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 var persisted userPO
if err = data.gormDB.WithContext(ctx).First(&persisted, created.ID).Error; err != nil { if err = data.gormDB.WithContext(ctx).First(&persisted, created.ID).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if persisted.NickName != "before" { if persisted.NickName != "compatible update" {
t.Fatalf("user update was not rolled back: %q", persisted.NickName) 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} withNewAuthority := &biz.User{Username: "with-new-authority", Password: "hash", AuthorityID: 888, Enable: 1}
if _, err = repo.CreateUserWithAuthorities(ctx, invalid, []uint{888, 123456}); err == nil { createdWithNewAuthority, err := repo.CreateUserWithAuthorities(ctx, withNewAuthority, []uint{888, 123456})
t.Fatal("expected invalid authority create to fail") if err != nil {
t.Fatal(err)
} }
var users int64 var users int64
if err = data.gormDB.WithContext(ctx).Model(&userPO{}).Where("username = ?", "invalid").Count(&users).Error; err != nil || users != 0 { if err = data.gormDB.WithContext(ctx).Model(&userPO{}).Where("username = ?", "with-new-authority").Count(&users).Error; err != nil || users != 1 {
t.Fatalf("partial user persisted: count=%d err=%v", users, err) 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
View File
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
func (r *userRepo) FindUserByUsername(ctx context.Context, username string) (*biz.User, error) { 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) 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) { func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error) {
var authority authorityPO 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 return nil, err
} }
var authorityPOs []authorityPO var authorityPOs []authorityPO
@@ -58,13 +70,17 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error)
return nil, err return nil, err
} }
departments := make([]biz.Department, 0, len(departmentPOs)) departments := make([]biz.Department, 0, len(departmentPOs))
var primary *biz.Department
for _, item := range departmentPOs { for _, item := range departmentPOs {
value := deptFromPO(item) value := deptFromPO(item)
departments = append(departments, *value) departments = append(departments, *value)
if item.ID == po.DeptID { }
copy := *value var primary *biz.Department
primary = &copy 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 var positionPOs []positionPO
@@ -200,12 +216,21 @@ func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*biz.User, er
return users, nil 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) { func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*biz.Menu, error) {
var pos []menuPO var pos []menuPO
err := r.data.gormDB.WithContext(ctx).Table("sys_base_menus"). 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"). 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). 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 { if err != nil {
return nil, err return nil, err
} }
@@ -214,15 +239,15 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b
for _, po := range pos { for _, po := range pos {
menu := menuFromPO(po) menu := menuFromPO(po)
menu.MenuID = po.ID menu.MenuID = po.ID
menu.Children = []*biz.Menu{} menu.Children = nil
menu.Parameters = []*biz.MenuParameter{} menu.Parameters = []*biz.MenuParameter{}
menu.Buttons = []*biz.MenuButton{} menu.Buttons = nil
byID[po.ID] = menu byID[po.ID] = menu
menuIDs = append(menuIDs, po.ID) menuIDs = append(menuIDs, po.ID)
} }
if len(menuIDs) > 0 { if len(menuIDs) > 0 {
var parameters []menuParameterPO var parameters []menuParameterPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Order("id").Find(&parameters).Error; err != nil { if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(&parameters).Error; err != nil {
return nil, err return nil, err
} }
for _, parameter := range parameters { for _, parameter := range parameters {
@@ -240,7 +265,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b
menu.Buttons = append(menu.Buttons, value) menu.Buttons = append(menu.Buttons, value)
} }
} }
roots := make([]*biz.Menu, 0) var roots []*biz.Menu
for _, po := range pos { for _, po := range pos {
menu := byID[po.ID] menu := byID[po.ID]
if parent := byID[po.ParentID]; parent != nil { 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 return nil, 0, err
} }
var pos []userPO 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 return nil, 0, err
} }
users, err := r.loadUsers(ctx, pos) users, err := r.loadUsers(ctx, pos)
@@ -307,9 +332,7 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User
} }
now := time.Now() 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} 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 { var createdAuthorities []biz.Authority
authorityIDs = []uint{user.AuthorityID}
}
if err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { 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 // The compatible registration rejects an existing non-deleted username before creating the
// account. The username column is intentionally not unique for legacy // 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) { } else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
if err := validateAuthorities(tx, authorityIDs); err != nil {
return err
}
if err := tx.Create(&po).Error; err != nil { if err := tx.Create(&po).Error; err != nil {
return err return err
} }
if len(authorityIDs) == 0 {
return nil
}
links := make([]userAuthorityPO, 0, len(authorityIDs)) links := make([]userAuthorityPO, 0, len(authorityIDs))
for _, authorityID := range 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}) links = append(links, userAuthorityPO{SysUserID: po.ID, SysAuthorityAuthorityID: authorityID})
} }
return tx.Create(&links).Error return tx.Create(&links).Error
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
return r.FindUserByID(ctx, po.ID) // 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
func validateAuthorities(tx *gorm.DB, authorityIDs []uint) error { // that response contract while retaining the persisted timestamps/UUID.
for _, authorityID := range authorityIDs { created := baseBizUser(&po)
var count int64 created.Authorities = createdAuthorities
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", authorityID).Count(&count).Error; err != nil { return created, nil
return err
}
if count == 0 {
return errors.New("角色不存在")
}
}
return nil
} }
func (r *userRepo) UpdateUser(ctx context.Context, user *biz.User) error { 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) parts = append(parts, user.Departments[i].Name)
user.Departments[i].NamePath = strings.Join(parts, "/") 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 return nil
} }
@@ -504,9 +521,6 @@ func setUserAuthorities(tx *gorm.DB, id uint, authorityIDs []uint) error {
if len(authorityIDs) == 0 { if len(authorityIDs) == 0 {
return errors.New("角色不能为空") 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 { if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil {
return err return err
} }
+4 -4
View File
@@ -14,10 +14,10 @@ type versionPO struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
Name string `gorm:"column:version_name"` Name *string `gorm:"column:version_name"`
Code string `gorm:"column:version_code"` Code *string `gorm:"column:version_code"`
Description string Description *string
Data string `gorm:"column:version_data;type:text"` Data *string `gorm:"column:version_data;type:text"`
} }
func (versionPO) TableName() string { return "sys_versions" } func (versionPO) TableName() string { return "sys_versions" }
+24
View File
@@ -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)
}
}
+3 -31
View File
@@ -1,7 +1,6 @@
package handler package handler
import ( import (
"strconv"
"time" "time"
"kra/internal/server/httpx" "kra/internal/server/httpx"
@@ -35,16 +34,7 @@ func (h *Announcement) Create(c *gin.Context) {
} }
func (h *Announcement) Delete(c *gin.Context) { func (h *Announcement) Delete(c *gin.Context) {
id, present, err := queryUintValue(c, "ID") if err := h.service.Delete(c.Request.Context(), c.Query("ID")); err != nil {
if err != nil {
httpx.Fail(c, err.Error())
return
}
if !present {
// Preserve the legacy endpoint behavior for an omitted ID.
id = 0
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
httpx.Fail(c, "删除失败") httpx.Fail(c, "删除失败")
return return
} }
@@ -52,20 +42,7 @@ func (h *Announcement) Delete(c *gin.Context) {
} }
func (h *Announcement) DeleteByIDs(c *gin.Context) { func (h *Announcement) DeleteByIDs(c *gin.Context) {
values := c.QueryArray("IDs[]") if err := h.service.DeleteByIDs(c.Request.Context(), c.QueryArray("IDs[]")); err != nil {
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 {
httpx.Fail(c, "批量删除失败") httpx.Fail(c, "批量删除失败")
return return
} }
@@ -86,12 +63,7 @@ func (h *Announcement) Update(c *gin.Context) {
} }
func (h *Announcement) Find(c *gin.Context) { func (h *Announcement) Find(c *gin.Context) {
id, _, err := queryUintValue(c, "ID") item, err := h.service.Find(c.Request.Context(), c.Query("ID"))
if err != nil {
httpx.Fail(c, err.Error())
return
}
item, err := h.service.Find(c.Request.Context(), id)
if err != nil { if err != nil {
httpx.Fail(c, "查询失败") httpx.Fail(c, "查询失败")
return return
+37 -68
View File
@@ -2,7 +2,6 @@ package handler
import ( import (
"errors" "errors"
"io"
"log/slog" "log/slog"
"strconv" "strconv"
"time" "time"
@@ -26,10 +25,23 @@ func NewAudit(service *service.AuditService, recorder *service.AuditRecorder, lo
return &Audit{service: service, recorder: recorder, logs: logs, logger: logger} return &Audit{service: service, recorder: recorder, logs: logs, logger: logger}
} }
func page(c *gin.Context) (int, int) { func page(c *gin.Context) (int, int, error) {
value, _ := strconv.Atoi(c.Query("page")) var value, size int
size, _ := strconv.Atoi(c.Query("pageSize")) if raw, exists := c.GetQuery("page"); exists && raw != "" {
return value, size 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) { 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 { func IDsFromQuery(c *gin.Context) []uint {
values := c.QueryArray("IDs[]") values := c.QueryArray("IDs[]")
if len(values) == 0 {
values = c.QueryArray("ids[]")
}
ids := make([]uint, 0, len(values)) ids := make([]uint, 0, len(values))
for _, value := range values { for _, value := range values {
id, _ := strconv.ParseUint(value, 10, 64) id, _ := strconv.ParseUint(value, 10, 64)
@@ -55,33 +64,6 @@ func IDsFromQuery(c *gin.Context) []uint {
return ids 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) { func (h *Audit) Operations(c *gin.Context) {
var req dto.OperationRecordSearchRequest var req dto.OperationRecordSearchRequest
if err := c.ShouldBindQuery(&req); err != nil { 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}, "查询成功") httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysOperationRecord": item}, "查询成功")
} }
func (h *Audit) DeleteOperation(c *gin.Context) { func (h *Audit) DeleteOperation(c *gin.Context) {
id, err := auditID(c) var req dto.IDRequest
if err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return return
} }
if id == 0 { if err := h.service.DeleteOperations(c.Request.Context(), []uint{req.ID}); err != nil {
httpx.Fail(c, "删除失败")
return
}
if err := h.service.DeleteOperations(c.Request.Context(), []uint{id}); err != nil {
httpx.Fail(c, "删除失败") httpx.Fail(c, "删除失败")
return return
} }
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功")
} }
func (h *Audit) DeleteOperations(c *gin.Context) { func (h *Audit) DeleteOperations(c *gin.Context) {
ids, err := auditIDs(c) var req dto.IDsRequest
if err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return 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, "批量删除失败") httpx.Fail(c, "批量删除失败")
return return
} }
@@ -168,28 +146,24 @@ func (h *Audit) Login(c *gin.Context) {
httpx.Write(c, httpx.CodeSuccess, item, "查询成功") httpx.Write(c, httpx.CodeSuccess, item, "查询成功")
} }
func (h *Audit) DeleteLogin(c *gin.Context) { func (h *Audit) DeleteLogin(c *gin.Context) {
id, err := auditID(c) var req dto.IDRequest
if err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return return
} }
if id == 0 { if err := h.service.DeleteLogins(c.Request.Context(), []uint{req.ID}); err != nil {
httpx.Fail(c, "删除失败")
return
}
if err := h.service.DeleteLogins(c.Request.Context(), []uint{id}); err != nil {
httpx.Fail(c, "删除失败") httpx.Fail(c, "删除失败")
return return
} }
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功")
} }
func (h *Audit) DeleteLogins(c *gin.Context) { func (h *Audit) DeleteLogins(c *gin.Context) {
ids, err := auditIDs(c) var req dto.IDsRequest
if err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return 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, "批量删除失败") httpx.Fail(c, "批量删除失败")
return return
} }
@@ -298,24 +272,15 @@ func failLogViewer(c *gin.Context, err error, logger *slog.Logger) {
} }
func (h *Audit) DeleteError(c *gin.Context) { func (h *Audit) DeleteError(c *gin.Context) {
id, err := auditID(c) id, _ := strconv.ParseUint(c.Query("ID"), 10, 64)
if err != nil { if err := h.service.DeleteErrors(c.Request.Context(), []uint{uint(id)}); err != nil {
httpx.Fail(c, err.Error())
return
}
if err := h.service.DeleteErrors(c.Request.Context(), []uint{id}); err != nil {
httpx.Fail(c, "删除失败:"+err.Error()) httpx.Fail(c, "删除失败:"+err.Error())
return return
} }
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功")
} }
func (h *Audit) DeleteErrors(c *gin.Context) { func (h *Audit) DeleteErrors(c *gin.Context) {
ids, err := auditIDs(c) if err := h.service.DeleteErrors(c.Request.Context(), IDsFromQuery(c)); err != nil {
if err != nil {
httpx.Fail(c, err.Error())
return
}
if err := h.service.DeleteErrors(c.Request.Context(), ids); err != nil {
httpx.Fail(c, "批量删除失败:"+err.Error()) httpx.Fail(c, "批量删除失败:"+err.Error())
return return
} }
@@ -343,7 +308,11 @@ func (h *Audit) Error(c *gin.Context) {
httpx.OKWithData(c, item) httpx.OKWithData(c, item)
} }
func (h *Audit) Errors(c *gin.Context) { 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) createdAtRange := make([]time.Time, 0, 2)
for _, raw := range c.QueryArray("createdAtRange[]") { for _, raw := range c.QueryArray("createdAtRange[]") {
value, parseErr := time.Parse(time.RFC3339, raw) value, parseErr := time.Parse(time.RFC3339, raw)
+16 -4
View File
@@ -161,8 +161,20 @@ func (h *Dictionary) FindDetail(c *gin.Context) {
httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysDictionaryDetail": item}, "查询成功") httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysDictionaryDetail": item}, "查询成功")
} }
func (h *Dictionary) Details(c *gin.Context) { func (h *Dictionary) Details(c *gin.Context) {
p, size := page(c) p, size, err := page(c)
dictionaryID, _ := strconv.ParseUint(c.Query("sysDictionaryID"), 10, 64) 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")} filter := biz.DictionaryDetailFilter{DictionaryID: uint(dictionaryID), Label: c.Query("label"), Value: c.Query("value")}
if raw, exists := c.GetQuery("status"); exists { if raw, exists := c.GetQuery("status"); exists {
value, parseErr := strconv.ParseBool(raw) value, parseErr := strconv.ParseBool(raw)
@@ -199,7 +211,7 @@ func (h *Dictionary) Details(c *gin.Context) {
func (h *Dictionary) Tree(byType bool) gin.HandlerFunc { func (h *Dictionary) Tree(byType bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
rawID := c.Query("sysDictionaryID") rawID := c.Query("sysDictionaryID")
id, parseErr := strconv.ParseUint(rawID, 10, 64) id, parseErr := strconv.ParseUint(rawID, 10, 32)
typ := "" typ := ""
if byType { if byType {
typ = c.Query("type") typ = c.Query("type")
@@ -241,7 +253,7 @@ func (h *Dictionary) Path(c *gin.Context) {
httpx.Fail(c, "字典详情ID不能为空") httpx.Fail(c, "字典详情ID不能为空")
return return
} }
id, parseErr := strconv.ParseUint(rawID, 10, 64) id, parseErr := strconv.ParseUint(rawID, 10, 32)
if parseErr != nil { if parseErr != nil {
httpx.Fail(c, "字典详情ID格式错误") httpx.Fail(c, "字典详情ID格式错误")
return return
+11 -1
View File
@@ -71,6 +71,12 @@ func (h *Export) Delete(c *gin.Context) {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return 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 { if err := h.service.Delete(c.Request.Context(), []uint{req.ID}); err != nil {
httpx.Fail(c, "删除失败") httpx.Fail(c, "删除失败")
return return
@@ -103,7 +109,11 @@ func (h *Export) Find(c *gin.Context) {
httpx.OKWithData(c, gin.H{"resysExportTemplate": item}) httpx.OKWithData(c, gin.H{"resysExportTemplate": item})
} }
func (h *Export) List(c *gin.Context) { 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")) start, startErr := parseTime(c.Query("startCreatedAt"))
end, endErr := parseTime(c.Query("endCreatedAt")) end, endErr := parseTime(c.Query("endCreatedAt"))
if startErr != nil || endErr != nil { if startErr != nil || endErr != nil {
+1 -1
View File
@@ -67,7 +67,7 @@ func (h *Media) Delete(c *gin.Context) {
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功") httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功")
} }
func (h *Media) DeleteMany(c *gin.Context) { func (h *Media) DeleteMany(c *gin.Context) {
var req dto.IDsRequest var req dto.MediaBatchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return return
+2 -2
View File
@@ -106,7 +106,7 @@ func (h *Menu) Delete(c *gin.Context) {
httpx.Fail(c, "ID值不能为空") httpx.Fail(c, "ID值不能为空")
return 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()) httpx.Fail(c, "删除失败:"+err.Error())
return return
} }
@@ -123,7 +123,7 @@ func (h *Menu) Find(c *gin.Context) {
httpx.Fail(c, "ID值不能为空") httpx.Fail(c, "ID值不能为空")
return 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 { if err != nil {
httpx.Fail(c, "获取失败") httpx.Fail(c, "获取失败")
return return
+4 -8
View File
@@ -1,8 +1,6 @@
package handler package handler
import ( import (
"strconv"
"kra/internal/server/httpx" "kra/internal/server/httpx"
"kra/internal/service" "kra/internal/service"
"kra/internal/service/dto" "kra/internal/service/dto"
@@ -45,8 +43,7 @@ func (h *Parameter) Update(c *gin.Context) {
} }
func (h *Parameter) Delete(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(), []string{c.Query("ID")}); err != nil {
if err := h.service.DeleteParameters(c.Request.Context(), []uint{uint(id)}); err != nil {
httpx.Fail(c, "删除失败:"+err.Error()) httpx.Fail(c, "删除失败:"+err.Error())
return return
} }
@@ -54,7 +51,7 @@ func (h *Parameter) Delete(c *gin.Context) {
} }
func (h *Parameter) DeleteMany(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 { if err := h.service.DeleteParameters(c.Request.Context(), ids); err != nil {
httpx.Fail(c, "批量删除失败:"+err.Error()) httpx.Fail(c, "批量删除失败:"+err.Error())
return return
@@ -63,8 +60,7 @@ func (h *Parameter) DeleteMany(c *gin.Context) {
} }
func (h *Parameter) Find(c *gin.Context) { func (h *Parameter) Find(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) item, err := h.service.ParameterByID(c.Request.Context(), c.Query("ID"))
item, err := h.service.Parameter(c.Request.Context(), uint(id), "")
if err != nil { if err != nil {
httpx.Fail(c, "查询失败:"+err.Error()) httpx.Fail(c, "查询失败:"+err.Error())
return return
@@ -73,7 +69,7 @@ func (h *Parameter) Find(c *gin.Context) {
} }
func (h *Parameter) Get(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 { if err != nil {
httpx.Fail(c, "获取失败:"+err.Error()) httpx.Fail(c, "获取失败:"+err.Error())
return return
+1 -4
View File
@@ -1,8 +1,6 @@
package handler package handler
import ( import (
"strconv"
"kra/internal/server/httpx" "kra/internal/server/httpx"
"kra/internal/service" "kra/internal/service"
"kra/internal/service/dto" "kra/internal/service/dto"
@@ -44,8 +42,7 @@ func (h *Permission) SetButtons(c *gin.Context) {
} }
func (h *Permission) CanRemoveButton(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(), c.Query("id"))
allowed, err := h.service.CanRemoveButton(c.Request.Context(), uint(id))
if err != nil { if err != nil {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return return
+1 -1
View File
@@ -91,7 +91,7 @@ func (h *Public) Login(c *gin.Context) {
} }
return 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.SetTokenCookie(c, result.Token, maxAge)
httpx.Write(c, httpx.CodeSuccess, result, "登录成功") httpx.Write(c, httpx.CodeSuccess, result, "登录成功")
} }
+4 -2
View File
@@ -125,7 +125,7 @@ func (h *Task) AlertStream(c *gin.Context) {
httpx.Fail(c, "未获取到用户身份") httpx.Fail(c, "未获取到用户身份")
return 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("Cache-Control", "no-cache, no-transform")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") c.Header("X-Accel-Buffering", "no")
@@ -135,7 +135,9 @@ func (h *Task) AlertStream(c *gin.Context) {
return return
} }
// Remove the normal server deadline for long-lived SSE connections. // 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) events := h.service.Subscribe(claims.ID)
defer h.service.Unsubscribe(claims.ID, events) defer h.service.Unsubscribe(claims.ID, events)
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
+7 -4
View File
@@ -71,7 +71,10 @@ func (h *User) Create(c *gin.Context) {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return 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 return
} }
httpx.Write(c, httpx.CodeSuccess, gin.H{"user": result}, "注册成功") 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) { func (h *User) Delete(c *gin.Context) {
var req dto.IDRequest var req dto.IDRequest
if err := c.ShouldBind(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return return
} }
@@ -235,7 +238,7 @@ func (h *User) SwitchAuthority(c *gin.Context) {
} }
c.Header("new-token", login.Token) c.Header("new-token", login.Token)
c.Header("new-expires-at", strconv.FormatInt(login.ExpiresAt/1000, 10)) 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.SetTokenCookie(c, login.Token, maxAge)
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "修改成功") httpx.Write(c, httpx.CodeSuccess, gin.H{}, "修改成功")
} }
@@ -246,7 +249,7 @@ func (h *User) Get(c *gin.Context) {
httpx.NoAuth(c, "未登录或非法访问") httpx.NoAuth(c, "未登录或非法访问")
return return
} }
result, err := h.service.User(c.Request.Context(), claims.ID) result, err := h.service.UserByUUID(c.Request.Context(), claims.UUID)
if err != nil { if err != nil {
httpx.Fail(c, "获取失败") httpx.Fail(c, "获取失败")
return return
+2 -1
View File
@@ -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}, "获取成功") httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: req.Page, PageSize: req.PageSize}, "获取成功")
} }
func (h *Version) Export(c *gin.Context) { func (h *Version) Export(c *gin.Context) {
var req dto.ExportVersionRequest var req dto.ExportVersionRequest
if err := c.ShouldBindJSON(&req); err != nil { 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-Type", "application/json")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", name)) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", name))
c.Header("Content-Length", strconv.Itoa(len(raw))) 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) { func (h *Version) Import(c *gin.Context) {
var req dto.ImportVersionRequest var req dto.ImportVersionRequest
+3 -3
View File
@@ -39,15 +39,15 @@ func (s *AnnouncementService) Update(ctx context.Context, in AnnouncementInput)
return s.uc.Update(ctx, announcementDO(in)) 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) 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) 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) item, err := s.uc.Find(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
+5 -2
View File
@@ -71,7 +71,7 @@ func (s *APIService) Groups(ctx context.Context) ([]string, map[string]string, e
return nil, nil, err return nil, nil, err
} }
seen := make(map[string]bool) seen := make(map[string]bool)
groups := make([]string, 0) var groups []string
groupAPIMap := make(map[string]string) groupAPIMap := make(map[string]string)
for _, item := range items { for _, item := range items {
if !seen[item.APIGroup] { if !seen[item.APIGroup] {
@@ -126,7 +126,10 @@ func (s *APIService) PolicyPathResponses(ctx context.Context, authorityID uint)
if err != nil { if err != nil {
return nil, err 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 { for _, item := range items {
out = append(out, dto.PolicyPathRequest{Path: item.Path, Method: item.Method}) out = append(out, dto.PolicyPathRequest{Path: item.Path, Method: item.Method})
} }
+8 -2
View File
@@ -28,7 +28,11 @@ func authorityDomain(req *dto.AuthorityRequest) *biz.Authority {
} }
func authorityResponse(value *biz.Authority) *dto.AuthorityResponse { 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) { 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 { if err := s.uc.CopyAuthority(ctx, req.OldAuthorityID, value); err != nil {
return nil, err 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) { func (s *AuthorityService) UpdateAuthorityRequest(ctx context.Context, req *dto.AuthorityRequest) (*dto.AuthorityResponse, error) {
+6 -3
View File
@@ -14,9 +14,12 @@ func NewDepartmentService(uc *biz.DepartmentUsecase) *DepartmentService {
} }
func departmentResponse(value *biz.Department) *dto.DepartmentResponse { func departmentResponse(value *biz.Department) *dto.DepartmentResponse {
children := make([]*dto.DepartmentResponse, 0, len(value.Children)) var children []*dto.DepartmentResponse
for _, child := range value.Children { if value.Children != nil {
children = append(children, departmentResponse(child)) children = make([]*dto.DepartmentResponse, 0, len(value.Children))
for _, child := range value.Children {
children = append(children, departmentResponse(child))
}
} }
var leader any var leader any
if value.Leader != nil { if value.Leader != nil {
+16
View File
@@ -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)
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ func (s *DictionaryService) ExportDictionary(ctx context.Context, id uint) (*dto
if err != nil { if err != nil {
return nil, err return nil, err
} }
details := make([]*dto.DictionaryDetailExportResponse, 0, len(v.Details)) var details []*dto.DictionaryDetailExportResponse
for _, detail := range v.Details { for _, detail := range v.Details {
details = append(details, &dto.DictionaryDetailExportResponse{ details = append(details, &dto.DictionaryDetailExportResponse{
Label: detail.Label, Value: detail.Value, Extend: detail.Extend, Label: detail.Label, Value: detail.Value, Extend: detail.Extend,
+28
View File
@@ -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)
}
}
+38
View File
@@ -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)
}
}
+4 -4
View File
@@ -20,12 +20,12 @@ type DeleteAuthorityRequest struct {
} }
type SetRoleUsersRequest struct { type SetRoleUsersRequest struct {
AuthorityID uint `json:"authorityId" form:"authorityId"` AuthorityID uint `json:"authorityId" form:"authorityId"`
UserIDs []uint `json:"userIds"` UserIDs []uint `json:"userIds" form:"userIds"`
} }
type SetDataScopeRequest struct { type SetDataScopeRequest struct {
AuthorityID uint `json:"authorityId" form:"authorityId"` AuthorityID uint `json:"authorityId" form:"authorityId"`
DataScope int `json:"dataScope"` DataScope int `json:"dataScope" form:"dataScope"`
DepartmentIDs []uint `json:"deptIds"` DepartmentIDs []uint `json:"deptIds" form:"deptIds"`
} }
type AuthorityResponse struct { type AuthorityResponse struct {
@@ -36,7 +36,7 @@ type AuthorityResponse struct {
AuthorityName string `json:"authorityName"` AuthorityName string `json:"authorityName"`
ParentID *uint `json:"parentId"` ParentID *uint `json:"parentId"`
Children []*AuthorityResponse `json:"children"` Children []*AuthorityResponse `json:"children"`
Menus []any `json:"menus"` Menus []*MenuResponse `json:"menus"`
DataScope int `json:"dataScope"` DataScope int `json:"dataScope"`
DefaultRouter string `json:"defaultRouter"` DefaultRouter string `json:"defaultRouter"`
} }
+3
View File
@@ -61,6 +61,9 @@ type IDRequest struct {
type IDsRequest struct { type IDsRequest struct {
IDs []uint `json:"ids"` IDs []uint `json:"ids"`
} }
type MediaBatchDeleteRequest struct {
IDs []uint `json:"ids" binding:"required"`
}
type RenameMediaRequest struct { type RenameMediaRequest struct {
ID uint `json:"ID"` ID uint `json:"ID"`
Name string `json:"name"` Name string `json:"name"`
+39 -17
View File
@@ -13,19 +13,27 @@ type MenuMetaRequest struct {
} }
type MenuButtonRequest struct { type MenuButtonRequest struct {
ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"`
Name string `json:"name"` UpdatedAt time.Time `json:"UpdatedAt"`
Description string `json:"desc"` ID uint `json:"ID"`
Name string `json:"name"`
Description string `json:"desc"`
SysBaseMenuID uint `json:"sysBaseMenuID"`
} }
type MenuParameterRequest struct { type MenuParameterRequest struct {
ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"`
Type string `json:"type"` UpdatedAt time.Time `json:"UpdatedAt"`
Key string `json:"key"` ID uint `json:"ID"`
Value string `json:"value"` SysBaseMenuID uint `json:"SysBaseMenuID"`
Type string `json:"type"`
Key string `json:"key"`
Value string `json:"value"`
} }
type MenuRequest struct { type MenuRequest struct {
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
ID uint `json:"ID"` ID uint `json:"ID"`
ParentID uint `json:"parentId"` ParentID uint `json:"parentId"`
Path string `json:"path"` Path string `json:"path"`
@@ -39,20 +47,16 @@ type MenuRequest struct {
} }
type DeleteMenuRequest struct { type DeleteMenuRequest struct {
ID uint `json:"ID"` ID int `json:"id"`
} }
type GetMenuRequest struct { type GetMenuRequest struct {
ID uint `json:"id"` ID int `json:"id"`
}
type AuthorityMenuItemRequest struct {
ID uint `json:"ID"`
} }
type SetAuthorityMenusRequest struct { type SetAuthorityMenusRequest struct {
AuthorityID uint `json:"authorityId"` AuthorityID uint `json:"authorityId"`
Menus []AuthorityMenuItemRequest `json:"menus"` Menus []MenuRequest `json:"menus"`
} }
type GetAuthorityMenusRequest struct { type GetAuthorityMenusRequest struct {
@@ -108,14 +112,32 @@ type MenuResponse struct {
Children []*MenuResponse `json:"children"` Children []*MenuResponse `json:"children"`
Parameters []MenuParameterResponse `json:"parameters"` Parameters []MenuParameterResponse `json:"parameters"`
MenuButtons []MenuButtonResponse `json:"menuBtn"` 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"` Buttons map[string]uint `json:"btns"`
Authorities []*AuthorityResponse `json:"authoritys"`
} }
type AuthorityMenuResponse struct { type AuthorityMenuResponse struct {
// The administration response embeds the base-menu fields, so menu fields are // The administration response embeds the base-menu fields, so menu fields are
// flat in the response (there is no sysBaseMenu envelope). Keep the // flat in the response (there is no sysBaseMenu envelope). Keep the
// authority ID internal, matching the compatible json:"-" field. // authority ID internal, matching the compatible json:"-" field.
*MenuResponse *DynamicMenuResponse
MenuID uint `json:"menuId"`
AuthorityID uint `json:"-"` AuthorityID uint `json:"-"`
} }
+3 -2
View File
@@ -1,8 +1,9 @@
package dto package dto
type GetAuthorityButtonsRequest struct { type GetAuthorityButtonsRequest struct {
MenuID uint `json:"menuID"` MenuID uint `json:"menuID"`
AuthorityID uint `json:"authorityId"` AuthorityID uint `json:"authorityId"`
Selected []uint `json:"selected"`
} }
type SetAuthorityButtonsRequest struct { type SetAuthorityButtonsRequest struct {
+7 -5
View File
@@ -31,11 +31,13 @@ type DictionaryDetailsByParentRequest struct {
IncludeChildren bool `json:"includeChildren" form:"includeChildren"` IncludeChildren bool `json:"includeChildren" form:"includeChildren"`
} }
type SystemParameterRequest struct { type SystemParameterRequest struct {
ID uint `json:"ID" form:"ID"` ID uint `json:"ID" form:"ID"`
Name string `json:"name" form:"name" binding:"required"` CreatedAt time.Time `json:"CreatedAt" form:"CreatedAt"`
Key string `json:"key" form:"key" binding:"required"` UpdatedAt time.Time `json:"UpdatedAt" form:"UpdatedAt"`
Value string `json:"value" form:"value" binding:"required"` Name string `json:"name" form:"name" binding:"required"`
Description string `json:"desc" form:"desc"` 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 { type SystemParameterSearchRequest struct {
+20 -19
View File
@@ -75,25 +75,26 @@ type LoginLogRequest struct {
} }
type UserResponse struct { type UserResponse struct {
ID uint `json:"ID"` ID uint `json:"ID"`
CreatedAt time.Time `json:"CreatedAt"` CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"` UpdatedAt time.Time `json:"UpdatedAt"`
DeletedAt any `json:"-"` DeletedAt any `json:"-"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
Username string `json:"userName"` Username string `json:"userName"`
NickName string `json:"nickName"` NickName string `json:"nickName"`
HeaderImg string `json:"headerImg"` HeaderImg string `json:"headerImg"`
AuthorityID uint `json:"authorityId"` AuthorityID uint `json:"authorityId"`
Authority *AuthorityResponse `json:"authority"` Authority *AuthorityResponse `json:"authority"`
Authorities []*AuthorityResponse `json:"authorities"` Authorities []*AuthorityResponse `json:"authorities"`
DeptID uint `json:"deptId"` DeptID uint `json:"deptId"`
Department any `json:"dept"` Department any `json:"dept"`
Departments []*DepartmentResponse `json:"departments"` Departments []*DepartmentResponse `json:"departments"`
Positions []*PositionResponse `json:"positions"` Positions []*PositionResponse `json:"positions"`
Phone string `json:"phone"` Phone string `json:"phone"`
Email string `json:"email"` Email string `json:"email"`
Enable int `json:"enable"` Enable int `json:"enable"`
OriginSetting map[string]any `json:"originSetting"` OriginSetting map[string]any `json:"originSetting"`
PasswordUpdatedAt *time.Time `json:"passwordUpdatedAt"`
} }
type ServerInfoResponse struct { type ServerInfoResponse struct {
+4 -4
View File
@@ -7,10 +7,10 @@ type VersionResponse struct {
CreatedAt time.Time `json:"CreatedAt"` CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"` UpdatedAt time.Time `json:"UpdatedAt"`
DeletedAt any `json:"-"` DeletedAt any `json:"-"`
VersionName string `json:"versionName"` VersionName *string `json:"versionName"`
VersionCode string `json:"versionCode"` VersionCode *string `json:"versionCode"`
Description string `json:"description"` Description *string `json:"description"`
VersionData string `json:"versionData"` VersionData *string `json:"versionData"`
} }
type VersionSearchRequest struct { type VersionSearchRequest struct {
+64 -19
View File
@@ -56,9 +56,10 @@ func (s *MenuService) AuthorityMenus(ctx context.Context, authorityID uint) ([]*
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]*dto.AuthorityMenuResponse, 0, len(items)) var out []*dto.AuthorityMenuResponse
for _, item := range items { 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 return out, nil
} }
@@ -74,42 +75,86 @@ func (s *MenuService) SetRoles(ctx context.Context, req *dto.SetMenuRolesRequest
} }
func menuDomain(v *dto.MenuRequest) *biz.Menu { 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)) menu.Buttons = make([]*biz.MenuButton, 0, len(v.MenuButtons))
for _, button := range 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)) menu.Parameters = make([]*biz.MenuParameter, 0, len(v.Parameters))
for _, parameter := range 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 return menu
} }
func menuResponse(menu *biz.Menu) *dto.MenuResponse { func menuResponse(menu *biz.Menu) *dto.MenuResponse {
children := make([]*dto.MenuResponse, 0, len(menu.Children)) var children []*dto.MenuResponse
for _, child := range menu.Children { if menu.Children != nil {
children = append(children, menuResponse(child)) children = make([]*dto.MenuResponse, 0, len(menu.Children))
} for _, child := range menu.Children {
buttons := make([]dto.MenuButtonResponse, 0, len(menu.Buttons)) children = append(children, menuResponse(child))
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
} }
} }
parameters := make([]dto.MenuParameterResponse, 0, len(menu.Parameters)) var buttons []dto.MenuButtonResponse
for _, parameter := range menu.Parameters { if menu.Buttons != nil {
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}) 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 { func menuResponses(items []*biz.Menu) []*dto.MenuResponse {
if items == nil {
return nil
}
out := make([]*dto.MenuResponse, 0, len(items)) out := make([]*dto.MenuResponse, 0, len(items))
for _, item := range items { for _, item := range items {
out = append(out, menuResponse(item)) out = append(out, menuResponse(item))
} }
return out 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
}
+63
View File
@@ -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)
}
}
+11 -4
View File
@@ -15,7 +15,7 @@ func NewParameterService(uc *biz.ParameterUsecase) *ParameterService {
} }
func parameterDomain(value *dto.SystemParameterRequest) *biz.SystemParameter { 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 { func (s *ParameterService) CreateParameterRequest(ctx context.Context, req *dto.SystemParameterRequest) error {
return s.CreateParameter(ctx, parameterDomain(req)) 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 return out, total, nil
} }
func (s *ParameterService) Parameter(ctx context.Context, id uint, key string) (*dto.SystemParameterResponse, error) { func (s *ParameterService) ParameterByID(ctx context.Context, id string) (*dto.SystemParameterResponse, error) {
v, err := s.uc.FindParameter(ctx, id, key) 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 { if err != nil {
return nil, err 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 { func (s *ParameterService) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error {
return s.uc.UpdateParameter(ctx, v) 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) return s.uc.DeleteParameters(ctx, ids)
} }
+1 -1
View File
@@ -20,6 +20,6 @@ func (s *PermissionService) SetSelectedButtons(ctx context.Context, authorityID,
return s.uc.SetSelectedButtons(ctx, authorityID, menuID, ids) 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) return s.uc.CanRemoveButton(ctx, id)
} }
+1 -1
View File
@@ -25,7 +25,7 @@ func (s *SecurityService) CurrentSecurity(ctx context.Context) (*biz.SecurityCon
return s.uc.Current(ctx) return s.uc.Current(ctx)
} }
func (s *SecurityService) Security(ctx context.Context) (*dto.SecurityConfigResponse, error) { func (s *SecurityService) Security(ctx context.Context) (*dto.SecurityConfigResponse, error) {
value, err := s.CurrentSecurity(ctx) value, err := s.uc.Security(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+10 -2
View File
@@ -45,12 +45,20 @@ func (s *UserService) User(ctx context.Context, id uint) (*dto.UserResponse, err
return convertUser(value), nil 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) menus, err := s.uc.Menus(ctx, authorityID)
if err != nil { if err != nil {
return nil, err 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) { func (s *UserService) ListUsers(ctx context.Context, page, pageSize int, filter *biz.UserListFilter) ([]*dto.UserResponse, int64, error) {
+27 -12
View File
@@ -6,24 +6,39 @@ import (
) )
func convertAuthority(value biz.Authority) *dto.AuthorityResponse { 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 { func convertUser(user *biz.User) *dto.UserResponse {
authorities := make([]*dto.AuthorityResponse, 0, len(user.Authorities)) var authorities []*dto.AuthorityResponse
for _, value := range user.Authorities { if user.Authorities != nil {
authorities = append(authorities, convertAuthority(value)) 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)) var departments []*dto.DepartmentResponse
for i := range user.Departments { if user.Departments != nil {
departments = append(departments, departmentResponse(&user.Departments[i])) 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)) var positions []*dto.PositionResponse
for i := range user.Positions { if user.Positions != nil {
positions = append(positions, positionResponse(&user.Positions[i])) 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 { if user.Department != nil {
department = departmentResponse(user.Department) 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}
} }
+18 -9
View File
@@ -132,6 +132,15 @@ func exportMenu(value *biz.Menu) *versionExportMenu {
func versionDTO(v *biz.Version) *dto.VersionResponse { 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} 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) { 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) items, total, err := s.uc.ListVersions(ctx, page, size, name, code, createdAtRange)
if err != nil { if err != nil {
@@ -155,11 +164,11 @@ func (s *VersionService) VersionData(ctx context.Context, id uint) (string, stri
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
if v.Data != "" { if v.Data != nil && *v.Data != "" {
return v.Data, v.Code, nil return *v.Data, versionValue(v.Code), nil
} }
payload := versionExportDocument{ 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{}, Menus: []*versionExportMenu{},
APIs: []versionExportAPI{}, APIs: []versionExportAPI{},
} }
@@ -167,7 +176,7 @@ func (s *VersionService) VersionData(ctx context.Context, id uint) (string, stri
if marshalErr != nil { if marshalErr != nil {
return "", "", marshalErr 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 { func (s *VersionService) DeleteVersions(ctx context.Context, ids []uint) error {
return s.uc.DeleteVersions(ctx, ids) return s.uc.DeleteVersions(ctx, ids)
@@ -206,7 +215,7 @@ func (s *VersionService) Export(ctx context.Context, name, code, description str
if err != nil { if err != nil {
return &biz.VersionStageError{Stage: biz.VersionStageJSON, Err: err} 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 &biz.VersionStageError{Stage: biz.VersionStageSave, Err: err}
} }
return nil return nil
@@ -250,10 +259,10 @@ func (s *VersionService) ImportRequest(ctx context.Context, req *dto.ImportVersi
} }
raw, _ := json.Marshal(req) raw, _ := json.Marshal(req)
_ = s.uc.CreateVersion(ctx, &biz.Version{ _ = s.uc.CreateVersion(ctx, &biz.Version{
Name: req.Version.Name, Name: versionPointer(req.Version.Name),
Code: req.Version.Code + "_imported_" + time.Now().Format("20060102150405"), Code: versionPointer(req.Version.Code + "_imported_" + time.Now().Format("20060102150405")),
Description: "导入版本: " + req.Version.Description, Description: versionPointer("导入版本: " + req.Version.Description),
Data: string(raw), Data: versionPointer(string(raw)),
}) })
return nil return nil
} }
+8 -1
View File
@@ -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. // the enabled rows that were scheduled successfully.
s.logger.InfoContext(ctx, "定时任务加载完成", "task_count", len(items)) s.logger.InfoContext(ctx, "定时任务加载完成", "task_count", len(items))
} else { } else {
@@ -222,6 +222,13 @@ func (s *TaskScheduler) Subscribe(userID uint) chan []byte {
if s.subscribers[userID] == nil { if s.subscribers[userID] == nil {
s.subscribers[userID] = map[chan []byte]struct{}{} 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.subscribers[userID][ch] = struct{}{}
s.subMu.Unlock() s.subMu.Unlock()
return ch return ch
+25
View File
@@ -23,3 +23,28 @@ func TestCloseSubscribersClosesEveryAlertStream(t *testing.T) {
// closed and removed every channel. // closed and removed every channel.
scheduler.Unsubscribe(1, first) 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()
}
+8 -1
View File
@@ -33,10 +33,17 @@ func Generate(secret, issuer string, expires, buffer time.Duration, userID, auth
} }
now := time.Now() now := time.Now()
claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), UserType: "admin", MustChangePwd: mustChange, RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"KRA"}, Issuer: issuer, NotBefore: jwt.NewNumericDate(now.Add(-1000)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}} claims := &Claims{UUID: uuid, ID: userID, Username: username, NickName: nickname, AuthorityID: authorityID, BufferTime: int64(buffer / time.Second), UserType: "admin", MustChangePwd: mustChange, RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"KRA"}, Issuer: issuer, NotBefore: jwt.NewNumericDate(now.Add(-1000)), ExpiresAt: jwt.NewNumericDate(now.Add(expires))}}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) token, err := Sign(secret, claims)
return token, claims, err return token, claims, err
} }
func Sign(secret string, claims *Claims) (string, error) {
if secret == "" {
return "", errors.New("empty JWT signing key")
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
func Parse(tokenString, secret string) (*Claims, error) { func Parse(tokenString, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 { if token.Method != jwt.SigningMethodHS256 {
+47 -7
View File
@@ -17,11 +17,24 @@
<el-form-item label="路由前缀"> <el-form-item label="路由前缀">
<el-input v-model.trim="config.admin.routerPrefix" placeholder="例如 /api;留空表示无前缀" /> <el-input v-model.trim="config.admin.routerPrefix" placeholder="例如 /api;留空表示无前缀" />
</el-form-item> </el-form-item>
<el-form-item label="服务端口">
<el-input-number v-model="config.admin.system.addr" :min="1" :max="65535" class="!w-full" />
</el-form-item>
<el-form-item label="IP 限流次数">
<el-input-number v-model="config.admin.system.iplimitCount" :min="0" class="!w-full" />
</el-form-item>
<el-form-item label="IP 限流窗口(秒)">
<el-input-number v-model="config.admin.system.iplimitTime" :min="0" class="!w-full" />
</el-form-item>
<el-form-item label="JWT 签发者"> <el-form-item label="JWT 签发者">
<el-input v-model.trim="config.admin.jwt.issuer" /> <el-input v-model.trim="config.admin.jwt.issuer" />
</el-form-item> </el-form-item>
<el-form-item label="JWT 签名密钥"> <el-form-item label="JWT 签名密钥">
<el-input v-model="config.admin.jwt.signingKey" show-password /> <el-input v-model="config.admin.jwt.signingKey" show-password>
<template #append>
<el-button @click="generateSigningKey">生成</el-button>
</template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item label="JWT 有效期"> <el-form-item label="JWT 有效期">
<el-input v-model.trim="config.admin.jwt.expiresTime" placeholder="例如 168h" /> <el-input v-model.trim="config.admin.jwt.expiresTime" placeholder="例如 168h" />
@@ -53,6 +66,9 @@
<el-form-item label="单文件最大字节数(0 为不限)"> <el-form-item label="单文件最大字节数(0 为不限)">
<el-input-number v-model="config.admin.media.maxFileSize" :min="0" class="!w-full" /> <el-input-number v-model="config.admin.media.maxFileSize" :min="0" class="!w-full" />
</el-form-item> </el-form-item>
<el-form-item label="分片目录">
<el-input v-model.trim="config.admin.media.chunkDir" />
</el-form-item>
<el-form-item label="运行开关" class="md:col-span-2"> <el-form-item label="运行开关" class="md:col-span-2">
<div class="flex flex-wrap gap-5"> <div class="flex flex-wrap gap-5">
<el-switch v-model="config.admin.system.useRedis" active-text="启用 Redis" /> <el-switch v-model="config.admin.system.useRedis" active-text="启用 Redis" />
@@ -118,9 +134,11 @@
<el-form-item label="最大连接池"><el-input-number v-model="config.data.mongo.max_pool_size" :min="0" class="!w-full" /></el-form-item> <el-form-item label="最大连接池"><el-input-number v-model="config.data.mongo.max_pool_size" :min="0" class="!w-full" /></el-form-item>
<el-form-item label="连接超时(毫秒)"><el-input-number v-model="config.data.mongo.connect_timeout_ms" :min="0" class="!w-full" /></el-form-item> <el-form-item label="连接超时(毫秒)"><el-input-number v-model="config.data.mongo.connect_timeout_ms" :min="0" class="!w-full" /></el-form-item>
<el-form-item label="Socket 超时(毫秒)"><el-input-number v-model="config.data.mongo.socket_timeout_ms" :min="0" class="!w-full" /></el-form-item> <el-form-item label="Socket 超时(毫秒)"><el-input-number v-model="config.data.mongo.socket_timeout_ms" :min="0" class="!w-full" /></el-form-item>
<el-form-item label="使用独立 Mongo 日志"><el-switch v-model="config.data.mongo.is_zap" /></el-form-item>
<el-form-item label="MongoDB 节点 JSON" class="md:col-span-2"><el-input v-model="mongoHostsText" type="textarea" :rows="4" placeholder='[{"host":"127.0.0.1","port":"27017"}]' /></el-form-item> <el-form-item label="MongoDB 节点 JSON" class="md:col-span-2"><el-input v-model="mongoHostsText" type="textarea" :rows="4" placeholder='[{"host":"127.0.0.1","port":"27017"}]' /></el-form-item>
<el-form-item label="多数据库配置 JSON" class="md:col-span-2"><el-input v-model="databaseListText" type="textarea" :rows="6" /></el-form-item> <el-form-item label="多数据库配置 JSON" class="md:col-span-2"><el-input v-model="databaseListText" type="textarea" :rows="6" /></el-form-item>
<el-form-item label="多 Redis 配置 JSON" class="md:col-span-2"><el-input v-model="redisListText" type="textarea" :rows="6" /></el-form-item> <el-form-item label="多 Redis 配置 JSON" class="md:col-span-2"><el-input v-model="redisListText" type="textarea" :rows="6" /></el-form-item>
<el-form-item label="磁盘挂载点 JSON" class="md:col-span-2"><el-input v-model="diskListText" type="textarea" :rows="3" placeholder='[{"mount_point":"/"}]' /></el-form-item>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
@@ -134,6 +152,7 @@
<el-form-item label="堆栈字段"><el-input v-model.trim="config.admin.zap.stacktrace_key" /></el-form-item> <el-form-item label="堆栈字段"><el-input v-model.trim="config.admin.zap.stacktrace_key" /></el-form-item>
<el-form-item label="保留天数"><el-input-number v-model="config.admin.zap.retention_day" :min="0" class="!w-full" /></el-form-item> <el-form-item label="保留天数"><el-input-number v-model="config.admin.zap.retention_day" :min="0" class="!w-full" /></el-form-item>
<el-form-item label="访问日志最大字节"><el-input-number v-model="config.admin.zap.access_log_max_bytes" :min="1024" class="!w-full" /></el-form-item> <el-form-item label="访问日志最大字节"><el-input-number v-model="config.admin.zap.access_log_max_bytes" :min="1024" class="!w-full" /></el-form-item>
<el-form-item label="仅写文件的模块(每行一个)"><el-input v-model="fileOnlyModulesText" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="输出选项" class="md:col-span-2"><div class="flex flex-wrap gap-5"><el-switch v-model="config.admin.zap.log_in_console" active-text="控制台" /><el-switch v-model="config.admin.zap.show_line" active-text="调用行" /><el-switch v-model="config.admin.zap.access_req_body" active-text="请求体" /><el-switch v-model="config.admin.zap.access_resp_data" active-text="响应体" /><el-switch v-model="config.admin.zap.access_req_headers" active-text="请求头" /></div></el-form-item> <el-form-item label="输出选项" class="md:col-span-2"><div class="flex flex-wrap gap-5"><el-switch v-model="config.admin.zap.log_in_console" active-text="控制台" /><el-switch v-model="config.admin.zap.show_line" active-text="调用行" /><el-switch v-model="config.admin.zap.access_req_body" active-text="请求体" /><el-switch v-model="config.admin.zap.access_resp_data" active-text="响应体" /><el-switch v-model="config.admin.zap.access_req_headers" active-text="请求头" /></div></el-form-item>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
@@ -143,7 +162,7 @@
<el-form-item label="节点标识"><el-input v-model.trim="config.admin.app.node" /></el-form-item> <el-form-item label="节点标识"><el-input v-model.trim="config.admin.app.node" /></el-form-item>
<el-form-item label="应用 ID"><el-input v-model.trim="config.admin.app.app_id" /></el-form-item> <el-form-item label="应用 ID"><el-input v-model.trim="config.admin.app.app_id" /></el-form-item>
<el-form-item label="运行环境"><el-input v-model.trim="config.admin.app.env" /></el-form-item> <el-form-item label="运行环境"><el-input v-model.trim="config.admin.app.env" /></el-form-item>
<el-form-item label="CORS 模式"><el-select v-model="config.admin.cors.mode" class="!w-full"><el-option label="白名单" value="whitelist" /><el-option label="全部放行" value="allow-all" /></el-select></el-form-item> <el-form-item label="CORS 模式"><el-select v-model="config.admin.cors.mode" class="!w-full"><el-option label="白名单" value="whitelist" /><el-option label="严格白名单" value="strict-whitelist" /><el-option label="全部放行" value="allow-all" /></el-select></el-form-item>
<el-form-item label="CORS 白名单 JSON" class="md:col-span-2"><el-input v-model="corsRulesText" type="textarea" :rows="7" /></el-form-item> <el-form-item label="CORS 白名单 JSON" class="md:col-span-2"><el-input v-model="corsRulesText" type="textarea" :rows="7" /></el-form-item>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
@@ -218,6 +237,7 @@
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getSystemConfig, reloadSystem, setSystemConfig } from '@/api/system' import { getSystemConfig, reloadSystem, setSystemConfig } from '@/api/system'
import { emailTest } from '@/api/email' import { emailTest } from '@/api/email'
import { CreateUUID } from '@/utils/format'
defineOptions({ name: 'Config' }) defineOptions({ name: 'Config' })
@@ -229,10 +249,10 @@
routerPrefix: '', routerPrefix: '',
jwt: { signingKey: '******', expiresTime: '168h', bufferTime: '24h', issuer: 'kra' }, jwt: { signingKey: '******', expiresTime: '168h', bufferTime: '24h', issuer: 'kra' },
captcha: { keyLong: 6, imgWidth: 240, imgHeight: 80, storeExpiration: '3m' }, captcha: { keyLong: 6, imgWidth: 240, imgHeight: 80, storeExpiration: '3m' },
local: { storePath: 'uploads/file', pathPrefix: 'uploads/file' }, media: { sessionTtl: 24, maxFileSize: 0 }, local: { storePath: 'uploads/file', pathPrefix: 'uploads/file' }, media: { sessionTtl: 24, maxFileSize: 0, chunkDir: 'uploads/chunks' },
zap: { level: 'info', prefix: '[kra] ', format: 'json', director: 'logs', encode_level: 'LowercaseLevelEncoder', stacktrace_key: 'stacktrace', show_line: true, log_in_console: true, retention_day: 7, access_req_body: true, access_resp_data: true, access_req_headers: true, access_log_max_bytes: 1024, file_only_modules: [] }, zap: { level: 'info', prefix: '[kra] ', format: 'json', director: 'logs', encode_level: 'LowercaseLevelEncoder', stacktrace_key: 'stacktrace', show_line: true, log_in_console: true, retention_day: 7, access_req_body: true, access_resp_data: true, access_req_headers: true, access_log_max_bytes: 1024, file_only_modules: [] },
cors: { mode: 'whitelist', whitelist: [] }, app: { node: '', app_id: 'kra', env: 'development' }, cors: { mode: 'whitelist', whitelist: [] }, app: { node: '', app_id: 'kra', env: 'development' },
system: { useRedis: false, useMultipoint: false, useStrictAuth: false, disableAutoMigrate: false, useMongo: false }, system: { useRedis: false, useMultipoint: false, useStrictAuth: false, disableAutoMigrate: false, useMongo: false, addr: 8000, iplimitCount: 0, iplimitTime: 0 },
storage: { type: 'local', qiniu: {}, aliyun_oss: {}, huawei_obs: {}, tencent_cos: {}, aws_s3: {}, cloudflare_r2: {}, minio: {} } storage: { type: 'local', qiniu: {}, aliyun_oss: {}, huawei_obs: {}, tencent_cos: {}, aws_s3: {}, cloudflare_r2: {}, minio: {} }
}, },
email: { email: {
@@ -258,6 +278,8 @@
const databaseListText = ref('[]') const databaseListText = ref('[]')
const redisListText = ref('[]') const redisListText = ref('[]')
const corsRulesText = ref('[]') const corsRulesText = ref('[]')
const diskListText = ref('[]')
const fileOnlyModulesText = ref('')
const initForm = async () => { const initForm = async () => {
const res = await getSystemConfig() const res = await getSystemConfig()
@@ -296,6 +318,8 @@
databaseListText.value = JSON.stringify(config.value.data.database_list || [], null, 2) databaseListText.value = JSON.stringify(config.value.data.database_list || [], null, 2)
redisListText.value = JSON.stringify(config.value.data.redis_list || [], null, 2) redisListText.value = JSON.stringify(config.value.data.redis_list || [], null, 2)
corsRulesText.value = JSON.stringify(config.value.admin.cors.whitelist || [], null, 2) corsRulesText.value = JSON.stringify(config.value.admin.cors.whitelist || [], null, 2)
diskListText.value = JSON.stringify(config.value.admin.disk_list || [], null, 2)
fileOnlyModulesText.value = (config.value.admin.zap.file_only_modules || []).join('\n')
} }
} }
@@ -308,8 +332,10 @@
config.value.data.database_list = JSON.parse(databaseListText.value || '[]') config.value.data.database_list = JSON.parse(databaseListText.value || '[]')
config.value.data.redis_list = JSON.parse(redisListText.value || '[]') config.value.data.redis_list = JSON.parse(redisListText.value || '[]')
config.value.admin.cors.whitelist = JSON.parse(corsRulesText.value || '[]') config.value.admin.cors.whitelist = JSON.parse(corsRulesText.value || '[]')
config.value.admin.disk_list = JSON.parse(diskListText.value || '[]')
config.value.admin.zap.file_only_modules = fileOnlyModulesText.value.split('\n').map(item => item.trim()).filter(Boolean)
} catch { } catch {
ElMessage.error('MongoDB 或多数据库 JSON 格式不正确') ElMessage.error('JSON 配置格式不正确')
return return
} }
const res = await setSystemConfig({ config: config.value }) const res = await setSystemConfig({ config: config.value })
@@ -322,8 +348,17 @@
} }
} }
const generateSigningKey = () => {
config.value.admin.jwt.signingKey = CreateUUID()
}
const reload = async () => { const reload = async () => {
await ElMessageBox.confirm('确定要重载服务配置吗?', '提示') try {
await ElMessageBox.confirm('确定要重载服务配置吗?', '提示')
} catch {
ElMessage.info('取消重载')
return
}
const res = await reloadSystem() const res = await reloadSystem()
if (res.code === 0) ElMessage.success('配置已重载') if (res.code === 0) ElMessage.success('配置已重载')
} }
@@ -332,7 +367,12 @@
testing.value = true testing.value = true
try { try {
const res = await emailTest() const res = await emailTest()
if (res.code === 0) ElMessage.success('测试邮件发送成功') if (res.code === 0) {
ElMessage.success('测试邮件发送成功')
await initForm()
} else {
ElMessage.error('测试邮件发送失败')
}
} finally { } finally {
testing.value = false testing.value = false
} }