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
@@ -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)
}
}