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 {
Create(context.Context, *Announcement) error
Delete(context.Context, uint) error
DeleteByIDs(context.Context, []uint) error
Delete(context.Context, string) error
DeleteByIDs(context.Context, []string) error
Update(context.Context, *Announcement) error
Find(context.Context, uint) (*Announcement, error)
Find(context.Context, string) (*Announcement, error)
List(context.Context, AnnouncementFilter) ([]*Announcement, int64, error)
UserOptions(context.Context) ([]UserOption, error)
}
@@ -50,15 +50,15 @@ func (uc *AnnouncementUsecase) Update(ctx context.Context, item *Announcement) e
return uc.repo.Update(ctx, item)
}
func (uc *AnnouncementUsecase) Delete(ctx context.Context, id uint) error {
func (uc *AnnouncementUsecase) Delete(ctx context.Context, id string) error {
return uc.repo.Delete(ctx, id)
}
func (uc *AnnouncementUsecase) DeleteByIDs(ctx context.Context, ids []uint) error {
func (uc *AnnouncementUsecase) DeleteByIDs(ctx context.Context, ids []string) error {
return uc.repo.DeleteByIDs(ctx, ids)
}
func (uc *AnnouncementUsecase) Find(ctx context.Context, id uint) (*Announcement, error) {
func (uc *AnnouncementUsecase) Find(ctx context.Context, id string) (*Announcement, error) {
return uc.repo.Find(ctx, id)
}
+7 -1
View File
@@ -79,7 +79,13 @@ func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff
for _, item := range stored {
storedSet[key(item)] = item
}
diff := &APISyncDiff{Ignored: ignored}
// The sync API always returns JSON arrays for all three collections,
// including when no routes were added or removed.
diff := &APISyncDiff{
Added: make([]*API, 0),
Deleted: make([]*API, 0),
Ignored: ignored,
}
added := make(map[string]bool)
for _, item := range routes {
routeKey := key(item)
+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 {
return nil, err
}
user, err := uc.users.User(ctx, claims.ID)
if err != nil {
return nil, err
}
// IssueToken receives the remaining lifetime rather than zero so the new
// token expires at the same instant as the token it replaced.
remaining := time.Until(claims.ExpiresAt)
issued, err := uc.issuer.IssueToken(user, authorityID, claims.MustChangePwd, remaining)
// Role switching signs the current claims again after replacing only the
// authority ID. It must not reload mutable profile fields or reset JWT
// timing/buffer claims as if this were a fresh login.
issued, err := uc.issuer.ReissueToken(claims, authorityID)
if err != nil {
return nil, err
}
user := &User{ID: claims.ID, UUID: claims.UUID, Username: claims.Username, NickName: claims.NickName, AuthorityID: authorityID, MustChangePassword: claims.MustChangePwd}
return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: claims.MustChangePwd}, nil
}
+60
View File
@@ -18,6 +18,10 @@ func (r *authenticationUserRepo) FindUserByUsername(context.Context, string) (*U
return r.user, nil
}
func (*authenticationUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) {
return true, nil
}
type authenticationSecurityRepo struct{ SecurityRepo }
func (*authenticationSecurityRepo) SecurityConfig(context.Context) (*SecurityConfig, error) {
@@ -48,11 +52,42 @@ func (*authenticationIssuer) IssueToken(*User, uint, bool, time.Duration) (*Issu
return &IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil
}
func (*authenticationIssuer) ReissueToken(claims *AuthClaims, _ uint) (*IssuedToken, error) {
return &IssuedToken{Value: "token", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
}
type authenticationAudit struct {
AuditRecordRepo
logins []*LoginLog
}
type switchAuthorityUserRepo struct {
UserRepo
setUserID, setAuthorityID uint
findCalls int
}
func (r *switchAuthorityUserRepo) SetUserAuthority(_ context.Context, userID, authorityID uint) error {
r.setUserID, r.setAuthorityID = userID, authorityID
return nil
}
func (r *switchAuthorityUserRepo) FindUserByID(context.Context, uint) (*User, error) {
r.findCalls++
return &User{NickName: "changed-in-database"}, nil
}
type switchAuthorityIssuer struct {
TokenIssuer
claims *AuthClaims
authorityID uint
}
func (i *switchAuthorityIssuer) ReissueToken(claims *AuthClaims, authorityID uint) (*IssuedToken, error) {
i.claims, i.authorityID = claims, authorityID
return &IssuedToken{Value: "switched", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
}
func (a *authenticationAudit) RecordLogin(_ context.Context, value *LoginLog) error {
a.logins = append(a.logins, value)
return nil
@@ -77,3 +112,28 @@ func TestLoginRecordsSuccessBeforeMultipointCacheFailure(t *testing.T) {
t.Fatalf("expected successful login audit before cache failure, got %+v", audit.logins)
}
}
func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T) {
repo := &switchAuthorityUserRepo{}
issuer := &switchAuthorityIssuer{}
uc := NewAuthenticationUsecase(NewUserUsecase(repo), nil, issuer, nil)
expiresAt := time.Now().Add(time.Hour).Truncate(time.Second)
claims := &AuthClaims{ID: 7, UUID: "uuid", Username: "admin", NickName: "token-nickname", AuthorityID: 888, MustChangePwd: true, ExpiresAt: expiresAt}
result, err := uc.SwitchAuthority(context.Background(), claims, 999)
if err != nil {
t.Fatal(err)
}
if repo.setUserID != 7 || repo.setAuthorityID != 999 {
t.Fatalf("stored authority switch = user:%d authority:%d", repo.setUserID, repo.setAuthorityID)
}
if repo.findCalls != 0 {
t.Fatalf("switch reloaded user %d times", repo.findCalls)
}
if issuer.claims != claims || issuer.authorityID != 999 {
t.Fatalf("reissue input = claims:%p authority:%d", issuer.claims, issuer.authorityID)
}
if result.User.NickName != "token-nickname" || result.User.AuthorityID != 999 || !result.NeedChangePassword || !result.ExpiresAt.Equal(expiresAt) {
t.Fatalf("switch result = %+v", result)
}
}
+1
View File
@@ -31,6 +31,7 @@ type Authority struct {
DataScope int
DefaultRouter string
Children []*Authority
Menus []*Menu
}
type AuthorityUsecase struct{ AuthorityAccessRepo }
+2 -34
View File
@@ -37,6 +37,7 @@ type DictionaryDetailRepo interface {
FindDictionaryDetail(context.Context, uint) (*DictionaryDetail, error)
ListDictionaryDetails(context.Context, int, int, DictionaryDetailFilter) ([]*DictionaryDetail, int64, error)
DictionaryDetailTree(context.Context, uint, string) ([]*DictionaryDetail, error)
DictionaryDetailsByParent(context.Context, uint, *uint, bool) ([]*DictionaryDetail, error)
}
type Dictionary struct {
@@ -78,38 +79,5 @@ func (uc *DictionaryUsecase) ImportDictionary(ctx context.Context, dictionary *D
}
func (uc *DictionaryUsecase) DictionaryDetailsByParent(ctx context.Context, dictionaryID uint, parentID *uint, includeChildren bool) ([]*DictionaryDetail, error) {
items, _, err := uc.ListDictionaryDetails(ctx, 0, 0, DictionaryDetailFilter{DictionaryID: dictionaryID})
if err != nil {
return nil, err
}
direct := make([]*DictionaryDetail, 0)
for _, item := range items {
if (parentID == nil && item.ParentID == nil) || (parentID != nil && item.ParentID != nil && *item.ParentID == *parentID) {
direct = append(direct, item)
}
}
if !includeChildren {
return direct, nil
}
children := make(map[uint][]*DictionaryDetail)
for _, item := range items {
item.Children = []*DictionaryDetail{}
if item.ParentID != nil {
children[*item.ParentID] = append(children[*item.ParentID], item)
}
}
var attach func(*DictionaryDetail)
attach = func(item *DictionaryDetail) {
item.Children = children[item.ID]
if item.Children == nil {
item.Children = []*DictionaryDetail{}
}
for _, child := range item.Children {
attach(child)
}
}
for _, item := range direct {
attach(item)
}
return direct, nil
return uc.DictionaryRepo.DictionaryDetailsByParent(ctx, dictionaryID, parentID, includeChildren)
}
+3
View File
@@ -82,6 +82,8 @@ type AuthClaims struct {
BufferTime time.Duration
MustChangePwd bool
Issuer string
Audience []string
NotBefore time.Time
ExpiresAt time.Time
}
@@ -96,5 +98,6 @@ var (
type TokenIssuer interface {
IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error)
ReissueToken(*AuthClaims, uint) (*IssuedToken, error)
ParseToken(string) (*AuthClaims, error)
}
+10 -5
View File
@@ -132,10 +132,11 @@ func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string,
if limit <= 0 {
limit = 100
}
// Multipart chunks are staged on the local filesystem, so listOssFiles
// never exposes them. Kra's storage abstraction may stage them through
// the selected backend; hide the internal prefix to preserve the API.
chunkRoot := ".chunks"
// Multipart chunks are an internal staging detail. The compatible local
// implementation keeps them outside the configured object-storage root, so
// the public listing must not expose them even when the selected backend is
// also used for staging.
chunkRoot := "uploads/chunks"
if uc.settings != nil {
if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" {
chunkRoot = configured
@@ -145,7 +146,11 @@ func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string,
result := make([]*StoredFile, 0, limit)
nextCursor := cursor
for {
items, next, more, err := uc.files.List(ctx, prefix, nextCursor, limit)
// Ask only for the remaining visible capacity. Otherwise a page that is
// partially filtered could fill the response halfway through the next
// storage page while returning that page's end cursor, skipping objects.
remaining := limit - len(result)
items, next, more, err := uc.files.List(ctx, prefix, nextCursor, remaining)
if err != nil {
return nil, "", false, err
}
+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)
SetAuthorityMenus(context.Context, uint, []uint) error
AuthorityMenuIDs(context.Context, uint) ([]uint, error)
// ListAuthorityMenus returns the base menus assigned to the target
// authority. Unlike List, it must not apply the current actor's strict
// menu filter: the administration endpoint is explicitly querying another
// role's assignments.
ListAuthorityMenus(context.Context, uint) ([]*Menu, error)
MenuRoleIDs(context.Context, uint) ([]uint, error)
DefaultRouterRoleIDs(context.Context, uint) ([]uint, error)
SetMenuRoles(context.Context, uint, []uint) error
@@ -79,10 +84,10 @@ func (uc *MenuUsecase) Tree(ctx context.Context) ([]*Menu, error) {
}
byID := make(map[uint]*Menu, len(items))
for _, item := range items {
item.Children = []*Menu{}
item.Children = nil
byID[item.ID] = item
}
roots := make([]*Menu, 0)
var roots []*Menu
for _, item := range items {
if parent := byID[item.ParentID]; parent != nil {
parent.Children = append(parent.Children, item)
@@ -114,31 +119,11 @@ func (uc *MenuUsecase) SetAuthorityMenus(ctx context.Context, authorityID uint,
}
func (uc *MenuUsecase) AuthorityMenus(ctx context.Context, authorityID uint) ([]*Menu, error) {
ids, err := uc.repo.AuthorityMenuIDs(ctx, authorityID)
items, err := uc.repo.ListAuthorityMenus(ctx, authorityID)
if err != nil {
return nil, err
}
items, err := uc.repo.List(ctx)
if err != nil {
return nil, err
}
selected := make(map[uint]struct{}, len(ids))
for _, id := range ids {
selected[id] = struct{}{}
}
result := make([]*Menu, 0, len(ids))
for _, item := range items {
if _, ok := selected[item.ID]; ok {
// The authority-menu endpoint returns menu relations only. Keep its
// existing contract: button details are loaded by the dedicated
// authority-button endpoints. The compatible query does not preload either
// relation here, so parameters must remain absent as well.
item.Buttons = nil
item.Parameters = nil
result = append(result, item)
}
}
return result, nil
return items, nil
}
func (uc *MenuUsecase) RoleIDs(ctx context.Context, menuID uint) ([]uint, error) {
+3 -2
View File
@@ -20,8 +20,9 @@ type SystemParameter struct {
type ParameterRepo interface {
CreateParameter(context.Context, *SystemParameter) error
UpdateParameter(context.Context, *SystemParameter) error
DeleteParameters(context.Context, []uint) error
FindParameter(context.Context, uint, string) (*SystemParameter, error)
DeleteParameters(context.Context, []string) error
FindParameterByID(context.Context, string) (*SystemParameter, error)
FindParameterByKey(context.Context, string) (*SystemParameter, error)
ListParameters(context.Context, int, int, *SystemParameter) ([]*SystemParameter, int64, error)
}
+1 -1
View File
@@ -8,7 +8,7 @@ type PermissionRepo interface {
AuthorityButtonIDs(context.Context, uint) ([]uint, error)
SelectedButtons(context.Context, uint, uint) ([]uint, error)
SetSelectedButtons(context.Context, uint, uint, []uint) error
CanRemoveButton(context.Context, uint) (bool, error)
CanRemoveButton(context.Context, string) (bool, error)
}
type PermissionUsecase struct{ PermissionRepo }
+8 -1
View File
@@ -99,7 +99,7 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
uc.mu.RUnlock()
value, err := uc.repo.SecurityConfig(ctx)
if err != nil {
// GVA returns its default value together with the database-not-ready
// Return the default value together with the database-not-ready
// error. Callers such as login/runtime policy consumers intentionally
// ignore the error and continue with that default, while the HTTP
// settings endpoint still reports the failure.
@@ -115,6 +115,13 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
return value, nil
}
// Security returns the persisted row for the administration endpoint. Runtime
// policy consumers use Current so their hot path remains cached, while the
// settings page mirrors the reference behavior and always observes storage.
func (uc *SecurityUsecase) Security(ctx context.Context) (*SecurityConfig, error) {
return uc.repo.SecurityConfig(ctx)
}
func (uc *SecurityUsecase) ValidatePassword(value *SecurityConfig, password string) error {
if value.PwdMinLength > 0 && utf8.RuneCountInString(password) < value.PwdMinLength {
return fmt.Errorf("密码长度不能少于 %d 位", value.PwdMinLength)
+29
View File
@@ -81,3 +81,32 @@ func TestUpdateSecurityDoesNotBackfillWithoutDisabledToEnabledTransition(t *test
t.Fatalf("call order = %v, want [get save]", got)
}
}
func TestSecurityReadsPersistedValueWhileCurrentUsesCache(t *testing.T) {
repo := &securityUpdateRepo{current: &SecurityConfig{ID: 1, LimitCount: 10}}
uc := NewSecurityUsecase(repo, nil, nil, nil)
cached, err := uc.Current(context.Background())
if err != nil {
t.Fatal(err)
}
if cached.LimitCount != 10 {
t.Fatalf("initial cached limit = %d", cached.LimitCount)
}
repo.current.LimitCount = 20
current, err := uc.Current(context.Background())
if err != nil {
t.Fatal(err)
}
if current.LimitCount != 10 {
t.Fatalf("Current() bypassed cache: %d", current.LimitCount)
}
fresh, err := uc.Security(context.Background())
if err != nil {
t.Fatal(err)
}
if fresh.LimitCount != 20 {
t.Fatalf("Security() returned stale value: %d", fresh.LimitCount)
}
}
+21 -28
View File
@@ -38,6 +38,8 @@ type UserListFilter struct {
type UserRepo interface {
FindUserByUsername(context.Context, string) (*User, error)
FindUserByID(context.Context, uint) (*User, error)
FindUserByUUID(context.Context, string) (*User, error)
HasAuthorityMenu(context.Context, uint, string) (bool, error)
MenusByAuthority(context.Context, uint) ([]*Menu, error)
ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error)
CreateUser(context.Context, *User) (*User, error)
@@ -77,23 +79,29 @@ func (uc *UserUsecase) User(ctx context.Context, id uint) (*User, error) {
return user, nil
}
func (uc *UserUsecase) fallbackDefaultRouter(ctx context.Context, user *User) {
if user == nil || user.Authority.DefaultRouter == "" {
return
}
menus, err := uc.repo.MenusByAuthority(ctx, user.AuthorityID)
if err != nil || !menuNameExists(menus, user.Authority.DefaultRouter) {
user.Authority.DefaultRouter = "404"
// UserByUUID resolves the identity from the UUID embedded in the JWT rather
// than from the mutable numeric ID used by administrative records.
func (uc *UserUsecase) UserByUUID(ctx context.Context, uuid string) (*User, error) {
user, err := uc.repo.FindUserByUUID(ctx, uuid)
if err != nil {
return nil, err
}
_ = uc.repo.FillDepartmentNamePaths(ctx, user)
uc.fallbackDefaultRouter(ctx, user)
return user, nil
}
func menuNameExists(menus []*Menu, name string) bool {
for _, menu := range menus {
if menu.Name == name || menuNameExists(menu.Children, name) {
return true
}
func (uc *UserUsecase) fallbackDefaultRouter(ctx context.Context, user *User) {
if user == nil {
return
}
hasMenu, err := uc.repo.HasAuthorityMenu(ctx, user.AuthorityID, user.Authority.DefaultRouter)
if err != nil {
return
}
if !hasMenu {
user.Authority.DefaultRouter = "404"
}
return false
}
func (uc *UserUsecase) Menus(ctx context.Context, authorityID uint) ([]*Menu, error) {
@@ -104,10 +112,6 @@ func (uc *UserUsecase) ListUsers(ctx context.Context, page, pageSize int, filter
return uc.repo.ListUsers(ctx, page, pageSize, filter)
}
func (uc *UserUsecase) CreateUser(ctx context.Context, user *User, authorityIDs []uint) (*User, error) {
if user.AuthorityID == 0 && len(authorityIDs) > 0 {
user.AuthorityID = authorityIDs[0]
}
authorityIDs = includeAuthority(authorityIDs, user.AuthorityID)
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
@@ -132,17 +136,6 @@ func (uc *UserUsecase) UpdateSelfUser(ctx context.Context, user *User) error {
return uc.repo.UpdateSelfUser(ctx, user)
}
func includeAuthority(ids []uint, primary uint) []uint {
if primary == 0 {
return ids
}
for _, id := range ids {
if id == primary {
return ids
}
}
return append(ids, primary)
}
func (uc *UserUsecase) DeleteUser(ctx context.Context, id uint) error {
return uc.repo.DeleteUser(ctx, id)
}
+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
CreatedAt time.Time
UpdatedAt time.Time
Name string
Code string
Description string
Data string
Name *string
Code *string
Description *string
Data *string
}
type VersionBundle struct {
Name string
+6 -6
View File
@@ -44,12 +44,12 @@ func (r *announcementRepo) Create(ctx context.Context, item *biz.Announcement) e
return nil
}
func (r *announcementRepo) Delete(ctx context.Context, id uint) error {
return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, id).Error
func (r *announcementRepo) Delete(ctx context.Context, id string) error {
return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, "id = ?", id).Error
}
func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []uint) error {
return r.data.gormDB.WithContext(ctx).Where("id IN ?", ids).Delete(&announcementPO{}).Error
func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []string) error {
return r.data.gormDB.WithContext(ctx).Delete(&[]announcementPO{}, "id IN ?", ids).Error
}
func (r *announcementRepo) Update(ctx context.Context, item *biz.Announcement) error {
@@ -57,9 +57,9 @@ func (r *announcementRepo) Update(ctx context.Context, item *biz.Announcement) e
return r.data.gormDB.WithContext(ctx).Model(&announcementPO{}).Where("id = ?", item.ID).Updates(&po).Error
}
func (r *announcementRepo) Find(ctx context.Context, id uint) (*biz.Announcement, error) {
func (r *announcementRepo) Find(ctx context.Context, id string) (*biz.Announcement, error) {
var po announcementPO
if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil {
if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil {
return nil, err
}
return announcementToBiz(po), nil
+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"
casbinmodel "github.com/casbin/casbin/v3/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type apiRepo struct{ data *Data }
@@ -167,11 +168,11 @@ func (r *apiRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]*
}
}
var pos []apiPO
maxSize := 100
if q != nil && q.StrictAll {
maxSize = 0
query := db.Order(order)
if q == nil || !q.StrictAll {
query = applyRequiredPagination(query, page, size, 100)
}
if err := applyPagination(db.Order(order), page, size, maxSize).Find(&pos).Error; err != nil {
if err := query.Find(&pos).Error; err != nil {
return nil, 0, err
}
out := make([]*biz.API, 0, len(pos))
@@ -248,7 +249,10 @@ func (r *apiRepo) PolicyPaths(ctx context.Context, aid uint) ([]*biz.API, error)
if err != nil {
return nil, err
}
out := make([]*biz.API, 0, len(rows))
var out []*biz.API
if len(rows) > 0 {
out = make([]*biz.API, 0, len(rows))
}
for _, row := range rows {
out = append(out, &biz.API{Path: row.V1, Method: row.V2})
}
@@ -264,46 +268,64 @@ func (r *apiRepo) SetPolicyPaths(ctx context.Context, aid uint, paths []*biz.API
if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil {
return err
}
if authority.ParentID != nil && *authority.ParentID != 0 {
allowed, err := r.PolicyPaths(ctx, actor.AuthorityID)
var registered []apiPO
if err := r.data.gormDB.WithContext(ctx).Find(&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 {
return err
}
allowedSet := make(map[string]bool, len(allowed))
for _, item := range allowed {
allowedSet[item.Path+"\x00"+item.Method] = true
policySet := make(map[string]bool, len(policies))
for _, item := range policies {
policySet[item.V1+"\x00"+item.V2] = true
}
for _, item := range paths {
if !allowedSet[item.Path+"\x00"+item.Method] {
return errors.New("存在api不在权限列表中")
for _, item := range registered {
key := item.Path + "\x00" + item.Method
if policySet[key] {
allowedSet[key] = true
}
}
}
}
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := deletePoliciesForAuthority(tx, aid); err != nil {
return err
}
// Keep the legacy relation table clean for upgraded installations. It
// is not consulted for authorization anymore.
if err := tx.Where("authority_id = ?", aid).Delete(&authorityAPIPO{}).Error; err != nil {
return err
}
rules := make([]casbinRulePO, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
key := path.Path + "\x00" + path.Method
if _, ok := seen[key]; ok {
continue
for _, item := range paths {
if !allowedSet[item.Path+"\x00"+item.Method] {
return errors.New("存在api不在权限列表中")
}
seen[key] = struct{}{}
rules = append(rules, newPolicyRule(aid, path.Path, path.Method))
}
if len(rules) > 0 {
return tx.Create(&rules).Error
}
db := r.data.gormDB.WithContext(ctx)
// The reference enforcer removes the old authority policies before it
// attempts to add the replacement set. Keep that ordering visible even
// though Kra reads policies directly from the database rather than through
// an in-memory enforcer.
if err := deletePoliciesForAuthority(db, aid); err != nil {
return err
}
// Keep the legacy relation table clean for upgraded installations. It is
// not consulted for authorization anymore.
if err := db.Where("authority_id = ?", aid).Delete(&authorityAPIPO{}).Error; err != nil {
return err
}
rules := make([]casbinRulePO, 0, len(paths))
seen := make(map[string]struct{})
for _, path := range paths {
key := strconv.FormatUint(uint64(aid), 10) + path.Path + path.Method
if _, ok := seen[key]; ok {
continue
}
return nil
})
seen[key] = struct{}{}
rules = append(rules, newPolicyRule(aid, path.Path, path.Method))
}
if len(rules) > 0 {
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rules).Error
}
return nil
}
func (r *apiRepo) IgnoredAPIs(ctx context.Context) ([]*biz.API, error) {
+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
}
var pos []apiTokenPO
if err := applyPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil {
if err := applyRequiredPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err
}
userIDs := make([]uint, 0, len(pos))
+106 -42
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 {
var count int64
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
// The administration contract checks existence with First and treats every result other than
// ErrRecordNotFound as a duplicate-role error. Keep that precedence so
// duplicate IDs win over strict-tree validation and the API message stays
// compatible.
var existing authorityPO
if err := tx.Where("authority_id = ?", value.AuthorityID).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("存在相同角色id")
}
if value.DefaultRouter == "" {
@@ -92,6 +93,8 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au
return err
}
value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt
value.DataScope, value.DefaultRouter = po.DataScope, po.DefaultRouter
value.Menus = []*biz.Menu{{ID: 1, Path: "dashboard", Name: "dashboard", Component: "view/dashboard/index.vue", Sort: 1, Title: "仪表盘", Icon: "setting"}}
var dashboard menuPO
if err := tx.Where("name = ?", "dashboard").First(&dashboard).Error; err == nil {
if err = tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: value.AuthorityID, SysBaseMenuID: dashboard.ID}).Error; err != nil {
@@ -113,10 +116,18 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
config := r.data.runtime.Admin()
actor, hasActor := biz.ActorFromContext(ctx)
// Strict-authority checks apply to every actor. A root actor is
// allowed to create/copy a role directly below itself; non-root actors
// may target themselves' descendants. Do not special-case 888 here:
// the configured strict-auth switch is the source of truth.
// Reject a duplicate target ID before performing the
// hierarchy/Casbin checks that happen later in UpdateCasbin.
var existing authorityPO
if err := tx.Where("authority_id = ?", value.AuthorityID).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("存在相同角色id")
}
// UpdateCasbin checks that the newly-created role is in the actor's
// strict-authority subtree. Before inserting it, the equivalent check is
// that its requested parent is the actor or one of the actor's allowed
// descendants. This preserves the compatible error and prevents a transient role
// from being visible when the check is guaranteed to fail.
strict := hasActor && config != nil && config.System != nil && config.System.UseStrictAuth
if strict {
allowed, err := r.strictAuthorityIDs(ctx, actor.AuthorityID)
@@ -128,18 +139,12 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
return errors.New("您提交的角色ID不合法")
}
}
var count int64
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil {
return err
}
if count != 0 {
return errors.New("存在相同角色id")
}
po := authorityPO{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}
if err := tx.Create(&po).Error; err != nil {
return err
}
value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt
value.DataScope, value.DefaultRouter = po.DataScope, po.DefaultRouter
copyLinks := func(table string, destination any, columns map[string]any) error {
return tx.Table(table).Where(columns).Find(destination).Error
}
@@ -155,6 +160,20 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
return err
}
}
if len(menus) > 0 {
menuIDs := make([]uint, 0, len(menus))
for _, menu := range menus {
menuIDs = append(menuIDs, menu.SysBaseMenuID)
}
var copiedMenus []menuPO
if err := tx.Where("id IN ?", menuIDs).Order("sort").Find(&copiedMenus).Error; err != nil {
return err
}
value.Menus = make([]*biz.Menu, 0, len(copiedMenus))
for _, menu := range copiedMenus {
value.Menus = append(value.Menus, menuFromPO(menu))
}
}
apis, err := policyRowsForAuthority(tx, sourceID)
if err != nil {
return err
@@ -163,7 +182,7 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
seenPolicies := make(map[string]struct{}, len(apis))
for _, api := range apis {
if strict {
allowed, err := policyExists(tx, actor.AuthorityID, api.V1, api.V2)
allowed, err := r.copyPolicyAllowed(ctx, tx, actor.AuthorityID, api.V1, api.V2)
if err != nil {
return err
}
@@ -198,6 +217,28 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
return nil
})
}
// copyPolicyAllowed mirrors the administration API-list and policy validation.
// A policy must refer to a currently registered API. Root roles
// may copy any registered API, while non-root roles may copy only APIs present
// in their own Casbin policy set.
func (r *authorityAccessRepo) copyPolicyAllowed(ctx context.Context, tx *gorm.DB, actorID uint, path, method string) (bool, error) {
var actor authorityPO
if err := tx.WithContext(ctx).Where("authority_id = ?", actorID).First(&actor).Error; err != nil {
return false, err
}
var apiCount int64
if err := tx.WithContext(ctx).Model(&apiPO{}).Where("path = ? AND method = ?", path, method).Count(&apiCount).Error; err != nil {
return false, err
}
if apiCount == 0 {
return false, nil
}
if actor.ParentID == nil || *actor.ParentID == 0 {
return true, nil
}
return policyExists(tx.WithContext(ctx), actorID, path, method)
}
func (r *authorityAccessRepo) UpdateAuthority(ctx context.Context, value *biz.Authority) error {
db := r.data.gormDB.WithContext(ctx)
var current authorityPO
@@ -217,11 +258,21 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
return err
}
var users, children int64
if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Count(&users).Error; err != nil {
// Associated-user checks only block on users that still
// exist (and are not soft-deleted). Counting raw join rows would make an
// orphaned association impossible to clean up, which is observably
// different from the administration contract.
var linkedUserIDs []uint
if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_user_id", &linkedUserIDs).Error; err != nil {
return err
}
if users > 0 {
return errors.New("此角色有用户正在使用禁止删除")
if len(linkedUserIDs) > 0 {
if err := tx.Model(&userPO{}).Where("id IN ?", linkedUserIDs).Count(&users).Error; err != nil {
return err
}
if users > 0 {
return errors.New("此角色有用户正在使用禁止删除")
}
}
if err := tx.Model(&userPO{}).Where("authority_id = ?", id).Count(&users).Error; err != nil {
return err
@@ -235,6 +286,9 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
if children > 0 {
return errors.New("此角色存在子角色不允许删除")
}
if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil {
return err
}
if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil {
return err
}
@@ -253,18 +307,28 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
})
}
func (r *authorityAccessRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) {
var pos []authorityPO
if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil {
return nil, err
}
db := r.data.gormDB.WithContext(ctx)
var allowed map[uint]bool
config := r.data.runtime.Admin()
if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth {
var err error
allowed, err = r.strictAuthorityIDs(ctx, actor.AuthorityID)
if err != nil {
if actor, ok := biz.ActorFromContext(ctx); ok {
// The current authority is loaded even when strict mode is disabled;
// an invalid token authority therefore fails the list request instead of
// exposing the complete role tree.
var current authorityPO
if err := db.Where("authority_id = ?", actor.AuthorityID).First(&current).Error; err != nil {
return nil, err
}
if config != nil && config.System != nil && config.System.UseStrictAuth {
var err error
allowed, err = r.strictAuthorityIDs(ctx, actor.AuthorityID)
if err != nil {
return nil, err
}
}
}
var pos []authorityPO
if err := db.Find(&pos).Error; err != nil {
return nil, err
}
out := make([]*biz.Authority, 0, len(pos))
for _, po := range pos {
@@ -285,15 +349,6 @@ func (r *authorityAccessRepo) SetAuthorityUsers(ctx context.Context, id uint, id
if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil {
return err
}
links := make([]userAuthorityPO, 0, len(ids))
for _, uid := range ids {
links = append(links, userAuthorityPO{SysUserID: uid, SysAuthorityAuthorityID: id})
}
if len(links) > 0 {
if err := tx.Create(&links).Error; err != nil {
return err
}
}
selected := map[uint]bool{}
for _, uid := range ids {
selected[uid] = true
@@ -305,16 +360,25 @@ func (r *authorityAccessRepo) SetAuthorityUsers(ctx context.Context, id uint, id
var user userPO
if err := tx.First(&user, uid).Error; err == nil && user.AuthorityID == id {
var another userAuthorityPO
if err = tx.Where("sys_user_id = ?", uid).First(&another).Error; errors.Is(err, gorm.ErrRecordNotFound) {
if err = tx.Where("sys_user_id = ?", uid).First(&another).Error; err != nil {
// Missing users and association lookup errors are ignored
// while choosing a replacement role.
continue
} else if err != nil {
return err
}
if err = tx.Model(&user).Update("authority_id", another.SysAuthorityAuthorityID).Error; err != nil {
return err
}
}
}
links := make([]userAuthorityPO, 0, len(ids))
for _, uid := range ids {
links = append(links, userAuthorityPO{SysUserID: uid, SysAuthorityAuthorityID: id})
}
if len(links) > 0 {
if err := tx.Create(&links).Error; err != nil {
return err
}
}
return nil
})
}
@@ -328,7 +392,7 @@ func (r *authorityAccessRepo) SetDataScope(ctx context.Context, id uint, scope i
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", id).Update("data_scope", scope).Error; err != nil {
return err
}
if err := tx.Where("authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil {
if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil {
return err
}
if scope == 5 {
@@ -345,7 +409,7 @@ func (r *authorityAccessRepo) SetDataScope(ctx context.Context, id uint, scope i
}
func (r *authorityAccessRepo) DataScopeDepartmentIDs(ctx context.Context, id uint) ([]uint, error) {
var ids []uint
err := r.data.gormDB.WithContext(ctx).Model(&authorityDepartmentPO{}).Where("authority_id = ?", id).Pluck("department_id", &ids).Error
err := r.data.gormDB.WithContext(ctx).Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_department_id", &ids).Error
return ids, err
}
func (r *authorityAccessRepo) ResolveDataScope(ctx context.Context, authorityID, userID uint) (biz.DataScope, error) {
+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)
}
// persistDatabaseConfig writes only the database selected on the init page.
// Initialization must not serialize the partially populated runtime config
// back over the template: doing so removes all omitted/default settings from
// data and admin and leaves the next startup with no visible configuration.
func (d *Data) persistDatabaseConfig(database *conf.Data_Database) error {
// persistDatabaseConfig writes only the database selected on the init page and
// the freshly generated JWT signing key. Initialization must not serialize the
// partially populated runtime config back over the template: doing so removes
// all omitted/default settings from data and admin and leaves the next startup
// with no visible configuration.
func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey string) error {
d.configMu.Lock()
defer d.configMu.Unlock()
@@ -215,6 +216,11 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database) error {
if err = setYAMLMapping(&document, "data", map[string]any{"database": databaseValue}); err != nil {
return err
}
if signingKey != "" {
if err = setYAMLMapping(&document, "admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil {
return err
}
}
return writeConfigDocument(configPath, &document)
}
+2 -2
View File
@@ -96,8 +96,8 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
c = &conf.Data{}
}
if c.Database == nil {
// An empty database block is the Kratos equivalent of GVA's empty
// Dbname: the service starts on the bootstrap database so /init/checkdb
// An empty database block starts the service on the bootstrap database
// so /init/checkdb
// and /init/initdb remain available.
c.Database = &conf.Data_Database{}
}
+1 -1
View File
@@ -43,7 +43,7 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *
return nil, 0, err
}
var pos []dataAccessLogPO
if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err
}
out := make([]*biz.DataAccessLog, 0, len(pos))
+13 -5
View File
@@ -32,15 +32,17 @@ type departmentPO struct {
func (departmentPO) TableName() string { return "sys_departments" }
type userDepartmentPO struct {
UserID uint `gorm:"primaryKey;column:sys_user_id"`
DepartmentID uint `gorm:"primaryKey;column:sys_department_id"`
// Match GVA's join model: no composite primary key, so repeated IDs in a
// request are persisted in the same order/shape as the reference service.
UserID uint `gorm:"column:sys_user_id"`
DepartmentID uint `gorm:"column:sys_department_id"`
}
func (userDepartmentPO) TableName() string { return "sys_user_departments" }
type authorityDepartmentPO struct {
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
DepartmentID uint `gorm:"primaryKey;column:department_id"`
AuthorityID uint `gorm:"column:sys_authority_authority_id;index"`
DepartmentID uint `gorm:"column:sys_department_id"`
}
func (authorityDepartmentPO) TableName() string { return "sys_authority_departments" }
@@ -152,6 +154,12 @@ func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*b
items := make([]*biz.Department, 0, len(pos))
for _, po := range pos {
item := deptFromPO(po)
if name == "" {
// Recursive GORM child queries initialize leaf collections to an empty
// slice. Preserve that tree response shape while search and single-item
// queries keep the model's nil children value.
item.Children = []*biz.Department{}
}
nodes[po.ID] = item
items = append(items, item)
}
@@ -161,7 +169,7 @@ func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*b
if name != "" {
return items, nil
}
var roots []*biz.Department
roots := make([]*biz.Department, 0)
for _, po := range pos {
n := nodes[po.ID]
if p := nodes[po.ParentID]; p != nil {
+93 -46
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 {
var existing dictionaryPO
if err := r.data.gormDB.WithContext(ctx).Where("type = ?", dictionary.Type).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("存在相同的type,不允许导入")
}
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
po := dictionaryPO{Name: dictionary.Name, Type: dictionary.Type, Status: dictionary.Status, Desc: dictionary.Desc, ParentID: dictionary.ParentID}
if err := tx.Create(&po).Error; err != nil {
@@ -183,15 +187,13 @@ func (r *dictionaryRepo) FindDictionary(ctx context.Context, id uint, typ string
}
out := dictionaryFromPO(po)
if details {
out.Details, _, err = r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: out.ID})
if err == nil {
filtered := out.Details[:0]
for _, item := range out.Details {
if item.Status != nil && *item.Status {
filtered = append(filtered, item)
}
}
out.Details = filtered
var detailPOs []dictionaryDetailPO
if err = db.Where("sys_dictionary_id = ? AND status = ?", out.ID, true).Order("sort").Find(&detailPOs).Error; err != nil {
return nil, err
}
out.Details = make([]*biz.DictionaryDetail, 0, len(detailPOs))
for _, detail := range detailPOs {
out.Details = append(out.Details, detailFromPO(detail))
}
}
return out, err
@@ -203,11 +205,14 @@ func (r *dictionaryRepo) ExportDictionary(ctx context.Context, id uint) (*biz.Di
return nil, err
}
value := dictionaryFromPO(po)
details, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: id})
if err != nil {
var detailPOs []dictionaryDetailPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", id).Order("sort").Find(&detailPOs).Error; err != nil {
return nil, err
}
value.Details = details
value.Details = make([]*biz.DictionaryDetail, 0, len(detailPOs))
for _, detail := range detailPOs {
value.Details = append(value.Details, detailFromPO(detail))
}
return value, nil
}
func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, name, typ string, details bool) ([]*biz.Dictionary, int64, error) {
@@ -221,19 +226,26 @@ func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, n
return nil, 0, err
}
var pos []dictionaryPO
if err := applyPagination(db.Order("id"), page, size, 0).Find(&pos).Error; err != nil {
// Both dictionary-list endpoints are intentionally unpaged. The page and
// size parameters remain in the repository seam for existing callers, but
// the administration contract ignores them.
if err := db.Find(&pos).Error; err != nil {
return nil, 0, err
}
parentIDs := make([]uint, 0, len(pos))
dictionaryIDs := make([]uint, 0, len(pos))
for _, po := range pos {
parentIDs = append(parentIDs, po.ID)
dictionaryIDs = append(dictionaryIDs, po.ID)
// The reference list-with-details endpoint only preloads details;
// its ordinary list endpoint preloads one level of dictionary children.
if !details {
parentIDs = append(parentIDs, po.ID)
}
}
childrenByParent := make(map[uint][]dictionaryPO)
if len(parentIDs) > 0 {
if !details && len(parentIDs) > 0 {
var children []dictionaryPO
if err := r.data.gormDB.WithContext(ctx).Where("parent_id IN ?", parentIDs).Order("id").Find(&children).Error; err != nil {
if err := r.data.gormDB.WithContext(ctx).Where("parent_id IN ?", parentIDs).Find(&children).Error; err != nil {
return nil, 0, err
}
for _, child := range children {
@@ -255,13 +267,16 @@ func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, n
out := make([]*biz.Dictionary, 0, len(pos))
for _, po := range pos {
v := dictionaryFromPO(po)
for _, child := range childrenByParent[po.ID] {
v.Children = append(v.Children, dictionaryFromPO(child))
}
if details {
v.Details = make([]*biz.DictionaryDetail, 0, len(detailsByDictionary[v.ID]))
for _, detail := range detailsByDictionary[v.ID] {
v.Details = append(v.Details, detailFromPO(detail))
}
} else {
v.Children = make([]*biz.Dictionary, 0, len(childrenByParent[po.ID]))
for _, child := range childrenByParent[po.ID] {
v.Children = append(v.Children, dictionaryFromPO(child))
}
}
out = append(out, v)
}
@@ -309,28 +324,25 @@ func (r *dictionaryRepo) UpdateDictionaryDetail(ctx context.Context, v *biz.Dict
level = parent.Level + 1
path = strings.Trim(strings.Join([]string{parent.Path, fmt.Sprint(parent.ID)}, ","), ",")
}
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&dictionaryDetailPO{}).Where("id = ?", v.ID).Updates(map[string]any{"label": v.Label, "value": v.Value, "extend": v.Extend, "status": v.Status, "sort": v.Sort, "sys_dictionary_id": v.DictionaryID, "parent_id": v.ParentID, "level": level, "path": path}).Error; err != nil {
return err
}
return r.updateDictionaryDetailChildren(tx, v.ID, level, path)
})
if err := db.Model(&dictionaryDetailPO{}).Where("id = ?", v.ID).Updates(map[string]any{"label": v.Label, "value": v.Value, "extend": v.Extend, "status": v.Status, "sort": v.Sort, "sys_dictionary_id": v.DictionaryID, "parent_id": v.ParentID, "level": level, "path": path}).Error; err != nil {
return err
}
return r.updateDictionaryDetailChildren(db, v.ID, level, path)
}
// updateDictionaryDetailChildren recomputes descendants after a move, as the
// The service does this recursively. Updating level as well as path matters when a
// subtree is moved beneath a different depth.
func (r *dictionaryRepo) updateDictionaryDetailChildren(tx *gorm.DB, parentID uint, parentLevel int, parentPath string) error {
// updateDictionaryDetailChildren recomputes descendants after a move. Updating
// level as well as path matters when a subtree is moved beneath a different depth.
func (r *dictionaryRepo) updateDictionaryDetailChildren(db *gorm.DB, parentID uint, parentLevel int, parentPath string) error {
var children []dictionaryDetailPO
if err := tx.Where("parent_id = ?", parentID).Order("sort,id").Find(&children).Error; err != nil {
if err := db.Where("parent_id = ?", parentID).Find(&children).Error; err != nil {
return err
}
for _, child := range children {
childPath := strings.Trim(strings.Join([]string{parentPath, fmt.Sprint(parentID)}, ","), ",")
if err := tx.Model(&dictionaryDetailPO{}).Where("id = ?", child.ID).Updates(map[string]any{"level": parentLevel + 1, "path": childPath}).Error; err != nil {
if err := db.Model(&dictionaryDetailPO{}).Where("id = ?", child.ID).Updates(map[string]any{"level": parentLevel + 1, "path": childPath}).Error; err != nil {
return err
}
if err := r.updateDictionaryDetailChildren(tx, child.ID, parentLevel+1, childPath); err != nil {
if err := r.updateDictionaryDetailChildren(db, child.ID, parentLevel+1, childPath); err != nil {
return err
}
}
@@ -378,7 +390,7 @@ func (r *dictionaryRepo) ListDictionaryDetails(ctx context.Context, page, size i
return nil, 0, err
}
var pos []dictionaryDetailPO
if err := applyPagination(db.Order("sort,id"), page, size, 100).Find(&pos).Error; err != nil {
if err := applyRequiredPagination(db.Order("sort,id"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err
}
out := make([]*biz.DictionaryDetail, 0, len(pos))
@@ -398,22 +410,57 @@ func (r *dictionaryRepo) DictionaryDetailTree(ctx context.Context, dictionaryID
}
dictionaryID = dictionary.ID
}
items, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: dictionaryID})
if err != nil {
var pos []dictionaryDetailPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ? AND parent_id IS NULL", dictionaryID).Order("sort").Find(&pos).Error; err != nil {
return nil, err
}
byID := map[uint]*biz.DictionaryDetail{}
for _, item := range items {
item.Children = []*biz.DictionaryDetail{}
byID[item.ID] = item
}
roots := []*biz.DictionaryDetail{}
for _, item := range items {
if item.ParentID != nil && byID[*item.ParentID] != nil {
byID[*item.ParentID].Children = append(byID[*item.ParentID].Children, item)
} else if item.ParentID == nil {
roots = append(roots, item)
roots := make([]*biz.DictionaryDetail, 0, len(pos))
for _, po := range pos {
item := detailFromPO(po)
if err := r.loadDictionaryDetailChildren(ctx, item); err != nil {
return nil, err
}
roots = append(roots, item)
}
return roots, nil
}
func (r *dictionaryRepo) DictionaryDetailsByParent(ctx context.Context, dictionaryID uint, parentID *uint, includeChildren bool) ([]*biz.DictionaryDetail, error) {
db := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", dictionaryID)
if parentID == nil {
db = db.Where("parent_id IS NULL")
} else {
db = db.Where("parent_id = ?", *parentID)
}
var pos []dictionaryDetailPO
if err := db.Order("sort").Find(&pos).Error; err != nil {
return nil, err
}
items := make([]*biz.DictionaryDetail, 0, len(pos))
for _, po := range pos {
item := detailFromPO(po)
if includeChildren {
if err := r.loadDictionaryDetailChildren(ctx, item); err != nil {
return nil, err
}
}
items = append(items, item)
}
return items, nil
}
func (r *dictionaryRepo) loadDictionaryDetailChildren(ctx context.Context, parent *biz.DictionaryDetail) error {
var pos []dictionaryDetailPO
if err := r.data.gormDB.WithContext(ctx).Where("parent_id = ?", parent.ID).Order("sort").Find(&pos).Error; err != nil {
return err
}
parent.Children = make([]*biz.DictionaryDetail, 0, len(pos))
for _, po := range pos {
child := detailFromPO(po)
if err := r.loadDictionaryDetailChildren(ctx, child); err != nil {
return err
}
parent.Children = append(parent.Children, child)
}
return nil
}
@@ -0,0 +1,188 @@
package data
import (
"context"
"testing"
"kra/internal/biz"
)
func TestDictionaryListPreservesPreloadCollectionShapes(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
repo := &dictionaryRepo{data: data}
if err := repo.CreateDictionary(ctx, &biz.Dictionary{Name: "status", Type: "status"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateDictionary(ctx, &biz.Dictionary{Name: "kind", Type: "kind"}); err != nil {
t.Fatal(err)
}
plain, _, err := repo.ListDictionaries(ctx, 1, 1, "", "", false)
if err != nil {
t.Fatal(err)
}
if len(plain) != 2 || plain[0].Children == nil || len(plain[0].Children) != 0 {
t.Fatalf("ordinary dictionary list children = %#v, want an empty preloaded slice", plain)
}
if plain[0].Details != nil {
t.Fatalf("ordinary dictionary list details = %#v, want nil", plain[0].Details)
}
withDetails, _, err := repo.ListDictionaries(ctx, 1, 1, "", "", true)
if err != nil {
t.Fatal(err)
}
if len(withDetails) != 2 || withDetails[0].Details == nil || len(withDetails[0].Details) != 0 {
t.Fatalf("dictionary details preload = %#v, want an empty preloaded slice", withDetails)
}
if withDetails[0].Children != nil {
t.Fatalf("details-only dictionary list children = %#v, want nil", withDetails[0].Children)
}
}
func TestImportDictionaryRejectsDuplicateType(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
repo := &dictionaryRepo{data: data}
if err := repo.CreateDictionary(ctx, &biz.Dictionary{Name: "first", Type: "duplicate"}); err != nil {
t.Fatal(err)
}
if err := repo.ImportDictionary(ctx, &biz.Dictionary{Name: "second", Type: "duplicate"}, nil); err == nil || err.Error() != "存在相同的type,不允许导入" {
t.Fatalf("duplicate import error = %v", err)
}
var count int64
if err := data.gormDB.WithContext(ctx).Model(&dictionaryPO{}).Where("type = ?", "duplicate").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("duplicate dictionary count = %d, want 1", count)
}
}
func TestDictionaryTreeAndParentQueriesPreserveLoadedChildrenShape(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
repo := &dictionaryRepo{data: data}
dictionary := &biz.Dictionary{Name: "tree", Type: "tree"}
if err := repo.CreateDictionary(ctx, dictionary); err != nil {
t.Fatal(err)
}
parent := &biz.DictionaryDetail{Label: "parent", Value: "parent", DictionaryID: dictionary.ID, Sort: 2}
if err := repo.CreateDictionaryDetail(ctx, parent); err != nil {
t.Fatal(err)
}
parentID := parent.ID
child := &biz.DictionaryDetail{Label: "child", Value: "child", DictionaryID: dictionary.ID, ParentID: &parentID, Sort: 1}
if err := repo.CreateDictionaryDetail(ctx, child); err != nil {
t.Fatal(err)
}
tree, err := repo.DictionaryDetailTree(ctx, dictionary.ID, "")
if err != nil {
t.Fatal(err)
}
if len(tree) != 1 || len(tree[0].Children) != 1 || tree[0].Children[0].Children == nil {
t.Fatalf("dictionary tree = %#v, want recursively loaded child slices", tree)
}
flat, err := repo.DictionaryDetailsByParent(ctx, dictionary.ID, &parentID, false)
if err != nil {
t.Fatal(err)
}
if len(flat) != 1 || flat[0].Children != nil {
t.Fatalf("flat parent query = %#v, want nil children", flat)
}
loaded, err := repo.DictionaryDetailsByParent(ctx, dictionary.ID, &parentID, true)
if err != nil {
t.Fatal(err)
}
if len(loaded) != 1 || loaded[0].Children == nil || len(loaded[0].Children) != 0 {
t.Fatalf("recursive parent query = %#v, want an empty loaded child slice", loaded)
}
}
func TestDictionaryDetailListAppliesZeroLimit(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
repo := &dictionaryRepo{data: data}
dictionary := &biz.Dictionary{Name: "limited", Type: "limited"}
if err := repo.CreateDictionary(ctx, dictionary); err != nil {
t.Fatal(err)
}
if err := repo.CreateDictionaryDetail(ctx, &biz.DictionaryDetail{Label: "one", Value: "one", DictionaryID: dictionary.ID}); err != nil {
t.Fatal(err)
}
items, total, err := repo.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: dictionary.ID})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 0 {
t.Fatalf("zero-limit detail list: total=%d items=%#v", total, items)
}
}
func TestUserListAppliesZeroLimitForNegativePageSize(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
if err := data.gormDB.WithContext(ctx).Create(&userPO{Username: "limited", Password: "hash", AuthorityID: 888, Enable: 1}).Error; err != nil {
t.Fatal(err)
}
items, total, err := (&userRepo{data: data}).ListUsers(ctx, 1, -1, &biz.UserListFilter{})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 0 {
t.Fatalf("negative-size user list: total=%d items=%#v", total, items)
}
}
func TestDepartmentTreeAndFlatResultsPreserveChildrenShape(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
repo := &departmentRepo{data: data}
root := &biz.Department{Name: "root"}
if err := repo.CreateDepartment(ctx, root); err != nil {
t.Fatal(err)
}
var rootPO departmentPO
if err := data.gormDB.WithContext(ctx).Where("name = ?", "root").First(&rootPO).Error; err != nil {
t.Fatal(err)
}
if err := repo.CreateDepartment(ctx, &biz.Department{Name: "child", ParentID: rootPO.ID}); err != nil {
t.Fatal(err)
}
tree, err := repo.ListDepartments(ctx, "")
if err != nil {
t.Fatal(err)
}
if len(tree) != 1 || len(tree[0].Children) != 1 || tree[0].Children[0].Children == nil || len(tree[0].Children[0].Children) != 0 {
t.Fatalf("department tree children = %#v, want every loaded node to expose a slice", tree)
}
flat, err := repo.ListDepartments(ctx, "child")
if err != nil {
t.Fatal(err)
}
if len(flat) != 1 || flat[0].Children != nil {
t.Fatalf("flat department search children = %#v, want nil", flat)
}
found, err := repo.FindDepartment(ctx, rootPO.ID)
if err != nil {
t.Fatal(err)
}
if found.Children != nil {
t.Fatalf("single department children = %#v, want nil", found.Children)
}
}
+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 {
if !r.data.databaseReady.Load() {
// GVA silently ignores error records before GVA_DB is initialized.
// Silently ignore error records before the database is initialized.
return nil
}
if v.Status == "" {
+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 exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []exportJoinPO) *biz.ExportTemplate {
v := &biz.ExportTemplate{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DBName: po.DBName, Name: po.Name, TableName: po.DBTableName, TemplateID: po.TemplateID, TemplateInfo: po.TemplateInfo, SQL: po.SQL, ImportSQL: po.ImportSQL, Limit: po.Limit, Order: po.Order}
if conditions != nil {
v.Conditions = make([]biz.ExportCondition, 0, len(conditions))
}
for _, x := range conditions {
v.Conditions = append(v.Conditions, biz.ExportCondition{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator})
}
if joins != nil {
v.Joins = make([]biz.ExportJoin, 0, len(joins))
}
for _, x := range joins {
v.Joins = append(v.Joins, biz.ExportJoin{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, Join: x.Join, Table: x.Table, On: x.On})
}
@@ -73,12 +79,14 @@ func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []e
func exportToPO(v *biz.ExportTemplate) exportTemplatePO {
return exportTemplatePO{ID: v.ID, DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order}
}
func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs bool) error {
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil {
return err
}
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportJoinPO{}).Error; err != nil {
return err
func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs, replace, forceTemplateID bool) error {
if replace {
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil {
return err
}
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportJoinPO{}).Error; err != nil {
return err
}
}
conditions := make([]exportConditionPO, 0, len(v.Conditions))
for _, x := range v.Conditions {
@@ -86,7 +94,16 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
if resetIDs {
id = 0
}
conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator})
templateID := x.TemplateID
// GORM's has-many association on Create always writes the parent's
// TemplateID into the child foreign key (references:TemplateID).
// Preserve that observable behavior for creates; updates intentionally
// keep the request's value after the compatible update deletes and
// recreates relations.
if forceTemplateID || templateID == "" {
templateID = v.TemplateID
}
conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, From: x.From, Column: x.Column, Operator: x.Operator})
}
if len(conditions) > 0 {
create := tx
@@ -105,7 +122,11 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
if resetIDs {
id = 0
}
joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, Join: x.Join, Table: x.Table, On: x.On})
templateID := x.TemplateID
if forceTemplateID || templateID == "" {
templateID = v.TemplateID
}
joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, Join: x.Join, Table: x.Table, On: x.On})
}
if len(joins) > 0 {
create := tx
@@ -123,7 +144,7 @@ func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *biz.ExportTemp
return err
}
v.ID = po.ID
return r.saveRelations(tx, v, false)
return r.saveRelations(tx, v, false, false, true)
})
}
func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemplate) error {
@@ -132,7 +153,7 @@ func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemp
if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil {
return err
}
return r.saveRelations(tx, v, true)
return r.saveRelations(tx, v, true, true, false)
})
}
func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error {
@@ -142,10 +163,10 @@ func (r *exportRepo) FindExportTemplate(ctx context.Context, id uint, tid string
var po exportTemplatePO
db := r.data.gormDB.WithContext(ctx)
var err error
if id != 0 {
err = db.First(&po, id).Error
} else {
if tid != "" {
err = db.Where("template_id = ?", tid).First(&po).Error
} else {
err = db.Where("id = ?", id).First(&po).Error
}
if err != nil {
return nil, err
@@ -181,7 +202,12 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q
return nil, 0, err
}
var pos []exportTemplatePO
if err := applyPagination(db, page, size, 0).Find(&pos).Error; err != nil {
// This module applies pageSize and its raw offset formula verbatim instead
// of using the shared PageInfo normalization.
if size != 0 {
db = db.Limit(size).Offset(size * (page - 1))
}
if err := db.Find(&pos).Error; err != nil {
return nil, 0, err
}
out := make([]*biz.ExportTemplate, 0, len(pos))
+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 {
if !r.data.databaseReady.Load() {
// The login endpoint remains reachable before database initialization;
// GVA skips this audit write while GVA_DB is nil.
// skip the audit write until storage is ready.
return nil
}
return r.data.gormDB.WithContext(ctx).Create(&loginLogPO{Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID}).Error
@@ -48,7 +48,7 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *biz.
return nil, 0, err
}
var pos []loginLogPO
if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err
}
ids := make([]uint, 0, len(pos))
+14 -11
View File
@@ -73,12 +73,6 @@ func (r *mediaRepo) FindMediaByHash(ctx context.Context, userID uint, hash strin
return mediaFromPO(po), nil
}
func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*biz.MediaFile, int64, error) {
if filter.Page < 1 {
filter.Page = 1
}
if filter.PageSize < 1 {
filter.PageSize = 10
}
db := r.data.gormDB.WithContext(ctx).Model(&mediaPO{})
if filter.Keyword != "" {
db = db.Where("name LIKE ?", "%"+filter.Keyword+"%")
@@ -110,7 +104,19 @@ func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*b
if filter.Desc {
orderKey += " desc"
}
if err := applyPagination(db.Order(orderKey), filter.Page, filter.PageSize, 100).Find(&pos).Error; err != nil {
limit := filter.PageSize
if limit > 100 {
limit = 100
}
offset := 0
if limit > 0 {
page := filter.Page
if page <= 0 {
page = 1
}
offset = limit * (page - 1)
}
if err := db.Limit(limit).Offset(offset).Order(orderKey).Find(&pos).Error; err != nil {
return nil, 0, err
}
out := make([]*biz.MediaFile, 0, len(pos))
@@ -141,9 +147,6 @@ func (r *mediaRepo) CreateMediaBatch(ctx context.Context, items []*biz.MediaFile
for _, v := range items {
pos = append(pos, mediaToPO(v))
}
if len(pos) == 0 {
return nil
}
return r.data.gormDB.WithContext(ctx).Create(&pos).Error
}
func (r *mediaRepo) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) error {
@@ -178,7 +181,7 @@ func (r *mediaRepo) DeleteCategory(ctx context.Context, id uint) error {
}
func (r *mediaRepo) ListCategories(ctx context.Context) ([]*biz.AttachmentCategory, error) {
var pos []categoryPO
if err := r.data.gormDB.WithContext(ctx).Order("id").Find(&pos).Error; err != nil {
if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil {
return nil, err
}
byID := map[uint]*biz.AttachmentCategory{}
+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 {
return menuPO{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType}
return menuPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType}
}
func replaceMenuRelations(tx *gorm.DB, menu *biz.Menu) error {
// The compatible update rebuilds menu-button rows but does not remove the
// existing authority-button rows that point at the old button IDs. Keep
// that observable behavior here; menu deletion itself still cleans those
// rows in Delete below, matching the administration endpoint behavior.
if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuButtonPO{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuParameterPO{}).Error; err != nil {
return err
}
buttons := make([]menuButtonPO, 0, len(menu.Buttons))
for _, button := range menu.Buttons {
buttons = append(buttons, menuButtonPO{Name: button.Name, Description: button.Description, MenuID: menu.ID})
if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuButtonPO{}).Error; err != nil {
return err
}
if len(buttons) > 0 {
if err := tx.Create(&buttons).Error; err != nil {
return createMenuRelations(tx, menu)
}
func createMenuRelations(tx *gorm.DB, menu *biz.Menu) error {
parameters := make([]menuParameterPO, 0, len(menu.Parameters))
for _, parameter := range menu.Parameters {
parameters = append(parameters, menuParameterPO{ID: parameter.ID, CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
}
if len(parameters) > 0 {
if err := tx.Create(&parameters).Error; err != nil {
return err
}
}
parameters := make([]menuParameterPO, 0, len(menu.Parameters))
for _, parameter := range menu.Parameters {
parameters = append(parameters, menuParameterPO{MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
buttons := make([]menuButtonPO, 0, len(menu.Buttons))
for _, button := range menu.Buttons {
buttons = append(buttons, menuButtonPO{ID: button.ID, CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, Name: button.Name, Description: button.Description, MenuID: menu.ID})
}
if len(parameters) > 0 {
return tx.Create(&parameters).Error
if len(buttons) > 0 {
return tx.Create(&buttons).Error
}
return nil
}
@@ -94,7 +94,7 @@ func (r *menuRepo) Create(ctx context.Context, v *biz.Menu) error {
return err
}
v.ID = po.ID
return replaceMenuRelations(tx, v)
return createMenuRelations(tx, v)
})
}
@@ -140,29 +140,25 @@ func (r *menuRepo) Delete(ctx context.Context, id uint) error {
if count > 0 {
return errors.New("此菜单有角色正在作为首页,不可删除")
}
var buttonIDs []uint
if err := tx.Model(&menuButtonPO{}).Where("sys_base_menu_id = ?", id).Pluck("id", &buttonIDs).Error; err != nil {
return err
}
if err := tx.Where("sys_menu_id = ?", id).Delete(&authorityButtonPO{}).Error; err != nil {
return err
}
if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuButtonPO{}).Error; err != nil {
if err := tx.Delete(&menuPO{}, id).Error; err != nil {
return err
}
if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuParameterPO{}).Error; err != nil {
return err
}
if err := tx.Where("sys_base_menu_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil {
if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuButtonPO{}).Error; err != nil {
return err
}
return tx.Delete(&menuPO{}, id).Error
if err := tx.Where("sys_menu_id = ?", id).Delete(&authorityButtonPO{}).Error; err != nil {
return err
}
return tx.Where("sys_base_menu_id = ?", id).Delete(&authorityMenuPO{}).Error
})
}
func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error {
var parameters []menuParameterPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Order("id").Find(&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
}
menu.Parameters = make([]*biz.MenuParameter, 0, len(parameters))
@@ -170,7 +166,7 @@ func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error {
menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter))
}
var buttons []menuButtonPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Order("id").Find(&buttons).Error; err != nil {
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(&buttons).Error; err != nil {
return err
}
menu.Buttons = make([]*biz.MenuButton, 0, len(buttons))
@@ -194,7 +190,7 @@ func (r *menuRepo) Find(ctx context.Context, id uint) (*biz.Menu, error) {
func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) {
var pos []menuPO
db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).Order("sort,id")
db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).Order("sort")
config := r.data.runtime.Admin()
if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth {
var authority authorityPO
@@ -224,7 +220,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) {
menu.Buttons = []*biz.MenuButton{}
}
var parameters []menuParameterPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Order("id").Find(&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
}
for _, parameter := range parameters {
@@ -232,7 +228,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) {
menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter))
}
var buttons []menuButtonPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Order("id").Find(&buttons).Error; err != nil {
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(&buttons).Error; err != nil {
return nil, err
}
for _, button := range buttons {
@@ -264,6 +260,9 @@ func (r *menuRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) e
if err := (&authorityAccessRepo{data: r.data}).checkAuthorityIDAuth(ctx, id); err != nil {
return err
}
if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", id).First(&authorityPO{}).Error; err != nil {
return err
}
config := r.data.runtime.Admin()
if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth {
var authority authorityPO
@@ -307,6 +306,29 @@ func (r *menuRepo) AuthorityMenuIDs(ctx context.Context, id uint) ([]uint, error
return ids, err
}
// ListAuthorityMenus reads the authority-menu query. It reads the
// target authority's relation rows first and then fetches only those base
// menus. In particular, it deliberately does not call List: List applies the
// current request actor's strict-auth filter, which would incorrectly hide
// menus when an administrator inspects a different (child) authority.
func (r *menuRepo) ListAuthorityMenus(ctx context.Context, authorityID uint) ([]*biz.Menu, error) {
var pos []menuPO
db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).
Where("id IN (?)", r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).
Select("sys_base_menu_id").Where("sys_authority_authority_id = ?", authorityID)).
Order("sort")
if err := db.Find(&pos).Error; err != nil {
return nil, err
}
out := make([]*biz.Menu, 0, len(pos))
for _, po := range pos {
// This endpoint does not preload Parameters or MenuBtn. Keep
// both relations nil so the response shape remains compatible.
out = append(out, menuFromPO(po))
}
return out, nil
}
func (r *menuRepo) MenuRoleIDs(ctx context.Context, id uint) ([]uint, error) {
var ids []uint
err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_base_menu_id = ?", id).Pluck("sys_authority_authority_id", &ids).Error
+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 (
"fmt"
"strings"
"time"
"gorm.io/gorm"
@@ -11,6 +12,9 @@ func migrateAll(db *gorm.DB) error {
if err := migrateLegacyIgnoreAPITable(db); err != nil {
return err
}
if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil {
return err
}
if err := db.AutoMigrate(
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
@@ -35,6 +39,134 @@ func migrateAll(db *gorm.DB) error {
return reconcileReferenceIndexes(db)
}
// migrateLegacyAuthorityDepartmentColumns preserves data created by early
// Kra builds, which used shortened join-column names and a composite primary
// key. The administration connection model has neither a primary key nor a
// uniqueness constraint, so rebuild the small table before AutoMigrate.
func migrateLegacyAuthorityDepartmentColumns(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
const (
table = "sys_authority_departments"
backup = "sys_authority_departments_kra_legacy"
)
// MySQL and Oracle auto-commit DDL. If a prior process stopped between the
// rename and cleanup steps, restore the untouched backup first and retry the
// migration from a known state.
if clean.Migrator().HasTable(backup) {
if clean.Migrator().HasTable(table) {
if err := clean.Migrator().DropTable(table); err != nil {
return fmt.Errorf("remove incomplete authority-department table: %w", err)
}
}
if err := clean.Migrator().RenameTable(backup, table); err != nil {
return fmt.Errorf("restore authority-department backup: %w", err)
}
}
if !clean.Migrator().HasTable(table) {
return nil
}
authorityColumn := "sys_authority_authority_id"
if !tableHasColumn(clean, table, authorityColumn) {
if !tableHasColumn(clean, table, "authority_id") {
return fmt.Errorf("authority-department table has no authority column")
}
authorityColumn = "authority_id"
}
departmentColumn := "sys_department_id"
if !tableHasColumn(clean, table, departmentColumn) {
if !tableHasColumn(clean, table, "department_id") {
return fmt.Errorf("authority-department table has no department column")
}
departmentColumn = "department_id"
}
hasPrimaryKey, err := tableHasPrimaryKey(clean, table)
if err != nil {
return err
}
if authorityColumn == "sys_authority_authority_id" && departmentColumn == "sys_department_id" && !hasPrimaryKey {
return nil
}
type relation struct {
AuthorityID uint `gorm:"column:authority_id"`
DepartmentID uint `gorm:"column:department_id"`
}
var rows []relation
selectColumns := authorityColumn + " AS authority_id, " + departmentColumn + " AS department_id"
if err := clean.Table(table).Select(selectColumns).Scan(&rows).Error; err != nil {
return fmt.Errorf("read legacy authority-department rows: %w", err)
}
rebuild := func(tx *gorm.DB) error {
if err := tx.Migrator().RenameTable(table, backup); err != nil {
return fmt.Errorf("rename legacy authority-department table: %w", err)
}
if err := tx.AutoMigrate(&authorityDepartmentPO{}); err != nil {
return fmt.Errorf("create authority-department table: %w", err)
}
if len(rows) > 0 {
items := make([]authorityDepartmentPO, 0, len(rows))
for _, row := range rows {
items = append(items, authorityDepartmentPO{AuthorityID: row.AuthorityID, DepartmentID: row.DepartmentID})
}
if err := tx.Create(&items).Error; err != nil {
return fmt.Errorf("copy authority-department rows: %w", err)
}
}
var count int64
if err := tx.Model(&authorityDepartmentPO{}).Count(&count).Error; err != nil {
return fmt.Errorf("verify authority-department rows: %w", err)
}
if count != int64(len(rows)) {
return fmt.Errorf("verify authority-department rows: got %d want %d", count, len(rows))
}
if err := tx.Migrator().DropTable(backup); err != nil {
return fmt.Errorf("drop legacy authority-department table: %w", err)
}
return nil
}
switch clean.Dialector.Name() {
case "mysql", "oracle":
if err := rebuild(clean); err != nil {
restoreErr := restoreAuthorityDepartmentBackup(clean, table, backup)
if restoreErr != nil {
return fmt.Errorf("%v; restore authority-department backup: %w", err, restoreErr)
}
return err
}
return nil
default:
return clean.Transaction(rebuild)
}
}
func restoreAuthorityDepartmentBackup(db *gorm.DB, table, backup string) error {
if db.Migrator().HasTable(table) {
if err := db.Migrator().DropTable(table); err != nil {
return err
}
}
if db.Migrator().HasTable(backup) {
return db.Migrator().RenameTable(backup, table)
}
return nil
}
func tableHasPrimaryKey(db *gorm.DB, table string) (bool, error) {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false, err
}
for _, column := range columns {
if primary, ok := column.PrimaryKey(); ok && primary {
return true, nil
}
}
return false, nil
}
// Older builds used a status label outside the administration page's supported
// state set, so normalize existing rows during migration.
func normalizeErrorRecordStatuses(db *gorm.DB) error {
@@ -55,18 +187,32 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
Path string
Method string
}
query := clean.Table("sys_authority_apis sa").
Select("sa.authority_id, a.path, a.method").
Joins("JOIN sys_apis a ON a.id = sa.api_id")
baseQuery := func() *gorm.DB {
return clean.Table("sys_authority_apis sa").
Select("sa.authority_id, a.path, a.method").
Joins("JOIN sys_apis a ON a.id = sa.api_id")
}
query := baseQuery()
// Early Kra schemas stored sys_apis without soft-delete timestamps. The
// legacy-policy migration must run before assuming that column exists;
// otherwise an upgrade from those schemas cannot start on MySQL.
if clean.Migrator().HasColumn(&apiPO{}, "deleted_at") {
if tableHasColumn(clean, "sys_apis", "deleted_at") {
query = query.Where("a.deleted_at IS NULL")
}
var rows []legacyPolicy
if err := query.Find(&rows).Error; err != nil {
return err
// A few MySQL-compatible drivers report stale/incomplete metadata from
// INFORMATION_SCHEMA during startup. If the optional soft-delete column
// was reported present but the join still rejects it, retry using only
// columns shared by every legacy schema. This migration must never block
// startup of an older database solely because deleted_at is absent.
if strings.Contains(strings.ToLower(err.Error()), "unknown column") && strings.Contains(strings.ToLower(err.Error()), "deleted_at") {
if retryErr := baseQuery().Find(&rows).Error; retryErr != nil {
return retryErr
}
} else {
return err
}
}
for _, row := range rows {
exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method)
@@ -83,6 +229,23 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
return nil
}
// tableHasColumn deliberately inspects the physical table rather than the
// model schema. Legacy databases may predate soft-delete columns even though
// the current PO includes gorm.DeletedAt. Metadata inspection failures are
// treated as "unknown" so callers use the portable query shape.
func tableHasColumn(db *gorm.DB, table, column string) bool {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false
}
for _, item := range columns {
if strings.EqualFold(item.Name(), column) {
return true
}
}
return false
}
// migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key
// shape (path, method) to the compatible model shape (ID/timestamps/soft
// delete). AutoMigrate can add columns but cannot replace an existing
+57
View File
@@ -98,3 +98,60 @@ func TestReconcileRootAuthorityAPIsUsesIndependentQueries(t *testing.T) {
t.Fatalf("root rules = %#v, want only GET /allowed", rules)
}
}
func TestMigrateLegacyAuthorityDepartmentColumns(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
for _, statement := range []string{
`CREATE TABLE sys_authority_departments (authority_id integer, department_id integer, PRIMARY KEY (authority_id, department_id))`,
`INSERT INTO sys_authority_departments (authority_id, department_id) VALUES (888, 7)`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateLegacyAuthorityDepartmentColumns(db); err != nil {
t.Fatalf("migrate legacy authority-department columns: %v", err)
}
if err = db.AutoMigrate(&authorityDepartmentPO{}); err != nil {
t.Fatal(err)
}
var relation authorityDepartmentPO
if err = db.First(&relation).Error; err != nil {
t.Fatal(err)
}
if relation.AuthorityID != 888 || relation.DepartmentID != 7 {
t.Fatalf("migrated relation = %#v", relation)
}
for _, column := range []string{"sys_authority_authority_id", "sys_department_id"} {
if !tableHasColumn(db, "sys_authority_departments", column) {
t.Fatalf("missing compatible column %s", column)
}
}
hasPrimaryKey, err := tableHasPrimaryKey(db, "sys_authority_departments")
if err != nil {
t.Fatal(err)
}
if hasPrimaryKey {
t.Fatal("authority-department table retained the legacy primary key")
}
if err = db.Create(&authorityDepartmentPO{AuthorityID: 888, DepartmentID: 7}).Error; err != nil {
t.Fatalf("duplicate administration relation was rejected: %v", err)
}
var count int64
if err = db.Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ? AND sys_department_id = ?", 888, 7).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("duplicate relation count = %d, want 2", count)
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q *
return nil, 0, err
}
var pos []operationPO
if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
return nil, 0, err
}
users := auditUsers(ctx, r.data.gormDB.WithContext(ctx), operationUserIDs(pos))
+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"
func applyPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB {
if size <= 0 {
return db
}
func paginationLimitOffset(page, size, maxSize int) (limit, offset int) {
limit = size
if maxSize > 0 && size > maxSize {
size = maxSize
limit = maxSize
}
if limit <= 0 {
return 0, 0
}
if page <= 0 {
page = 1
}
return db.Offset((page - 1) * size).Limit(size)
return limit, (page - 1) * limit
}
func applyPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB {
limit, offset := paginationLimitOffset(page, size, maxSize)
if limit == 0 {
return db
}
return db.Offset(offset).Limit(limit)
}
func applyRequiredPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB {
limit, offset := paginationLimitOffset(page, size, maxSize)
return db.Offset(offset).Limit(limit)
}
+18 -15
View File
@@ -28,31 +28,34 @@ type parameterPO struct {
func (parameterPO) TableName() string { return "sys_params" }
func (r *parameterRepo) CreateParameter(ctx context.Context, v *biz.SystemParameter) error {
po := parameterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}
po := parameterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}
if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil {
return err
}
v.ID = po.ID
v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
return nil
}
func (r *parameterRepo) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error {
// The compatible update uses a struct, so zero-value optional fields (notably desc)
// are intentionally ignored rather than clearing an existing value.
return r.data.gormDB.WithContext(ctx).Model(&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 {
return r.data.gormDB.WithContext(ctx).Delete(&parameterPO{}, ids).Error
}
func (r *parameterRepo) FindParameter(ctx context.Context, id uint, key string) (*biz.SystemParameter, error) {
var po parameterPO
db := r.data.gormDB.WithContext(ctx)
var err error
if id != 0 {
err = db.First(&po, id).Error
} else {
err = db.Where(map[string]any{"key": key}).First(&po).Error
func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []string) error {
if len(ids) == 1 {
return r.data.gormDB.WithContext(ctx).Delete(&parameterPO{}, "id = ?", ids[0]).Error
}
if err != nil {
return r.data.gormDB.WithContext(ctx).Delete(&[]parameterPO{}, "id in ?", ids).Error
}
func (r *parameterRepo) FindParameterByID(ctx context.Context, id string) (*biz.SystemParameter, error) {
var po parameterPO
if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil {
return nil, err
}
return parameterFromPO(po), nil
}
func (r *parameterRepo) FindParameterByKey(ctx context.Context, key string) (*biz.SystemParameter, error) {
var po parameterPO
if err := r.data.gormDB.WithContext(ctx).Where(parameterPO{Key: key}).First(&po).Error; err != nil {
return nil, err
}
return parameterFromPO(po), nil
+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 (
"context"
"errors"
"time"
"kra/internal/biz"
@@ -26,9 +27,9 @@ type menuButtonPO struct {
func (menuButtonPO) TableName() string { return "sys_base_menu_btns" }
type authorityButtonPO struct {
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
MenuID uint `gorm:"primaryKey;column:sys_menu_id"`
ButtonID uint `gorm:"primaryKey;column:sys_base_menu_btn_id"`
AuthorityID uint `gorm:"column:authority_id"`
MenuID uint `gorm:"column:sys_menu_id"`
ButtonID uint `gorm:"column:sys_base_menu_btn_id"`
}
func (authorityButtonPO) TableName() string { return "sys_authority_btns" }
@@ -62,9 +63,15 @@ func (r *permissionRepo) SetAuthorityButtons(ctx context.Context, aid uint, butt
})
}
func (r *permissionRepo) SelectedButtons(ctx context.Context, aid, menuID uint) ([]uint, error) {
var rows []authorityButtonPO
if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Find(&rows).Error; err != nil {
return nil, err
}
var ids []uint
err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Pluck("sys_base_menu_btn_id", &ids).Error
return ids, err
for _, row := range rows {
ids = append(ids, row.ButtonID)
}
return ids, nil
}
func (r *permissionRepo) SetSelectedButtons(ctx context.Context, aid, menuID uint, ids []uint) error {
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@@ -81,10 +88,16 @@ func (r *permissionRepo) SetSelectedButtons(ctx context.Context, aid, menuID uin
return nil
})
}
func (r *permissionRepo) CanRemoveButton(ctx context.Context, id uint) (bool, error) {
var count int64
err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("sys_base_menu_btn_id = ?", id).Count(&count).Error
return count == 0, err
func (r *permissionRepo) CanRemoveButton(ctx context.Context, id string) (bool, error) {
var row authorityButtonPO
err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_btn_id = ?", id).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, nil
}
if err != nil {
return false, errors.New("此按钮正在被使用无法删除")
}
return false, nil
}
func (r *permissionRepo) AuthorityButtonIDs(ctx context.Context, aid uint) ([]uint, error) {
var ids []uint
+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" }
type userPositionPO struct {
UserID uint `gorm:"primaryKey;column:sys_user_id"`
PositionID uint `gorm:"primaryKey;column:sys_position_id"`
// GVA does not declare a key on this explicit join model. Do not let a
// database uniqueness constraint change duplicate-assignment behavior.
UserID uint `gorm:"column:sys_user_id"`
PositionID uint `gorm:"column:sys_position_id"`
}
func (userPositionPO) TableName() string { return "sys_user_positions" }
@@ -82,7 +84,10 @@ func (r *positionRepo) ListPositions(ctx context.Context, page, size int, q *biz
return nil, 0, err
}
var pos []positionPO
if err := applyPagination(db.Order("sort"), page, size, 0).Find(&pos).Error; err != nil {
// The compatible position endpoint applies the supplied LIMIT/OFFSET
// directly (it does not normalize missing page values through the shared
// pagination helper).
if err := db.Order("sort").Limit(size).Offset(size * (page - 1)).Find(&pos).Error; err != nil {
return nil, 0, err
}
out := make([]*biz.Position, 0, len(pos))
+33 -1
View File
@@ -7,6 +7,8 @@ import (
"kra/internal/biz"
"kra/internal/conf"
"kra/pkg/adminauth"
jwt "github.com/golang-jwt/jwt/v5"
)
type runtimeSettings struct{ runtime *conf.Runtime }
@@ -94,6 +96,34 @@ func (i *tokenIssuer) IssueToken(user *biz.User, authorityID uint, mustChangePas
return &biz.IssuedToken{Value: token, ExpiresAt: claims.ExpiresAt.Time, TTL: expires}, nil
}
func (i *tokenIssuer) ReissueToken(source *biz.AuthClaims, authorityID uint) (*biz.IssuedToken, error) {
if source == nil {
return nil, errors.New("nil JWT claims")
}
settings := i.settings.JWTSettings()
claims := &adminauth.Claims{
UUID: source.UUID,
ID: source.ID,
Username: source.Username,
NickName: source.NickName,
AuthorityID: authorityID,
BufferTime: int64(source.BufferTime / time.Second),
UserType: source.UserType,
MustChangePwd: source.MustChangePwd,
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings(append([]string(nil), source.Audience...)),
Issuer: source.Issuer,
NotBefore: jwt.NewNumericDate(source.NotBefore),
ExpiresAt: jwt.NewNumericDate(source.ExpiresAt),
},
}
token, err := adminauth.Sign(settings.SigningKey, claims)
if err != nil {
return nil, err
}
return &biz.IssuedToken{Value: token, ExpiresAt: source.ExpiresAt, TTL: time.Until(source.ExpiresAt)}, nil
}
func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) {
claims, err := adminauth.Parse(token, i.settings.JWTSettings().SigningKey)
if err != nil {
@@ -110,5 +140,7 @@ func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) {
return nil, biz.ErrTokenInvalid
}
}
return &biz.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, UserType: claims.UserType, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, Issuer: claims.Issuer, ExpiresAt: claims.ExpiresAt.Time}, nil
audience := make([]string, len(claims.Audience))
copy(audience, claims.Audience)
return &biz.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, UserType: claims.UserType, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, Issuer: claims.Issuer, Audience: audience, NotBefore: claims.NotBefore.Time, ExpiresAt: claims.ExpiresAt.Time}, nil
}
+20 -19
View File
@@ -19,25 +19,25 @@ type securityConfigPO struct {
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
CaptchaOpen int
CaptchaTimeout int
KeyLong int
ImgWidth int
ImgHeight int
PwdMinLength int
PwdRequireUpper bool
PwdRequireLower bool
PwdRequireDigit bool
PwdRequireSpecial bool
LimitEnable bool
LimitWindow int
LimitCount int
LockEnable bool
LockThreshold int
LockDuration int
PwdExpireEnable bool
PwdExpireDays int
ForceNewUserChangePassword bool
CaptchaOpen int `gorm:"default:0"`
CaptchaTimeout int `gorm:"default:3600"`
KeyLong int `gorm:"default:6"`
ImgWidth int `gorm:"default:240"`
ImgHeight int `gorm:"default:80"`
PwdMinLength int `gorm:"default:8"`
PwdRequireUpper bool `gorm:"default:false"`
PwdRequireLower bool `gorm:"default:false"`
PwdRequireDigit bool `gorm:"default:false"`
PwdRequireSpecial bool `gorm:"default:false"`
LimitEnable bool `gorm:"default:false"`
LimitWindow int `gorm:"default:60"`
LimitCount int `gorm:"default:30"`
LockEnable bool `gorm:"default:false"`
LockThreshold int `gorm:"default:5"`
LockDuration int `gorm:"default:30"`
PwdExpireEnable bool `gorm:"default:false"`
PwdExpireDays int `gorm:"default:90"`
ForceNewUserChangePassword bool `gorm:"default:false"`
}
func (securityConfigPO) TableName() string { return "sys_security_config" }
@@ -54,6 +54,7 @@ func securityToPO(v *biz.SecurityConfig) securityConfigPO {
func (r *securityRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig, error) {
if !r.data.databaseReady.Load() {
po := defaultSecurityConfig()
po.ID = 0
return securityFromPO(po), errors.New("数据库未初始化")
}
var po securityConfigPO
+7 -4
View File
@@ -67,15 +67,18 @@ type menuPO struct {
func (menuPO) TableName() string { return "sys_base_menus" }
type userAuthorityPO struct {
SysUserID uint `gorm:"primaryKey;column:sys_user_id"`
SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"`
// GVA's explicit join model deliberately has no primary key or uniqueness
// constraint. Keep duplicate IDs representable; association replacement
// and validation are handled by the service transaction instead.
SysUserID uint `gorm:"column:sys_user_id"`
SysAuthorityAuthorityID uint `gorm:"column:sys_authority_authority_id"`
}
func (userAuthorityPO) TableName() string { return "sys_user_authority" }
type authorityMenuPO struct {
SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"`
SysBaseMenuID uint `gorm:"primaryKey;column:sys_base_menu_id"`
SysAuthorityAuthorityID uint `gorm:"column:sys_authority_authority_id"`
SysBaseMenuID uint `gorm:"column:sys_base_menu_id"`
}
func (authorityMenuPO) TableName() string { return "sys_authority_menus" }
+11 -1
View File
@@ -232,10 +232,20 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
}); err != nil {
return err
}
if err := r.data.persistDatabaseConfig(config); err != nil {
signingKey := uuid.NewString()
if err := r.data.persistDatabaseConfig(config, signingKey); err != nil {
return fmt.Errorf("persist database configuration: %w", err)
}
r.data.activateDatabase(candidate, config)
currentData, currentAdmin := r.data.runtime.Values()
if currentAdmin == nil {
currentAdmin = &conf.AdminBackend{}
}
if currentAdmin.Jwt == nil {
currentAdmin.Jwt = &conf.AdminBackend_JWT{}
}
currentAdmin.Jwt.SigningKey = signingKey
r.data.runtime.Replace(currentData, currentAdmin)
activated = true
return nil
}
+60
View File
@@ -2,9 +2,14 @@ package data
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"kra/internal/conf"
"gopkg.in/yaml.v3"
)
func TestDatabaseConnectionConfigured(t *testing.T) {
@@ -49,3 +54,58 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
t.Fatal("an active configured database must not require initialization")
}
}
func TestPersistDatabaseConfigPreservesTemplateAndRotatesJWTKey(t *testing.T) {
root := t.TempDir()
configPath := filepath.Join(root, "config.yaml")
original := `server:
http:
addr: 0.0.0.0:8000
data:
database:
driver: mysql
name: old
redis:
addr: 127.0.0.1:6379
admin:
jwt:
signing_key: old-key
issuer: kra
zap:
director: logs
extension:
preserved: true
`
if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil {
t.Fatal(err)
}
runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{ConfigPath: configPath})
data := &Data{runtime: runtime}
if err := data.persistDatabaseConfig(&conf.Data_Database{Driver: "mysql", Host: "db", Port: "3306", User: "root", Password: "secret", Name: "kra", Config: "parseTime=True"}, "new-key"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
var value map[string]any
if err = yaml.Unmarshal(raw, &value); err != nil {
t.Fatal(err)
}
admin := value["admin"].(map[string]any)
jwt := admin["jwt"].(map[string]any)
if jwt["signing_key"] != "new-key" || jwt["issuer"] != "kra" {
t.Fatalf("unexpected jwt config: %#v", jwt)
}
dataConfig := value["data"].(map[string]any)
database := dataConfig["database"].(map[string]any)
if database["name"] != "kra" || database["password"] != "secret" {
t.Fatalf("unexpected database config: %#v", database)
}
if _, ok := dataConfig["redis"]; !ok {
t.Fatalf("redis config was removed: %#v", dataConfig)
}
if _, ok := value["extension"]; !ok || !strings.Contains(string(raw), "preserved: true") {
t.Fatalf("extension config was removed: %s", raw)
}
}
+95 -10
View File
@@ -41,25 +41,110 @@ func TestUserAuthorityWritesAreAtomic(t *testing.T) {
t.Fatalf("authority links = %d, err = %v", links, err)
}
created.NickName = "must rollback"
if err = repo.UpdateUserWithAuthorities(ctx, created, []uint{888, 123456}); err == nil {
t.Fatal("expected invalid authority update to fail")
// With strict authority checks disabled, GVA writes the supplied join rows
// without first requiring every role record to exist.
created.NickName = "compatible update"
if err = repo.UpdateUserWithAuthorities(ctx, created, []uint{888, 123456}); err != nil {
t.Fatalf("missing authority ID was rejected in non-strict mode: %v", err)
}
var persisted userPO
if err = data.gormDB.WithContext(ctx).First(&persisted, created.ID).Error; err != nil {
t.Fatal(err)
}
if persisted.NickName != "before" {
t.Fatalf("user update was not rolled back: %q", persisted.NickName)
if persisted.NickName != "compatible update" {
t.Fatalf("user update was not persisted: %q", persisted.NickName)
}
var missingAuthorityLinks int64
if err = data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).
Where("sys_user_id = ? AND sys_authority_authority_id = ?", created.ID, 123456).
Count(&missingAuthorityLinks).Error; err != nil || missingAuthorityLinks != 1 {
t.Fatalf("missing-authority link count = %d, err = %v", missingAuthorityLinks, err)
}
invalid := &biz.User{Username: "invalid", Password: "hash", AuthorityID: 888, Enable: 1}
if _, err = repo.CreateUserWithAuthorities(ctx, invalid, []uint{888, 123456}); err == nil {
t.Fatal("expected invalid authority create to fail")
withNewAuthority := &biz.User{Username: "with-new-authority", Password: "hash", AuthorityID: 888, Enable: 1}
createdWithNewAuthority, err := repo.CreateUserWithAuthorities(ctx, withNewAuthority, []uint{888, 123456})
if err != nil {
t.Fatal(err)
}
var users int64
if err = data.gormDB.WithContext(ctx).Model(&userPO{}).Where("username = ?", "invalid").Count(&users).Error; err != nil || users != 0 {
t.Fatalf("partial user persisted: count=%d err=%v", users, err)
if err = data.gormDB.WithContext(ctx).Model(&userPO{}).Where("username = ?", "with-new-authority").Count(&users).Error; err != nil || users != 1 {
t.Fatalf("created user count=%d err=%v", users, err)
}
var generated authorityPO
if err = data.gormDB.WithContext(ctx).First(&generated, "authority_id = ?", 123456).Error; err != nil {
t.Fatal(err)
}
if generated.DataScope != 1 || generated.DefaultRouter != "dashboard" {
t.Fatalf("generated authority defaults = %+v", generated)
}
links = 0
if err = data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_user_id = ?", createdWithNewAuthority.ID).Count(&links).Error; err != nil || links != 2 {
t.Fatalf("generated authority links = %d, err = %v", links, err)
}
}
func TestUserLoadMatchesIndependentAuthorityAndDepartmentPreloads(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
department := departmentPO{Name: "primary-only"}
if err := data.gormDB.WithContext(ctx).Create(&department).Error; err != nil {
t.Fatal(err)
}
po := userPO{UUID: "00000000-0000-0000-0000-000000000001", Username: "orphaned-role", Password: "hash", AuthorityID: 700001, DeptID: department.ID, Enable: 1}
if err := data.gormDB.WithContext(ctx).Create(&po).Error; err != nil {
t.Fatal(err)
}
loaded, err := (&userRepo{data: data}).FindUserByID(ctx, po.ID)
if err != nil {
t.Fatal(err)
}
if loaded.Authority.AuthorityID != 0 {
t.Fatalf("missing primary authority should preload as zero value: %+v", loaded.Authority)
}
if loaded.Department == nil || loaded.Department.ID != department.ID {
t.Fatalf("primary department was not independently loaded: %+v", loaded.Department)
}
if len(loaded.Departments) != 0 {
t.Fatalf("primary-only department leaked into many-to-many departments: %+v", loaded.Departments)
}
}
func TestUserRegistrationDoesNotAddPrimaryAuthorityToRequestedAssociations(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{{AuthorityID: 888, AuthorityName: "primary"}, {AuthorityID: 999, AuthorityName: "requested"}}).Error; err != nil {
t.Fatal(err)
}
created, err := (&userRepo{data: data}).CreateUserWithAuthorities(ctx, &biz.User{Username: "association-shape", Password: "hash", AuthorityID: 888, Enable: 1}, []uint{999})
if err != nil {
t.Fatal(err)
}
var links []userAuthorityPO
if err = data.gormDB.WithContext(ctx).Where("sys_user_id = ?", created.ID).Find(&links).Error; err != nil {
t.Fatal(err)
}
if len(links) != 1 || links[0].SysAuthorityAuthorityID != 999 {
t.Fatalf("registration authority links = %+v", links)
}
}
func TestDefaultRouterLookupAcceptsAssignedOrphanMenu(t *testing.T) {
data := newTransactionTestData(t)
ctx := context.Background()
menu := menuPO{Name: "orphan-default", ParentID: 999999}
if err := data.gormDB.WithContext(ctx).Create(&menu).Error; err != nil {
t.Fatal(err)
}
if err := data.gormDB.WithContext(ctx).Create(&authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: menu.ID}).Error; err != nil {
t.Fatal(err)
}
hasMenu, err := (&userRepo{data: data}).HasAuthorityMenu(ctx, 888, "orphan-default")
if err != nil {
t.Fatal(err)
}
if !hasMenu {
t.Fatal("assigned default menu was not found")
}
}
+56 -42
View File
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *userRepo) FindUserByUsername(ctx context.Context, username string) (*biz.User, error) {
@@ -36,9 +37,20 @@ func (r *userRepo) FindUserByID(ctx context.Context, id uint) (*biz.User, error)
return r.loadUser(ctx, &po)
}
func (r *userRepo) FindUserByUUID(ctx context.Context, value string) (*biz.User, error) {
var po userPO
if err := r.data.gormDB.WithContext(ctx).Where("uuid = ?", value).First(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, biz.ErrUserNotFound
}
return nil, err
}
return r.loadUser(ctx, &po)
}
func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error) {
var authority authorityPO
if err := r.data.gormDB.WithContext(ctx).First(&authority, "authority_id = ?", po.AuthorityID).Error; err != nil {
if err := r.data.gormDB.WithContext(ctx).First(&authority, "authority_id = ?", po.AuthorityID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var authorityPOs []authorityPO
@@ -58,13 +70,17 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error)
return nil, err
}
departments := make([]biz.Department, 0, len(departmentPOs))
var primary *biz.Department
for _, item := range departmentPOs {
value := deptFromPO(item)
departments = append(departments, *value)
if item.ID == po.DeptID {
copy := *value
primary = &copy
}
var primary *biz.Department
if po.DeptID != 0 {
var primaryPO departmentPO
if err := r.data.gormDB.WithContext(ctx).First(&primaryPO, po.DeptID).Error; err == nil {
primary = deptFromPO(primaryPO)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
var positionPOs []positionPO
@@ -200,12 +216,21 @@ func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*biz.User, er
return users, nil
}
func (r *userRepo) HasAuthorityMenu(ctx context.Context, authorityID uint, name string) (bool, error) {
var count int64
err := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).
Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id").
Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.name = ?", authorityID, name).
Count(&count).Error
return count > 0, err
}
func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*biz.Menu, error) {
var pos []menuPO
err := r.data.gormDB.WithContext(ctx).Table("sys_base_menus").
Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id").
Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.deleted_at IS NULL", authorityID).
Order("sys_base_menus.sort asc, sys_base_menus.id asc").Scan(&pos).Error
Order("sys_base_menus.sort").Scan(&pos).Error
if err != nil {
return nil, err
}
@@ -214,15 +239,15 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b
for _, po := range pos {
menu := menuFromPO(po)
menu.MenuID = po.ID
menu.Children = []*biz.Menu{}
menu.Children = nil
menu.Parameters = []*biz.MenuParameter{}
menu.Buttons = []*biz.MenuButton{}
menu.Buttons = nil
byID[po.ID] = menu
menuIDs = append(menuIDs, po.ID)
}
if len(menuIDs) > 0 {
var parameters []menuParameterPO
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Order("id").Find(&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
}
for _, parameter := range parameters {
@@ -240,7 +265,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b
menu.Buttons = append(menu.Buttons, value)
}
}
roots := make([]*biz.Menu, 0)
var roots []*biz.Menu
for _, po := range pos {
menu := byID[po.ID]
if parent := byID[po.ParentID]; parent != nil {
@@ -281,7 +306,7 @@ func (r *userRepo) ListUsers(ctx context.Context, page, pageSize int, filter *bi
return nil, 0, err
}
var pos []userPO
if err := applyPagination(db.Order(order), page, pageSize, 100).Find(&pos).Error; err != nil {
if err := applyRequiredPagination(db.Order(order), page, pageSize, 100).Find(&pos).Error; err != nil {
return nil, 0, err
}
users, err := r.loadUsers(ctx, pos)
@@ -307,9 +332,7 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User
}
now := time.Now()
po := userPO{UUID: user.UUID, Username: user.Username, Password: user.Password, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Phone: user.Phone, Email: user.Email, Enable: user.Enable, PasswordUpdatedAt: &now, MustChangePassword: user.MustChangePassword}
if len(authorityIDs) == 0 {
authorityIDs = []uint{user.AuthorityID}
}
var createdAuthorities []biz.Authority
if err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// The compatible registration rejects an existing non-deleted username before creating the
// account. The username column is intentionally not unique for legacy
@@ -321,34 +344,36 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := validateAuthorities(tx, authorityIDs); err != nil {
return err
}
if err := tx.Create(&po).Error; err != nil {
return err
}
if len(authorityIDs) == 0 {
return nil
}
links := make([]userAuthorityPO, 0, len(authorityIDs))
for _, authorityID := range authorityIDs {
// Creating a user through the compatible administration endpoint lets
// GORM save the many-to-many authority values supplied by the request.
// With an ID-only value this is an upsert: an existing role is left
// untouched, while a missing role is materialized with model defaults.
associated := authorityPO{AuthorityID: authorityID}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&associated).Error; err != nil {
return err
}
createdAuthorities = append(createdAuthorities, toBizAuthority(associated))
links = append(links, userAuthorityPO{SysUserID: po.ID, SysAuthorityAuthorityID: authorityID})
}
return tx.Create(&links).Error
}); err != nil {
return nil, err
}
return r.FindUserByID(ctx, po.ID)
}
func validateAuthorities(tx *gorm.DB, authorityIDs []uint) error {
for _, authorityID := range authorityIDs {
var count int64
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", authorityID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("角色不存在")
}
}
return nil
// Registration returns the just-created entity, not a second hydrated
// query. The reference handler returns the request's authority IDs and
// leaves related authority/department records at their zero values; keep
// that response contract while retaining the persisted timestamps/UUID.
created := baseBizUser(&po)
created.Authorities = createdAuthorities
return created, nil
}
func (r *userRepo) UpdateUser(ctx context.Context, user *biz.User) error {
@@ -416,14 +441,6 @@ func (r *userRepo) FillDepartmentNamePaths(ctx context.Context, user *biz.User)
parts = append(parts, user.Departments[i].Name)
user.Departments[i].NamePath = strings.Join(parts, "/")
}
if user.Department != nil {
for i := range user.Departments {
if user.Departments[i].ID == user.Department.ID {
user.Department.NamePath = user.Departments[i].NamePath
break
}
}
}
return nil
}
@@ -504,9 +521,6 @@ func setUserAuthorities(tx *gorm.DB, id uint, authorityIDs []uint) error {
if len(authorityIDs) == 0 {
return errors.New("角色不能为空")
}
if err := validateAuthorities(tx, authorityIDs); err != nil {
return err
}
if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil {
return err
}
+4 -4
View File
@@ -14,10 +14,10 @@ type versionPO struct {
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string `gorm:"column:version_name"`
Code string `gorm:"column:version_code"`
Description string
Data string `gorm:"column:version_data;type:text"`
Name *string `gorm:"column:version_name"`
Code *string `gorm:"column:version_code"`
Description *string
Data *string `gorm:"column:version_data;type:text"`
}
func (versionPO) TableName() string { return "sys_versions" }
+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
import (
"strconv"
"time"
"kra/internal/server/httpx"
@@ -35,16 +34,7 @@ func (h *Announcement) Create(c *gin.Context) {
}
func (h *Announcement) Delete(c *gin.Context) {
id, present, err := queryUintValue(c, "ID")
if err != nil {
httpx.Fail(c, err.Error())
return
}
if !present {
// Preserve the legacy endpoint behavior for an omitted ID.
id = 0
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
if err := h.service.Delete(c.Request.Context(), c.Query("ID")); err != nil {
httpx.Fail(c, "删除失败")
return
}
@@ -52,20 +42,7 @@ func (h *Announcement) Delete(c *gin.Context) {
}
func (h *Announcement) DeleteByIDs(c *gin.Context) {
values := c.QueryArray("IDs[]")
if len(values) == 0 {
values = c.QueryArray("IDs")
}
ids := make([]uint, 0, len(values))
for _, value := range values {
id, err := strconv.ParseUint(value, 10, 64)
if err != nil || id == 0 {
httpx.Fail(c, "参数错误")
return
}
ids = append(ids, uint(id))
}
if err := h.service.DeleteByIDs(c.Request.Context(), ids); err != nil {
if err := h.service.DeleteByIDs(c.Request.Context(), c.QueryArray("IDs[]")); err != nil {
httpx.Fail(c, "批量删除失败")
return
}
@@ -86,12 +63,7 @@ func (h *Announcement) Update(c *gin.Context) {
}
func (h *Announcement) Find(c *gin.Context) {
id, _, err := queryUintValue(c, "ID")
if err != nil {
httpx.Fail(c, err.Error())
return
}
item, err := h.service.Find(c.Request.Context(), id)
item, err := h.service.Find(c.Request.Context(), c.Query("ID"))
if err != nil {
httpx.Fail(c, "查询失败")
return
+37 -68
View File
@@ -2,7 +2,6 @@ package handler
import (
"errors"
"io"
"log/slog"
"strconv"
"time"
@@ -26,10 +25,23 @@ func NewAudit(service *service.AuditService, recorder *service.AuditRecorder, lo
return &Audit{service: service, recorder: recorder, logs: logs, logger: logger}
}
func page(c *gin.Context) (int, int) {
value, _ := strconv.Atoi(c.Query("page"))
size, _ := strconv.Atoi(c.Query("pageSize"))
return value, size
func page(c *gin.Context) (int, int, error) {
var value, size int
if raw, exists := c.GetQuery("page"); exists && raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
return 0, 0, err
}
value = parsed
}
if raw, exists := c.GetQuery("pageSize"); exists && raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
return 0, 0, err
}
size = parsed
}
return value, size, nil
}
func queryUintValue(c *gin.Context, key string) (uint, bool, error) {
@@ -42,9 +54,6 @@ func queryUintValue(c *gin.Context, key string) (uint, bool, error) {
}
func IDsFromQuery(c *gin.Context) []uint {
values := c.QueryArray("IDs[]")
if len(values) == 0 {
values = c.QueryArray("ids[]")
}
ids := make([]uint, 0, len(values))
for _, value := range values {
id, _ := strconv.ParseUint(value, 10, 64)
@@ -55,33 +64,6 @@ func IDsFromQuery(c *gin.Context) []uint {
return ids
}
// auditID accepts both DELETE encodings supported by the administration API:
// the identifier may be carried in the query string or in a JSON body.
func auditID(c *gin.Context) (uint, error) {
if raw := c.Query("ID"); raw != "" {
value, err := strconv.ParseUint(raw, 10, 64)
return uint(value), err
}
var request dto.IDRequest
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) {
return 0, err
}
return request.ID, nil
}
// auditIDs mirrors auditID for bulk deletes and supports both query-array and
// JSON-body encodings used by compatible administration pages.
func auditIDs(c *gin.Context) ([]uint, error) {
if ids := IDsFromQuery(c); len(ids) > 0 {
return ids, nil
}
var request dto.IDsRequest
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
return request.IDs, nil
}
func (h *Audit) Operations(c *gin.Context) {
var req dto.OperationRecordSearchRequest
if err := c.ShouldBindQuery(&req); err != nil {
@@ -113,28 +95,24 @@ func (h *Audit) Operation(c *gin.Context) {
httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysOperationRecord": item}, "查询成功")
}
func (h *Audit) DeleteOperation(c *gin.Context) {
id, err := auditID(c)
if err != nil {
var req dto.IDRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
if id == 0 {
httpx.Fail(c, "删除失败")
return
}
if err := h.service.DeleteOperations(c.Request.Context(), []uint{id}); err != nil {
if err := h.service.DeleteOperations(c.Request.Context(), []uint{req.ID}); err != nil {
httpx.Fail(c, "删除失败")
return
}
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功")
}
func (h *Audit) DeleteOperations(c *gin.Context) {
ids, err := auditIDs(c)
if err != nil {
var req dto.IDsRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
if err := h.service.DeleteOperations(c.Request.Context(), ids); err != nil {
if err := h.service.DeleteOperations(c.Request.Context(), req.IDs); err != nil {
httpx.Fail(c, "批量删除失败")
return
}
@@ -168,28 +146,24 @@ func (h *Audit) Login(c *gin.Context) {
httpx.Write(c, httpx.CodeSuccess, item, "查询成功")
}
func (h *Audit) DeleteLogin(c *gin.Context) {
id, err := auditID(c)
if err != nil {
var req dto.IDRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
if id == 0 {
httpx.Fail(c, "删除失败")
return
}
if err := h.service.DeleteLogins(c.Request.Context(), []uint{id}); err != nil {
if err := h.service.DeleteLogins(c.Request.Context(), []uint{req.ID}); err != nil {
httpx.Fail(c, "删除失败")
return
}
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功")
}
func (h *Audit) DeleteLogins(c *gin.Context) {
ids, err := auditIDs(c)
if err != nil {
var req dto.IDsRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
if err := h.service.DeleteLogins(c.Request.Context(), ids); err != nil {
if err := h.service.DeleteLogins(c.Request.Context(), req.IDs); err != nil {
httpx.Fail(c, "批量删除失败")
return
}
@@ -298,24 +272,15 @@ func failLogViewer(c *gin.Context, err error, logger *slog.Logger) {
}
func (h *Audit) DeleteError(c *gin.Context) {
id, err := auditID(c)
if err != nil {
httpx.Fail(c, err.Error())
return
}
if err := h.service.DeleteErrors(c.Request.Context(), []uint{id}); err != nil {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64)
if err := h.service.DeleteErrors(c.Request.Context(), []uint{uint(id)}); err != nil {
httpx.Fail(c, "删除失败:"+err.Error())
return
}
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功")
}
func (h *Audit) DeleteErrors(c *gin.Context) {
ids, err := auditIDs(c)
if err != nil {
httpx.Fail(c, err.Error())
return
}
if err := h.service.DeleteErrors(c.Request.Context(), ids); err != nil {
if err := h.service.DeleteErrors(c.Request.Context(), IDsFromQuery(c)); err != nil {
httpx.Fail(c, "批量删除失败:"+err.Error())
return
}
@@ -343,7 +308,11 @@ func (h *Audit) Error(c *gin.Context) {
httpx.OKWithData(c, item)
}
func (h *Audit) Errors(c *gin.Context) {
p, size := page(c)
p, size, err := page(c)
if err != nil {
httpx.Fail(c, err.Error())
return
}
createdAtRange := make([]time.Time, 0, 2)
for _, raw := range c.QueryArray("createdAtRange[]") {
value, parseErr := time.Parse(time.RFC3339, raw)
+16 -4
View File
@@ -161,8 +161,20 @@ func (h *Dictionary) FindDetail(c *gin.Context) {
httpx.Write(c, httpx.CodeSuccess, gin.H{"reSysDictionaryDetail": item}, "查询成功")
}
func (h *Dictionary) Details(c *gin.Context) {
p, size := page(c)
dictionaryID, _ := strconv.ParseUint(c.Query("sysDictionaryID"), 10, 64)
p, size, err := page(c)
if err != nil {
httpx.Fail(c, err.Error())
return
}
var dictionaryID uint64
if raw, exists := c.GetQuery("sysDictionaryID"); exists && raw != "" {
var parseErr error
dictionaryID, parseErr = strconv.ParseUint(raw, 10, 0)
if parseErr != nil {
httpx.Fail(c, parseErr.Error())
return
}
}
filter := biz.DictionaryDetailFilter{DictionaryID: uint(dictionaryID), Label: c.Query("label"), Value: c.Query("value")}
if raw, exists := c.GetQuery("status"); exists {
value, parseErr := strconv.ParseBool(raw)
@@ -199,7 +211,7 @@ func (h *Dictionary) Details(c *gin.Context) {
func (h *Dictionary) Tree(byType bool) gin.HandlerFunc {
return func(c *gin.Context) {
rawID := c.Query("sysDictionaryID")
id, parseErr := strconv.ParseUint(rawID, 10, 64)
id, parseErr := strconv.ParseUint(rawID, 10, 32)
typ := ""
if byType {
typ = c.Query("type")
@@ -241,7 +253,7 @@ func (h *Dictionary) Path(c *gin.Context) {
httpx.Fail(c, "字典详情ID不能为空")
return
}
id, parseErr := strconv.ParseUint(rawID, 10, 64)
id, parseErr := strconv.ParseUint(rawID, 10, 32)
if parseErr != nil {
httpx.Fail(c, "字典详情ID格式错误")
return
+11 -1
View File
@@ -71,6 +71,12 @@ func (h *Export) Delete(c *gin.Context) {
httpx.Fail(c, err.Error())
return
}
// Deleting an empty model is rejected by GORM's global-update guard in the
// reference endpoint. Preserve the same externally visible failure.
if req.ID == 0 {
httpx.Fail(c, "删除失败")
return
}
if err := h.service.Delete(c.Request.Context(), []uint{req.ID}); err != nil {
httpx.Fail(c, "删除失败")
return
@@ -103,7 +109,11 @@ func (h *Export) Find(c *gin.Context) {
httpx.OKWithData(c, gin.H{"resysExportTemplate": item})
}
func (h *Export) List(c *gin.Context) {
p, size := page(c)
p, size, err := page(c)
if err != nil {
httpx.Fail(c, err.Error())
return
}
start, startErr := parseTime(c.Query("startCreatedAt"))
end, endErr := parseTime(c.Query("endCreatedAt"))
if startErr != nil || endErr != nil {
+1 -1
View File
@@ -67,7 +67,7 @@ func (h *Media) Delete(c *gin.Context) {
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "删除成功")
}
func (h *Media) DeleteMany(c *gin.Context) {
var req dto.IDsRequest
var req dto.MediaBatchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
+2 -2
View File
@@ -106,7 +106,7 @@ func (h *Menu) Delete(c *gin.Context) {
httpx.Fail(c, "ID值不能为空")
return
}
if err := h.service.Delete(c.Request.Context(), req.ID); err != nil {
if err := h.service.Delete(c.Request.Context(), uint(req.ID)); err != nil {
httpx.Fail(c, "删除失败:"+err.Error())
return
}
@@ -123,7 +123,7 @@ func (h *Menu) Find(c *gin.Context) {
httpx.Fail(c, "ID值不能为空")
return
}
item, err := h.service.Find(c.Request.Context(), req.ID)
item, err := h.service.Find(c.Request.Context(), uint(req.ID))
if err != nil {
httpx.Fail(c, "获取失败")
return
+4 -8
View File
@@ -1,8 +1,6 @@
package handler
import (
"strconv"
"kra/internal/server/httpx"
"kra/internal/service"
"kra/internal/service/dto"
@@ -45,8 +43,7 @@ func (h *Parameter) Update(c *gin.Context) {
}
func (h *Parameter) Delete(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64)
if err := h.service.DeleteParameters(c.Request.Context(), []uint{uint(id)}); err != nil {
if err := h.service.DeleteParameters(c.Request.Context(), []string{c.Query("ID")}); err != nil {
httpx.Fail(c, "删除失败:"+err.Error())
return
}
@@ -54,7 +51,7 @@ func (h *Parameter) Delete(c *gin.Context) {
}
func (h *Parameter) DeleteMany(c *gin.Context) {
ids := IDsFromQuery(c)
ids := c.QueryArray("IDs[]")
if err := h.service.DeleteParameters(c.Request.Context(), ids); err != nil {
httpx.Fail(c, "批量删除失败:"+err.Error())
return
@@ -63,8 +60,7 @@ func (h *Parameter) DeleteMany(c *gin.Context) {
}
func (h *Parameter) Find(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("ID"), 10, 64)
item, err := h.service.Parameter(c.Request.Context(), uint(id), "")
item, err := h.service.ParameterByID(c.Request.Context(), c.Query("ID"))
if err != nil {
httpx.Fail(c, "查询失败:"+err.Error())
return
@@ -73,7 +69,7 @@ func (h *Parameter) Find(c *gin.Context) {
}
func (h *Parameter) Get(c *gin.Context) {
item, err := h.service.Parameter(c.Request.Context(), 0, c.Query("key"))
item, err := h.service.ParameterByKey(c.Request.Context(), c.Query("key"))
if err != nil {
httpx.Fail(c, "获取失败:"+err.Error())
return
+1 -4
View File
@@ -1,8 +1,6 @@
package handler
import (
"strconv"
"kra/internal/server/httpx"
"kra/internal/service"
"kra/internal/service/dto"
@@ -44,8 +42,7 @@ func (h *Permission) SetButtons(c *gin.Context) {
}
func (h *Permission) CanRemoveButton(c *gin.Context) {
id, _ := strconv.ParseUint(c.Query("id"), 10, 64)
allowed, err := h.service.CanRemoveButton(c.Request.Context(), uint(id))
allowed, err := h.service.CanRemoveButton(c.Request.Context(), c.Query("id"))
if err != nil {
httpx.Fail(c, err.Error())
return
+1 -1
View File
@@ -91,7 +91,7 @@ func (h *Public) Login(c *gin.Context) {
}
return
}
maxAge := int(time.Until(time.UnixMilli(result.ExpiresAt)).Seconds())
maxAge := int(time.UnixMilli(result.ExpiresAt).Unix() - time.Now().Unix())
httpx.SetTokenCookie(c, result.Token, maxAge)
httpx.Write(c, httpx.CodeSuccess, result, "登录成功")
}
+4 -2
View File
@@ -125,7 +125,7 @@ func (h *Task) AlertStream(c *gin.Context) {
httpx.Fail(c, "未获取到用户身份")
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Content-Type", "text/event-stream; charset=utf-8")
c.Header("Cache-Control", "no-cache, no-transform")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
@@ -135,7 +135,9 @@ func (h *Task) AlertStream(c *gin.Context) {
return
}
// Remove the normal server deadline for long-lived SSE connections.
_ = http.NewResponseController(c.Writer).SetWriteDeadline(time.Time{})
controller := http.NewResponseController(c.Writer)
_ = controller.SetWriteDeadline(time.Time{})
_ = controller.SetReadDeadline(time.Time{})
events := h.service.Subscribe(claims.ID)
defer h.service.Unsubscribe(claims.ID, events)
ticker := time.NewTicker(30 * time.Second)
+7 -4
View File
@@ -71,7 +71,10 @@ func (h *User) Create(c *gin.Context) {
httpx.Fail(c, err.Error())
return
}
httpx.Write(c, httpx.CodeError, gin.H{"user": dto.UserResponse{}}, "注册失败")
if result == nil {
result = &dto.UserResponse{}
}
httpx.Write(c, httpx.CodeError, gin.H{"user": result}, "注册失败")
return
}
httpx.Write(c, httpx.CodeSuccess, gin.H{"user": result}, "注册成功")
@@ -115,7 +118,7 @@ func (h *User) UpdateSelf(c *gin.Context) {
func (h *User) Delete(c *gin.Context) {
var req dto.IDRequest
if err := c.ShouldBind(&req); err != nil {
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
@@ -235,7 +238,7 @@ func (h *User) SwitchAuthority(c *gin.Context) {
}
c.Header("new-token", login.Token)
c.Header("new-expires-at", strconv.FormatInt(login.ExpiresAt/1000, 10))
maxAge := int(time.Until(time.UnixMilli(login.ExpiresAt)).Seconds())
maxAge := int(time.UnixMilli(login.ExpiresAt).Unix() - time.Now().Unix())
httpx.SetTokenCookie(c, login.Token, maxAge)
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "修改成功")
}
@@ -246,7 +249,7 @@ func (h *User) Get(c *gin.Context) {
httpx.NoAuth(c, "未登录或非法访问")
return
}
result, err := h.service.User(c.Request.Context(), claims.ID)
result, err := h.service.UserByUUID(c.Request.Context(), claims.UUID)
if err != nil {
httpx.Fail(c, "获取失败")
return
+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}, "获取成功")
}
func (h *Version) Export(c *gin.Context) {
var req dto.ExportVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -122,7 +123,7 @@ func (h *Version) Download(c *gin.Context) {
c.Header("Content-Type", "application/json")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", name))
c.Header("Content-Length", strconv.Itoa(len(raw)))
c.Data(http.StatusOK, "application/json; charset=utf-8", []byte(raw))
c.Data(http.StatusOK, "application/json", []byte(raw))
}
func (h *Version) Import(c *gin.Context) {
var req dto.ImportVersionRequest
+3 -3
View File
@@ -39,15 +39,15 @@ func (s *AnnouncementService) Update(ctx context.Context, in AnnouncementInput)
return s.uc.Update(ctx, announcementDO(in))
}
func (s *AnnouncementService) Delete(ctx context.Context, id uint) error {
func (s *AnnouncementService) Delete(ctx context.Context, id string) error {
return s.uc.Delete(ctx, id)
}
func (s *AnnouncementService) DeleteByIDs(ctx context.Context, ids []uint) error {
func (s *AnnouncementService) DeleteByIDs(ctx context.Context, ids []string) error {
return s.uc.DeleteByIDs(ctx, ids)
}
func (s *AnnouncementService) Find(ctx context.Context, id uint) (*dto.AnnouncementResponse, error) {
func (s *AnnouncementService) Find(ctx context.Context, id string) (*dto.AnnouncementResponse, error) {
item, err := s.uc.Find(ctx, id)
if err != nil {
return nil, err
+5 -2
View File
@@ -71,7 +71,7 @@ func (s *APIService) Groups(ctx context.Context) ([]string, map[string]string, e
return nil, nil, err
}
seen := make(map[string]bool)
groups := make([]string, 0)
var groups []string
groupAPIMap := make(map[string]string)
for _, item := range items {
if !seen[item.APIGroup] {
@@ -126,7 +126,10 @@ func (s *APIService) PolicyPathResponses(ctx context.Context, authorityID uint)
if err != nil {
return nil, err
}
out := make([]dto.PolicyPathRequest, 0, len(items))
var out []dto.PolicyPathRequest
if len(items) > 0 {
out = make([]dto.PolicyPathRequest, 0, len(items))
}
for _, item := range items {
out = append(out, dto.PolicyPathRequest{Path: item.Path, Method: item.Method})
}
+8 -2
View File
@@ -28,7 +28,11 @@ func authorityDomain(req *dto.AuthorityRequest) *biz.Authority {
}
func authorityResponse(value *biz.Authority) *dto.AuthorityResponse {
return &dto.AuthorityResponse{CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: value.DeletedAt, AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: []*dto.AuthorityResponse{}, Menus: []any{}, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}
var menus []*dto.MenuResponse
if value.Menus != nil {
menus = menuResponses(value.Menus)
}
return &dto.AuthorityResponse{CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: value.DeletedAt, AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: nil, Menus: menus, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}
}
func (s *AuthorityService) Authorities(ctx context.Context) ([]*dto.AuthorityResponse, error) {
@@ -56,7 +60,9 @@ func (s *AuthorityService) CopyAuthorityRequest(ctx context.Context, req *dto.Co
if err := s.uc.CopyAuthority(ctx, req.OldAuthorityID, value); err != nil {
return nil, err
}
return authorityResponse(value), nil
result := authorityResponse(value)
result.Children = []*dto.AuthorityResponse{}
return result, nil
}
func (s *AuthorityService) UpdateAuthorityRequest(ctx context.Context, req *dto.AuthorityRequest) (*dto.AuthorityResponse, error) {
+6 -3
View File
@@ -14,9 +14,12 @@ func NewDepartmentService(uc *biz.DepartmentUsecase) *DepartmentService {
}
func departmentResponse(value *biz.Department) *dto.DepartmentResponse {
children := make([]*dto.DepartmentResponse, 0, len(value.Children))
for _, child := range value.Children {
children = append(children, departmentResponse(child))
var children []*dto.DepartmentResponse
if value.Children != nil {
children = make([]*dto.DepartmentResponse, 0, len(value.Children))
for _, child := range value.Children {
children = append(children, departmentResponse(child))
}
}
var leader any
if value.Leader != nil {
+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 {
return nil, err
}
details := make([]*dto.DictionaryDetailExportResponse, 0, len(v.Details))
var details []*dto.DictionaryDetailExportResponse
for _, detail := range v.Details {
details = append(details, &dto.DictionaryDetailExportResponse{
Label: detail.Label, Value: detail.Value, Extend: detail.Extend,
+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 {
AuthorityID uint `json:"authorityId" form:"authorityId"`
UserIDs []uint `json:"userIds"`
UserIDs []uint `json:"userIds" form:"userIds"`
}
type SetDataScopeRequest struct {
AuthorityID uint `json:"authorityId" form:"authorityId"`
DataScope int `json:"dataScope"`
DepartmentIDs []uint `json:"deptIds"`
DataScope int `json:"dataScope" form:"dataScope"`
DepartmentIDs []uint `json:"deptIds" form:"deptIds"`
}
type AuthorityResponse struct {
@@ -36,7 +36,7 @@ type AuthorityResponse struct {
AuthorityName string `json:"authorityName"`
ParentID *uint `json:"parentId"`
Children []*AuthorityResponse `json:"children"`
Menus []any `json:"menus"`
Menus []*MenuResponse `json:"menus"`
DataScope int `json:"dataScope"`
DefaultRouter string `json:"defaultRouter"`
}
+3
View File
@@ -61,6 +61,9 @@ type IDRequest struct {
type IDsRequest struct {
IDs []uint `json:"ids"`
}
type MediaBatchDeleteRequest struct {
IDs []uint `json:"ids" binding:"required"`
}
type RenameMediaRequest struct {
ID uint `json:"ID"`
Name string `json:"name"`
+39 -17
View File
@@ -13,19 +13,27 @@ type MenuMetaRequest struct {
}
type MenuButtonRequest struct {
ID uint `json:"ID"`
Name string `json:"name"`
Description string `json:"desc"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
ID uint `json:"ID"`
Name string `json:"name"`
Description string `json:"desc"`
SysBaseMenuID uint `json:"sysBaseMenuID"`
}
type MenuParameterRequest struct {
ID uint `json:"ID"`
Type string `json:"type"`
Key string `json:"key"`
Value string `json:"value"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
ID uint `json:"ID"`
SysBaseMenuID uint `json:"SysBaseMenuID"`
Type string `json:"type"`
Key string `json:"key"`
Value string `json:"value"`
}
type MenuRequest struct {
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
ID uint `json:"ID"`
ParentID uint `json:"parentId"`
Path string `json:"path"`
@@ -39,20 +47,16 @@ type MenuRequest struct {
}
type DeleteMenuRequest struct {
ID uint `json:"ID"`
ID int `json:"id"`
}
type GetMenuRequest struct {
ID uint `json:"id"`
}
type AuthorityMenuItemRequest struct {
ID uint `json:"ID"`
ID int `json:"id"`
}
type SetAuthorityMenusRequest struct {
AuthorityID uint `json:"authorityId"`
Menus []AuthorityMenuItemRequest `json:"menus"`
AuthorityID uint `json:"authorityId"`
Menus []MenuRequest `json:"menus"`
}
type GetAuthorityMenusRequest struct {
@@ -108,14 +112,32 @@ type MenuResponse struct {
Children []*MenuResponse `json:"children"`
Parameters []MenuParameterResponse `json:"parameters"`
MenuButtons []MenuButtonResponse `json:"menuBtn"`
Authorities []*AuthorityResponse `json:"authoritys"`
}
type DynamicMenuResponse struct {
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
ID uint `json:"ID"`
MenuID uint `json:"menuId"`
ParentID uint `json:"parentId"`
Path string `json:"path"`
Name string `json:"name"`
Hidden bool `json:"hidden"`
Component string `json:"component"`
Sort int `json:"sort"`
Meta MenuMetaResponse `json:"meta"`
Children []*DynamicMenuResponse `json:"children"`
Parameters []MenuParameterResponse `json:"parameters"`
MenuButtons []MenuButtonResponse `json:"menuBtn"`
Buttons map[string]uint `json:"btns"`
Authorities []*AuthorityResponse `json:"authoritys"`
}
type AuthorityMenuResponse struct {
// The administration response embeds the base-menu fields, so menu fields are
// flat in the response (there is no sysBaseMenu envelope). Keep the
// authority ID internal, matching the compatible json:"-" field.
*MenuResponse
MenuID uint `json:"menuId"`
*DynamicMenuResponse
AuthorityID uint `json:"-"`
}
+3 -2
View File
@@ -1,8 +1,9 @@
package dto
type GetAuthorityButtonsRequest struct {
MenuID uint `json:"menuID"`
AuthorityID uint `json:"authorityId"`
MenuID uint `json:"menuID"`
AuthorityID uint `json:"authorityId"`
Selected []uint `json:"selected"`
}
type SetAuthorityButtonsRequest struct {
+7 -5
View File
@@ -31,11 +31,13 @@ type DictionaryDetailsByParentRequest struct {
IncludeChildren bool `json:"includeChildren" form:"includeChildren"`
}
type SystemParameterRequest struct {
ID uint `json:"ID" form:"ID"`
Name string `json:"name" form:"name" binding:"required"`
Key string `json:"key" form:"key" binding:"required"`
Value string `json:"value" form:"value" binding:"required"`
Description string `json:"desc" form:"desc"`
ID uint `json:"ID" form:"ID"`
CreatedAt time.Time `json:"CreatedAt" form:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt" form:"UpdatedAt"`
Name string `json:"name" form:"name" binding:"required"`
Key string `json:"key" form:"key" binding:"required"`
Value string `json:"value" form:"value" binding:"required"`
Description string `json:"desc" form:"desc"`
}
type SystemParameterSearchRequest struct {
+20 -19
View File
@@ -75,25 +75,26 @@ type LoginLogRequest struct {
}
type UserResponse struct {
ID uint `json:"ID"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
DeletedAt any `json:"-"`
UUID string `json:"uuid"`
Username string `json:"userName"`
NickName string `json:"nickName"`
HeaderImg string `json:"headerImg"`
AuthorityID uint `json:"authorityId"`
Authority *AuthorityResponse `json:"authority"`
Authorities []*AuthorityResponse `json:"authorities"`
DeptID uint `json:"deptId"`
Department any `json:"dept"`
Departments []*DepartmentResponse `json:"departments"`
Positions []*PositionResponse `json:"positions"`
Phone string `json:"phone"`
Email string `json:"email"`
Enable int `json:"enable"`
OriginSetting map[string]any `json:"originSetting"`
ID uint `json:"ID"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
DeletedAt any `json:"-"`
UUID string `json:"uuid"`
Username string `json:"userName"`
NickName string `json:"nickName"`
HeaderImg string `json:"headerImg"`
AuthorityID uint `json:"authorityId"`
Authority *AuthorityResponse `json:"authority"`
Authorities []*AuthorityResponse `json:"authorities"`
DeptID uint `json:"deptId"`
Department any `json:"dept"`
Departments []*DepartmentResponse `json:"departments"`
Positions []*PositionResponse `json:"positions"`
Phone string `json:"phone"`
Email string `json:"email"`
Enable int `json:"enable"`
OriginSetting map[string]any `json:"originSetting"`
PasswordUpdatedAt *time.Time `json:"passwordUpdatedAt"`
}
type ServerInfoResponse struct {
+4 -4
View File
@@ -7,10 +7,10 @@ type VersionResponse struct {
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
DeletedAt any `json:"-"`
VersionName string `json:"versionName"`
VersionCode string `json:"versionCode"`
Description string `json:"description"`
VersionData string `json:"versionData"`
VersionName *string `json:"versionName"`
VersionCode *string `json:"versionCode"`
Description *string `json:"description"`
VersionData *string `json:"versionData"`
}
type VersionSearchRequest struct {
+64 -19
View File
@@ -56,9 +56,10 @@ func (s *MenuService) AuthorityMenus(ctx context.Context, authorityID uint) ([]*
if err != nil {
return nil, err
}
out := make([]*dto.AuthorityMenuResponse, 0, len(items))
var out []*dto.AuthorityMenuResponse
for _, item := range items {
out = append(out, &dto.AuthorityMenuResponse{MenuResponse: menuResponse(item), MenuID: item.ID, AuthorityID: authorityID})
item.MenuID = item.ID
out = append(out, &dto.AuthorityMenuResponse{DynamicMenuResponse: dynamicMenuResponse(item), AuthorityID: authorityID})
}
return out, nil
}
@@ -74,42 +75,86 @@ func (s *MenuService) SetRoles(ctx context.Context, req *dto.SetMenuRolesRequest
}
func menuDomain(v *dto.MenuRequest) *biz.Menu {
menu := &biz.Menu{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType}
menu := &biz.Menu{CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.Meta.ActiveName, KeepAlive: v.Meta.KeepAlive, DefaultMenu: v.Meta.DefaultMenu, Title: v.Meta.Title, Icon: v.Meta.Icon, CloseTab: v.Meta.CloseTab, TransitionType: v.Meta.TransitionType}
menu.Buttons = make([]*biz.MenuButton, 0, len(v.MenuButtons))
for _, button := range v.MenuButtons {
menu.Buttons = append(menu.Buttons, &biz.MenuButton{ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID})
menu.Buttons = append(menu.Buttons, &biz.MenuButton{CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, ID: button.ID, Name: button.Name, Description: button.Description, MenuID: v.ID})
}
menu.Parameters = make([]*biz.MenuParameter, 0, len(v.Parameters))
for _, parameter := range v.Parameters {
menu.Parameters = append(menu.Parameters, &biz.MenuParameter{ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
menu.Parameters = append(menu.Parameters, &biz.MenuParameter{CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, ID: parameter.ID, MenuID: v.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
}
return menu
}
func menuResponse(menu *biz.Menu) *dto.MenuResponse {
children := make([]*dto.MenuResponse, 0, len(menu.Children))
for _, child := range menu.Children {
children = append(children, menuResponse(child))
}
buttons := make([]dto.MenuButtonResponse, 0, len(menu.Buttons))
buttonAuthorities := make(map[string]uint, len(menu.Buttons))
for _, button := range menu.Buttons {
buttons = append(buttons, dto.MenuButtonResponse{CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, ID: button.ID, Name: button.Name, Description: button.Description, SysBaseMenuID: button.MenuID})
if button.AuthorityID != 0 {
buttonAuthorities[button.Name] = button.AuthorityID
var children []*dto.MenuResponse
if menu.Children != nil {
children = make([]*dto.MenuResponse, 0, len(menu.Children))
for _, child := range menu.Children {
children = append(children, menuResponse(child))
}
}
parameters := make([]dto.MenuParameterResponse, 0, len(menu.Parameters))
for _, parameter := range menu.Parameters {
parameters = append(parameters, dto.MenuParameterResponse{CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, ID: parameter.ID, SysBaseMenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
var buttons []dto.MenuButtonResponse
if menu.Buttons != nil {
buttons = make([]dto.MenuButtonResponse, 0, len(menu.Buttons))
for _, button := range menu.Buttons {
buttons = append(buttons, dto.MenuButtonResponse{CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, ID: button.ID, Name: button.Name, Description: button.Description, SysBaseMenuID: button.MenuID})
}
}
return &dto.MenuResponse{CreatedAt: menu.CreatedAt, UpdatedAt: menu.UpdatedAt, ID: menu.ID, MenuID: menu.MenuID, ParentID: menu.ParentID, Path: menu.Path, Name: menu.Name, Hidden: menu.Hidden, Component: menu.Component, Sort: menu.Sort, Meta: dto.MenuMetaResponse{ActiveName: menu.ActiveName, KeepAlive: menu.KeepAlive, DefaultMenu: menu.DefaultMenu, Title: menu.Title, Icon: menu.Icon, CloseTab: menu.CloseTab, TransitionType: menu.TransitionType}, Children: children, Parameters: parameters, MenuButtons: buttons, Buttons: buttonAuthorities}
var parameters []dto.MenuParameterResponse
if menu.Parameters != nil {
parameters = make([]dto.MenuParameterResponse, 0, len(menu.Parameters))
for _, parameter := range menu.Parameters {
parameters = append(parameters, dto.MenuParameterResponse{CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, ID: parameter.ID, SysBaseMenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
}
}
return &dto.MenuResponse{CreatedAt: menu.CreatedAt, UpdatedAt: menu.UpdatedAt, ID: menu.ID, MenuID: menu.MenuID, ParentID: menu.ParentID, Path: menu.Path, Name: menu.Name, Hidden: menu.Hidden, Component: menu.Component, Sort: menu.Sort, Meta: dto.MenuMetaResponse{ActiveName: menu.ActiveName, KeepAlive: menu.KeepAlive, DefaultMenu: menu.DefaultMenu, Title: menu.Title, Icon: menu.Icon, CloseTab: menu.CloseTab, TransitionType: menu.TransitionType}, Children: children, Parameters: parameters, MenuButtons: buttons}
}
func dynamicMenuResponse(menu *biz.Menu) *dto.DynamicMenuResponse {
var children []*dto.DynamicMenuResponse
if menu.Children != nil {
children = make([]*dto.DynamicMenuResponse, 0, len(menu.Children))
for _, child := range menu.Children {
children = append(children, dynamicMenuResponse(child))
}
}
var parameters []dto.MenuParameterResponse
if menu.Parameters != nil {
parameters = make([]dto.MenuParameterResponse, 0, len(menu.Parameters))
for _, parameter := range menu.Parameters {
parameters = append(parameters, dto.MenuParameterResponse{CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, ID: parameter.ID, SysBaseMenuID: parameter.MenuID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
}
}
var buttonAuthorities map[string]uint
for _, button := range menu.Buttons {
if button.AuthorityID == 0 {
continue
}
if buttonAuthorities == nil {
buttonAuthorities = make(map[string]uint)
}
buttonAuthorities[button.Name] = button.AuthorityID
}
return &dto.DynamicMenuResponse{CreatedAt: menu.CreatedAt, UpdatedAt: menu.UpdatedAt, ID: menu.ID, MenuID: menu.MenuID, ParentID: menu.ParentID, Path: menu.Path, Name: menu.Name, Hidden: menu.Hidden, Component: menu.Component, Sort: menu.Sort, Meta: dto.MenuMetaResponse{ActiveName: menu.ActiveName, KeepAlive: menu.KeepAlive, DefaultMenu: menu.DefaultMenu, Title: menu.Title, Icon: menu.Icon, CloseTab: menu.CloseTab, TransitionType: menu.TransitionType}, Children: children, Parameters: parameters, MenuButtons: nil, Buttons: buttonAuthorities}
}
func menuResponses(items []*biz.Menu) []*dto.MenuResponse {
if items == nil {
return nil
}
out := make([]*dto.MenuResponse, 0, len(items))
for _, item := range items {
out = append(out, menuResponse(item))
}
return out
}
func dynamicMenuResponses(items []*biz.Menu) []*dto.DynamicMenuResponse {
out := make([]*dto.DynamicMenuResponse, 0, len(items))
for _, item := range items {
out = append(out, dynamicMenuResponse(item))
}
return out
}
+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 {
return &biz.SystemParameter{ID: value.ID, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description}
return &biz.SystemParameter{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, Name: value.Name, Key: value.Key, Value: value.Value, Desc: value.Description}
}
func (s *ParameterService) CreateParameterRequest(ctx context.Context, req *dto.SystemParameterRequest) error {
return s.CreateParameter(ctx, parameterDomain(req))
@@ -40,8 +40,15 @@ func (s *ParameterService) Parameters(ctx context.Context, page, size int, q *bi
}
return out, total, nil
}
func (s *ParameterService) Parameter(ctx context.Context, id uint, key string) (*dto.SystemParameterResponse, error) {
v, err := s.uc.FindParameter(ctx, id, key)
func (s *ParameterService) ParameterByID(ctx context.Context, id string) (*dto.SystemParameterResponse, error) {
v, err := s.uc.FindParameterByID(ctx, id)
if err != nil {
return nil, err
}
return parameterDTO(v), nil
}
func (s *ParameterService) ParameterByKey(ctx context.Context, key string) (*dto.SystemParameterResponse, error) {
v, err := s.uc.FindParameterByKey(ctx, key)
if err != nil {
return nil, err
}
@@ -53,6 +60,6 @@ func (s *ParameterService) CreateParameter(ctx context.Context, v *biz.SystemPar
func (s *ParameterService) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error {
return s.uc.UpdateParameter(ctx, v)
}
func (s *ParameterService) DeleteParameters(ctx context.Context, ids []uint) error {
func (s *ParameterService) DeleteParameters(ctx context.Context, ids []string) error {
return s.uc.DeleteParameters(ctx, ids)
}
+1 -1
View File
@@ -20,6 +20,6 @@ func (s *PermissionService) SetSelectedButtons(ctx context.Context, authorityID,
return s.uc.SetSelectedButtons(ctx, authorityID, menuID, ids)
}
func (s *PermissionService) CanRemoveButton(ctx context.Context, id uint) (bool, error) {
func (s *PermissionService) CanRemoveButton(ctx context.Context, id string) (bool, error) {
return s.uc.CanRemoveButton(ctx, id)
}
+1 -1
View File
@@ -25,7 +25,7 @@ func (s *SecurityService) CurrentSecurity(ctx context.Context) (*biz.SecurityCon
return s.uc.Current(ctx)
}
func (s *SecurityService) Security(ctx context.Context) (*dto.SecurityConfigResponse, error) {
value, err := s.CurrentSecurity(ctx)
value, err := s.uc.Security(ctx)
if err != nil {
return nil, err
}
+10 -2
View File
@@ -45,12 +45,20 @@ func (s *UserService) User(ctx context.Context, id uint) (*dto.UserResponse, err
return convertUser(value), nil
}
func (s *UserService) Menus(ctx context.Context, authorityID uint) ([]*dto.MenuResponse, error) {
func (s *UserService) UserByUUID(ctx context.Context, uuid string) (*dto.UserResponse, error) {
value, err := s.uc.UserByUUID(ctx, uuid)
if err != nil {
return nil, err
}
return convertUser(value), nil
}
func (s *UserService) Menus(ctx context.Context, authorityID uint) ([]*dto.DynamicMenuResponse, error) {
menus, err := s.uc.Menus(ctx, authorityID)
if err != nil {
return nil, err
}
return menuResponses(menus), nil
return dynamicMenuResponses(menus), nil
}
func (s *UserService) ListUsers(ctx context.Context, page, pageSize int, filter *biz.UserListFilter) ([]*dto.UserResponse, int64, error) {
+27 -12
View File
@@ -6,24 +6,39 @@ import (
)
func convertAuthority(value biz.Authority) *dto.AuthorityResponse {
return &dto.AuthorityResponse{CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: value.DeletedAt, AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: []*dto.AuthorityResponse{}, Menus: []any{}, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}
var menus []*dto.MenuResponse
if value.Menus != nil {
menus = menuResponses(value.Menus)
}
return &dto.AuthorityResponse{CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, DeletedAt: value.DeletedAt, AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, Children: nil, Menus: menus, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}
}
func convertUser(user *biz.User) *dto.UserResponse {
authorities := make([]*dto.AuthorityResponse, 0, len(user.Authorities))
for _, value := range user.Authorities {
authorities = append(authorities, convertAuthority(value))
var authorities []*dto.AuthorityResponse
if user.Authorities != nil {
authorities = make([]*dto.AuthorityResponse, 0, len(user.Authorities))
for _, value := range user.Authorities {
authorities = append(authorities, convertAuthority(value))
}
}
departments := make([]*dto.DepartmentResponse, 0, len(user.Departments))
for i := range user.Departments {
departments = append(departments, departmentResponse(&user.Departments[i]))
var departments []*dto.DepartmentResponse
if user.Departments != nil {
departments = make([]*dto.DepartmentResponse, 0, len(user.Departments))
for i := range user.Departments {
departments = append(departments, departmentResponse(&user.Departments[i]))
}
}
positions := make([]*dto.PositionResponse, 0, len(user.Positions))
for i := range user.Positions {
positions = append(positions, positionResponse(&user.Positions[i]))
var positions []*dto.PositionResponse
if user.Positions != nil {
positions = make([]*dto.PositionResponse, 0, len(user.Positions))
for i := range user.Positions {
positions = append(positions, positionResponse(&user.Positions[i]))
}
}
var department any = map[string]any{}
// The primary department is represented by a zero-value object when it is
// absent, preserving the response shape expected by the administration UI.
var department any = dto.DepartmentResponse{}
if user.Department != nil {
department = departmentResponse(user.Department)
}
return &dto.UserResponse{ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, DeletedAt: nil, UUID: user.UUID, Username: user.Username, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Authority: convertAuthority(user.Authority), Authorities: authorities, DeptID: user.DeptID, Department: department, Departments: departments, Positions: positions, Phone: user.Phone, Email: user.Email, Enable: user.Enable, OriginSetting: user.OriginSetting}
return &dto.UserResponse{ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, DeletedAt: nil, UUID: user.UUID, Username: user.Username, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Authority: convertAuthority(user.Authority), Authorities: authorities, DeptID: user.DeptID, Department: department, Departments: departments, Positions: positions, Phone: user.Phone, Email: user.Email, Enable: user.Enable, OriginSetting: user.OriginSetting, PasswordUpdatedAt: user.PasswordUpdatedAt}
}
+18 -9
View File
@@ -132,6 +132,15 @@ func exportMenu(value *biz.Menu) *versionExportMenu {
func versionDTO(v *biz.Version) *dto.VersionResponse {
return &dto.VersionResponse{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, DeletedAt: nil, VersionName: v.Name, VersionCode: v.Code, Description: v.Description, VersionData: v.Data}
}
func versionValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func versionPointer(value string) *string { return &value }
func (s *VersionService) Versions(ctx context.Context, page, size int, name, code string, createdAtRange []*time.Time) ([]*dto.VersionResponse, int64, error) {
items, total, err := s.uc.ListVersions(ctx, page, size, name, code, createdAtRange)
if err != nil {
@@ -155,11 +164,11 @@ func (s *VersionService) VersionData(ctx context.Context, id uint) (string, stri
if err != nil {
return "", "", err
}
if v.Data != "" {
return v.Data, v.Code, nil
if v.Data != nil && *v.Data != "" {
return *v.Data, versionValue(v.Code), nil
}
payload := versionExportDocument{
Version: versionExportInfo{Name: v.Name, Code: v.Code, Description: v.Description, ExportTime: v.CreatedAt.Format("2006-01-02 15:04:05")},
Version: versionExportInfo{Name: versionValue(v.Name), Code: versionValue(v.Code), Description: versionValue(v.Description), ExportTime: v.CreatedAt.Format("2006-01-02 15:04:05")},
Menus: []*versionExportMenu{},
APIs: []versionExportAPI{},
}
@@ -167,7 +176,7 @@ func (s *VersionService) VersionData(ctx context.Context, id uint) (string, stri
if marshalErr != nil {
return "", "", marshalErr
}
return string(raw), v.Code, nil
return string(raw), versionValue(v.Code), nil
}
func (s *VersionService) DeleteVersions(ctx context.Context, ids []uint) error {
return s.uc.DeleteVersions(ctx, ids)
@@ -206,7 +215,7 @@ func (s *VersionService) Export(ctx context.Context, name, code, description str
if err != nil {
return &biz.VersionStageError{Stage: biz.VersionStageJSON, Err: err}
}
if err = s.uc.CreateVersion(ctx, &biz.Version{Name: name, Code: code, Description: description, Data: string(raw)}); err != nil {
if err = s.uc.CreateVersion(ctx, &biz.Version{Name: versionPointer(name), Code: versionPointer(code), Description: versionPointer(description), Data: versionPointer(string(raw))}); err != nil {
return &biz.VersionStageError{Stage: biz.VersionStageSave, Err: err}
}
return nil
@@ -250,10 +259,10 @@ func (s *VersionService) ImportRequest(ctx context.Context, req *dto.ImportVersi
}
raw, _ := json.Marshal(req)
_ = s.uc.CreateVersion(ctx, &biz.Version{
Name: req.Version.Name,
Code: req.Version.Code + "_imported_" + time.Now().Format("20060102150405"),
Description: "导入版本: " + req.Version.Description,
Data: string(raw),
Name: versionPointer(req.Version.Name),
Code: versionPointer(req.Version.Code + "_imported_" + time.Now().Format("20060102150405")),
Description: versionPointer("导入版本: " + req.Version.Description),
Data: versionPointer(string(raw)),
})
return nil
}
+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.
s.logger.InfoContext(ctx, "定时任务加载完成", "task_count", len(items))
} else {
@@ -222,6 +222,13 @@ func (s *TaskScheduler) Subscribe(userID uint) chan []byte {
if s.subscribers[userID] == nil {
s.subscribers[userID] = map[chan []byte]struct{}{}
}
if len(s.subscribers[userID]) >= 10 {
for old := range s.subscribers[userID] {
delete(s.subscribers[userID], old)
close(old)
break
}
}
s.subscribers[userID][ch] = struct{}{}
s.subMu.Unlock()
return ch
+25
View File
@@ -23,3 +23,28 @@ func TestCloseSubscribersClosesEveryAlertStream(t *testing.T) {
// closed and removed every channel.
scheduler.Unsubscribe(1, first)
}
func TestSubscribeCapsConnectionsPerUser(t *testing.T) {
scheduler := &TaskScheduler{subscribers: map[uint]map[chan []byte]struct{}{}}
channels := make([]chan []byte, 0, 11)
for i := 0; i < 11; i++ {
channels = append(channels, scheduler.Subscribe(7))
}
if got := len(scheduler.subscribers[7]); got != 10 {
t.Fatalf("subscriber count = %d, want 10", got)
}
closed := 0
for _, ch := range channels {
select {
case _, open := <-ch:
if !open {
closed++
}
default:
}
}
if closed != 1 {
t.Fatalf("evicted subscriber count = %d, want 1", closed)
}
scheduler.closeSubscribers()
}