优化
This commit is contained in:
@@ -44,12 +44,12 @@ func (r *announcementRepo) Create(ctx context.Context, item *biz.Announcement) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *announcementRepo) Delete(ctx context.Context, id uint) error {
|
||||
return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, id).Error
|
||||
func (r *announcementRepo) Delete(ctx context.Context, id string) error {
|
||||
return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []uint) error {
|
||||
return r.data.gormDB.WithContext(ctx).Where("id IN ?", ids).Delete(&announcementPO{}).Error
|
||||
func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []string) error {
|
||||
return r.data.gormDB.WithContext(ctx).Delete(&[]announcementPO{}, "id IN ?", ids).Error
|
||||
}
|
||||
|
||||
func (r *announcementRepo) Update(ctx context.Context, item *biz.Announcement) error {
|
||||
@@ -57,9 +57,9 @@ func (r *announcementRepo) Update(ctx context.Context, item *biz.Announcement) e
|
||||
return r.data.gormDB.WithContext(ctx).Model(&announcementPO{}).Where("id = ?", item.ID).Updates(&po).Error
|
||||
}
|
||||
|
||||
func (r *announcementRepo) Find(ctx context.Context, id uint) (*biz.Announcement, error) {
|
||||
func (r *announcementRepo) Find(ctx context.Context, id string) (*biz.Announcement, error) {
|
||||
var po announcementPO
|
||||
if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil {
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return announcementToBiz(po), nil
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz"
|
||||
)
|
||||
|
||||
func TestAnnouncementRepositoryKeepsRawIDQuerySemantics(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
repo := &announcementRepo{data: data}
|
||||
ctx := context.Background()
|
||||
item := &biz.Announcement{Title: "notice"}
|
||||
if err := repo.Create(ctx, item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found, err := repo.Find(ctx, "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found.ID != item.ID {
|
||||
t.Fatalf("found announcement ID = %d, want %d", found.ID, item.ID)
|
||||
}
|
||||
if err = repo.DeleteByIDs(ctx, nil); err != nil {
|
||||
t.Fatalf("empty bulk delete must be a successful no-op: %v", err)
|
||||
}
|
||||
if err = repo.Delete(ctx, "not-a-number"); err != nil {
|
||||
t.Fatalf("raw invalid ID must be delegated to the storage query: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err = data.gormDB.WithContext(ctx).Model(&announcementPO{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("raw invalid ID removed rows: count=%d", count)
|
||||
}
|
||||
}
|
||||
+57
-35
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/casbin/casbin/v3"
|
||||
casbinmodel "github.com/casbin/casbin/v3/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type apiRepo struct{ data *Data }
|
||||
@@ -167,11 +168,11 @@ func (r *apiRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]*
|
||||
}
|
||||
}
|
||||
var pos []apiPO
|
||||
maxSize := 100
|
||||
if q != nil && q.StrictAll {
|
||||
maxSize = 0
|
||||
query := db.Order(order)
|
||||
if q == nil || !q.StrictAll {
|
||||
query = applyRequiredPagination(query, page, size, 100)
|
||||
}
|
||||
if err := applyPagination(db.Order(order), page, size, maxSize).Find(&pos).Error; err != nil {
|
||||
if err := query.Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]*biz.API, 0, len(pos))
|
||||
@@ -248,7 +249,10 @@ func (r *apiRepo) PolicyPaths(ctx context.Context, aid uint) ([]*biz.API, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*biz.API, 0, len(rows))
|
||||
var out []*biz.API
|
||||
if len(rows) > 0 {
|
||||
out = make([]*biz.API, 0, len(rows))
|
||||
}
|
||||
for _, row := range rows {
|
||||
out = append(out, &biz.API{Path: row.V1, Method: row.V2})
|
||||
}
|
||||
@@ -264,46 +268,64 @@ func (r *apiRepo) SetPolicyPaths(ctx context.Context, aid uint, paths []*biz.API
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if authority.ParentID != nil && *authority.ParentID != 0 {
|
||||
allowed, err := r.PolicyPaths(ctx, actor.AuthorityID)
|
||||
var registered []apiPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Find(®istered).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
allowedSet := make(map[string]bool, len(registered))
|
||||
if authority.ParentID == nil || *authority.ParentID == 0 {
|
||||
for _, item := range registered {
|
||||
allowedSet[item.Path+"\x00"+item.Method] = true
|
||||
}
|
||||
} else {
|
||||
policies, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), actor.AuthorityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allowedSet := make(map[string]bool, len(allowed))
|
||||
for _, item := range allowed {
|
||||
allowedSet[item.Path+"\x00"+item.Method] = true
|
||||
policySet := make(map[string]bool, len(policies))
|
||||
for _, item := range policies {
|
||||
policySet[item.V1+"\x00"+item.V2] = true
|
||||
}
|
||||
for _, item := range paths {
|
||||
if !allowedSet[item.Path+"\x00"+item.Method] {
|
||||
return errors.New("存在api不在权限列表中")
|
||||
for _, item := range registered {
|
||||
key := item.Path + "\x00" + item.Method
|
||||
if policySet[key] {
|
||||
allowedSet[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := deletePoliciesForAuthority(tx, aid); err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the legacy relation table clean for upgraded installations. It
|
||||
// is not consulted for authorization anymore.
|
||||
if err := tx.Where("authority_id = ?", aid).Delete(&authorityAPIPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rules := make([]casbinRulePO, 0, len(paths))
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
for _, path := range paths {
|
||||
key := path.Path + "\x00" + path.Method
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
for _, item := range paths {
|
||||
if !allowedSet[item.Path+"\x00"+item.Method] {
|
||||
return errors.New("存在api不在权限列表中")
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
rules = append(rules, newPolicyRule(aid, path.Path, path.Method))
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
return tx.Create(&rules).Error
|
||||
}
|
||||
db := r.data.gormDB.WithContext(ctx)
|
||||
// The reference enforcer removes the old authority policies before it
|
||||
// attempts to add the replacement set. Keep that ordering visible even
|
||||
// though Kra reads policies directly from the database rather than through
|
||||
// an in-memory enforcer.
|
||||
if err := deletePoliciesForAuthority(db, aid); err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the legacy relation table clean for upgraded installations. It is
|
||||
// not consulted for authorization anymore.
|
||||
if err := db.Where("authority_id = ?", aid).Delete(&authorityAPIPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rules := make([]casbinRulePO, 0, len(paths))
|
||||
seen := make(map[string]struct{})
|
||||
for _, path := range paths {
|
||||
key := strconv.FormatUint(uint64(aid), 10) + path.Path + path.Method
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
})
|
||||
seen[key] = struct{}{}
|
||||
rules = append(rules, newPolicyRule(aid, path.Path, path.Method))
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rules).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *apiRepo) IgnoredAPIs(ctx context.Context) ([]*biz.API, error) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
)
|
||||
|
||||
func newPolicyTestData(t *testing.T) *Data {
|
||||
t.Helper()
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&apiPO{}, &casbinRulePO{}, &authorityPO{}, &authorityAPIPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &Data{
|
||||
gormDB: newReloadableDB(db, nil),
|
||||
runtime: conf.NewRuntime(
|
||||
&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}},
|
||||
&conf.AdminBackend{System: &conf.AdminBackend_System{UseStrictAuth: true}},
|
||||
),
|
||||
}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
return data
|
||||
}
|
||||
|
||||
func apiPolicyUintPointer(value uint) *uint { return &value }
|
||||
|
||||
func TestSetPolicyPathsStrictRootRequiresRegisteredAPI(t *testing.T) {
|
||||
data := newPolicyTestData(t)
|
||||
ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: 888})
|
||||
if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{
|
||||
{AuthorityID: 888, ParentID: apiPolicyUintPointer(0)},
|
||||
{AuthorityID: 999, ParentID: apiPolicyUintPointer(888)},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&apiPO{Path: "/known", Method: "POST"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 999, []*biz.API{{Path: "/unknown", Method: "POST"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "存在api不在权限列表中") {
|
||||
t.Fatalf("unregistered root assignment error = %v", err)
|
||||
}
|
||||
if err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 999, []*biz.API{{Path: "/known", Method: "POST"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPolicyPathsStrictChildRejectsStalePolicy(t *testing.T) {
|
||||
data := newPolicyTestData(t)
|
||||
ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: 1001})
|
||||
if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{
|
||||
{AuthorityID: 888, ParentID: apiPolicyUintPointer(0)},
|
||||
{AuthorityID: 1001, ParentID: apiPolicyUintPointer(888)},
|
||||
{AuthorityID: 1002, ParentID: apiPolicyUintPointer(1001)},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&apiPO{Path: "/known", Method: "POST"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&[]casbinRulePO{
|
||||
newPolicyRule(1001, "/known", "POST"),
|
||||
newPolicyRule(1001, "/stale", "POST"),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 1002, []*biz.API{{Path: "/stale", Method: "POST"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "存在api不在权限列表中") {
|
||||
t.Fatalf("stale child assignment error = %v", err)
|
||||
}
|
||||
if err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 1002, []*biz.API{{Path: "/known", Method: "POST"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPolicyPathsUsesCompatibleDedupeKey(t *testing.T) {
|
||||
data := newPolicyTestData(t)
|
||||
ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: 888})
|
||||
if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{
|
||||
{AuthorityID: 888, ParentID: apiPolicyUintPointer(0)},
|
||||
{AuthorityID: 999, ParentID: apiPolicyUintPointer(888)},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&[]apiPO{
|
||||
{Path: "/a", Method: "BC"},
|
||||
{Path: "/aB", Method: "C"},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&apiRepo{data: data}).SetPolicyPaths(ctx, 999, []*biz.API{
|
||||
{Path: "/a", Method: "BC"},
|
||||
{Path: "/aB", Method: "C"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := policyRowsForAuthority(data.gormDB.WithContext(ctx), 999)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].V1 != "/a" || rows[0].V2 != "BC" {
|
||||
t.Fatalf("deduplicated policies = %#v, want only the first concatenated-key match", rows)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID
|
||||
return nil, 0, err
|
||||
}
|
||||
var pos []apiTokenPO
|
||||
if err := applyPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
if err := applyRequiredPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
userIDs := make([]uint, 0, len(pos))
|
||||
|
||||
+106
-42
@@ -77,11 +77,12 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au
|
||||
}
|
||||
}
|
||||
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
// The administration contract checks existence with First and treats every result other than
|
||||
// ErrRecordNotFound as a duplicate-role error. Keep that precedence so
|
||||
// duplicate IDs win over strict-tree validation and the API message stays
|
||||
// compatible.
|
||||
var existing authorityPO
|
||||
if err := tx.Where("authority_id = ?", value.AuthorityID).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("存在相同角色id")
|
||||
}
|
||||
if value.DefaultRouter == "" {
|
||||
@@ -92,6 +93,8 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au
|
||||
return err
|
||||
}
|
||||
value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt
|
||||
value.DataScope, value.DefaultRouter = po.DataScope, po.DefaultRouter
|
||||
value.Menus = []*biz.Menu{{ID: 1, Path: "dashboard", Name: "dashboard", Component: "view/dashboard/index.vue", Sort: 1, Title: "仪表盘", Icon: "setting"}}
|
||||
var dashboard menuPO
|
||||
if err := tx.Where("name = ?", "dashboard").First(&dashboard).Error; err == nil {
|
||||
if err = tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: value.AuthorityID, SysBaseMenuID: dashboard.ID}).Error; err != nil {
|
||||
@@ -113,10 +116,18 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
|
||||
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
config := r.data.runtime.Admin()
|
||||
actor, hasActor := biz.ActorFromContext(ctx)
|
||||
// Strict-authority checks apply to every actor. A root actor is
|
||||
// allowed to create/copy a role directly below itself; non-root actors
|
||||
// may target themselves' descendants. Do not special-case 888 here:
|
||||
// the configured strict-auth switch is the source of truth.
|
||||
// Reject a duplicate target ID before performing the
|
||||
// hierarchy/Casbin checks that happen later in UpdateCasbin.
|
||||
var existing authorityPO
|
||||
if err := tx.Where("authority_id = ?", value.AuthorityID).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("存在相同角色id")
|
||||
}
|
||||
|
||||
// UpdateCasbin checks that the newly-created role is in the actor's
|
||||
// strict-authority subtree. Before inserting it, the equivalent check is
|
||||
// that its requested parent is the actor or one of the actor's allowed
|
||||
// descendants. This preserves the compatible error and prevents a transient role
|
||||
// from being visible when the check is guaranteed to fail.
|
||||
strict := hasActor && config != nil && config.System != nil && config.System.UseStrictAuth
|
||||
if strict {
|
||||
allowed, err := r.strictAuthorityIDs(ctx, actor.AuthorityID)
|
||||
@@ -128,18 +139,12 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
|
||||
return errors.New("您提交的角色ID不合法")
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", value.AuthorityID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
return errors.New("存在相同角色id")
|
||||
}
|
||||
po := authorityPO{AuthorityID: value.AuthorityID, AuthorityName: value.AuthorityName, ParentID: value.ParentID, DataScope: value.DataScope, DefaultRouter: value.DefaultRouter}
|
||||
if err := tx.Create(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
value.CreatedAt, value.UpdatedAt = po.CreatedAt, po.UpdatedAt
|
||||
value.DataScope, value.DefaultRouter = po.DataScope, po.DefaultRouter
|
||||
copyLinks := func(table string, destination any, columns map[string]any) error {
|
||||
return tx.Table(table).Where(columns).Find(destination).Error
|
||||
}
|
||||
@@ -155,6 +160,20 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(menus) > 0 {
|
||||
menuIDs := make([]uint, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
menuIDs = append(menuIDs, menu.SysBaseMenuID)
|
||||
}
|
||||
var copiedMenus []menuPO
|
||||
if err := tx.Where("id IN ?", menuIDs).Order("sort").Find(&copiedMenus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
value.Menus = make([]*biz.Menu, 0, len(copiedMenus))
|
||||
for _, menu := range copiedMenus {
|
||||
value.Menus = append(value.Menus, menuFromPO(menu))
|
||||
}
|
||||
}
|
||||
apis, err := policyRowsForAuthority(tx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -163,7 +182,7 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
|
||||
seenPolicies := make(map[string]struct{}, len(apis))
|
||||
for _, api := range apis {
|
||||
if strict {
|
||||
allowed, err := policyExists(tx, actor.AuthorityID, api.V1, api.V2)
|
||||
allowed, err := r.copyPolicyAllowed(ctx, tx, actor.AuthorityID, api.V1, api.V2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -198,6 +217,28 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint,
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// copyPolicyAllowed mirrors the administration API-list and policy validation.
|
||||
// A policy must refer to a currently registered API. Root roles
|
||||
// may copy any registered API, while non-root roles may copy only APIs present
|
||||
// in their own Casbin policy set.
|
||||
func (r *authorityAccessRepo) copyPolicyAllowed(ctx context.Context, tx *gorm.DB, actorID uint, path, method string) (bool, error) {
|
||||
var actor authorityPO
|
||||
if err := tx.WithContext(ctx).Where("authority_id = ?", actorID).First(&actor).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
var apiCount int64
|
||||
if err := tx.WithContext(ctx).Model(&apiPO{}).Where("path = ? AND method = ?", path, method).Count(&apiCount).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if apiCount == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if actor.ParentID == nil || *actor.ParentID == 0 {
|
||||
return true, nil
|
||||
}
|
||||
return policyExists(tx.WithContext(ctx), actorID, path, method)
|
||||
}
|
||||
func (r *authorityAccessRepo) UpdateAuthority(ctx context.Context, value *biz.Authority) error {
|
||||
db := r.data.gormDB.WithContext(ctx)
|
||||
var current authorityPO
|
||||
@@ -217,11 +258,21 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
|
||||
return err
|
||||
}
|
||||
var users, children int64
|
||||
if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Count(&users).Error; err != nil {
|
||||
// Associated-user checks only block on users that still
|
||||
// exist (and are not soft-deleted). Counting raw join rows would make an
|
||||
// orphaned association impossible to clean up, which is observably
|
||||
// different from the administration contract.
|
||||
var linkedUserIDs []uint
|
||||
if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_user_id", &linkedUserIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if users > 0 {
|
||||
return errors.New("此角色有用户正在使用禁止删除")
|
||||
if len(linkedUserIDs) > 0 {
|
||||
if err := tx.Model(&userPO{}).Where("id IN ?", linkedUserIDs).Count(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if users > 0 {
|
||||
return errors.New("此角色有用户正在使用禁止删除")
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&userPO{}).Where("authority_id = ?", id).Count(&users).Error; err != nil {
|
||||
return err
|
||||
@@ -235,6 +286,9 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
|
||||
if children > 0 {
|
||||
return errors.New("此角色存在子角色不允许删除")
|
||||
}
|
||||
if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -253,18 +307,28 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
|
||||
})
|
||||
}
|
||||
func (r *authorityAccessRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) {
|
||||
var pos []authorityPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := r.data.gormDB.WithContext(ctx)
|
||||
var allowed map[uint]bool
|
||||
config := r.data.runtime.Admin()
|
||||
if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth {
|
||||
var err error
|
||||
allowed, err = r.strictAuthorityIDs(ctx, actor.AuthorityID)
|
||||
if err != nil {
|
||||
if actor, ok := biz.ActorFromContext(ctx); ok {
|
||||
// The current authority is loaded even when strict mode is disabled;
|
||||
// an invalid token authority therefore fails the list request instead of
|
||||
// exposing the complete role tree.
|
||||
var current authorityPO
|
||||
if err := db.Where("authority_id = ?", actor.AuthorityID).First(¤t).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config != nil && config.System != nil && config.System.UseStrictAuth {
|
||||
var err error
|
||||
allowed, err = r.strictAuthorityIDs(ctx, actor.AuthorityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
var pos []authorityPO
|
||||
if err := db.Find(&pos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*biz.Authority, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
@@ -285,15 +349,6 @@ func (r *authorityAccessRepo) SetAuthorityUsers(ctx context.Context, id uint, id
|
||||
if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
links := make([]userAuthorityPO, 0, len(ids))
|
||||
for _, uid := range ids {
|
||||
links = append(links, userAuthorityPO{SysUserID: uid, SysAuthorityAuthorityID: id})
|
||||
}
|
||||
if len(links) > 0 {
|
||||
if err := tx.Create(&links).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
selected := map[uint]bool{}
|
||||
for _, uid := range ids {
|
||||
selected[uid] = true
|
||||
@@ -305,16 +360,25 @@ func (r *authorityAccessRepo) SetAuthorityUsers(ctx context.Context, id uint, id
|
||||
var user userPO
|
||||
if err := tx.First(&user, uid).Error; err == nil && user.AuthorityID == id {
|
||||
var another userAuthorityPO
|
||||
if err = tx.Where("sys_user_id = ?", uid).First(&another).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if err = tx.Where("sys_user_id = ?", uid).First(&another).Error; err != nil {
|
||||
// Missing users and association lookup errors are ignored
|
||||
// while choosing a replacement role.
|
||||
continue
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&user).Update("authority_id", another.SysAuthorityAuthorityID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
links := make([]userAuthorityPO, 0, len(ids))
|
||||
for _, uid := range ids {
|
||||
links = append(links, userAuthorityPO{SysUserID: uid, SysAuthorityAuthorityID: id})
|
||||
}
|
||||
if len(links) > 0 {
|
||||
if err := tx.Create(&links).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -328,7 +392,7 @@ func (r *authorityAccessRepo) SetDataScope(ctx context.Context, id uint, scope i
|
||||
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", id).Update("data_scope", scope).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil {
|
||||
if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityDepartmentPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if scope == 5 {
|
||||
@@ -345,7 +409,7 @@ func (r *authorityAccessRepo) SetDataScope(ctx context.Context, id uint, scope i
|
||||
}
|
||||
func (r *authorityAccessRepo) DataScopeDepartmentIDs(ctx context.Context, id uint) ([]uint, error) {
|
||||
var ids []uint
|
||||
err := r.data.gormDB.WithContext(ctx).Model(&authorityDepartmentPO{}).Where("authority_id = ?", id).Pluck("department_id", &ids).Error
|
||||
err := r.data.gormDB.WithContext(ctx).Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_department_id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
func (r *authorityAccessRepo) ResolveDataScope(ctx context.Context, authorityID, userID uint) (biz.DataScope, error) {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func authorityUintPointer(value uint) *uint { return &value }
|
||||
|
||||
func enableStrictAuthorityTestMode(data *Data) {
|
||||
currentData, _ := data.runtime.Values()
|
||||
data.runtime.Replace(currentData, &conf.AdminBackend{System: &conf.AdminBackend_System{UseStrictAuth: true}})
|
||||
}
|
||||
|
||||
func TestCopyAuthorityStrictPolicyValidationMatchesAdministrationContract(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
enableStrictAuthorityTestMode(data)
|
||||
db := data.gormDB.WithContext(context.Background())
|
||||
rootID := uint(888)
|
||||
if err := db.Create(&[]authorityPO{
|
||||
{AuthorityID: rootID, AuthorityName: "root", ParentID: authorityUintPointer(0)},
|
||||
{AuthorityID: 900, AuthorityName: "source", ParentID: &rootID},
|
||||
{AuthorityID: 901, AuthorityName: "stale-source", ParentID: &rootID},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&apiPO{Path: "/registered", Method: "GET"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&[]casbinRulePO{
|
||||
newPolicyRule(900, "/registered", "GET"),
|
||||
newPolicyRule(901, "/stale", "POST"),
|
||||
// A root role is allowed to grant registered APIs without already
|
||||
// holding the policy, but a stale policy is still not a registered API.
|
||||
newPolicyRule(rootID, "/stale", "POST"),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo := &authorityAccessRepo{data: data}
|
||||
ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: rootID})
|
||||
created := &biz.Authority{AuthorityID: 910, AuthorityName: "copy", ParentID: &rootID}
|
||||
if err := repo.CopyAuthority(ctx, 900, created); err != nil {
|
||||
t.Fatalf("root copy of a registered API failed: %v", err)
|
||||
}
|
||||
if exists, err := policyExists(db, created.AuthorityID, "/registered", "GET"); err != nil || !exists {
|
||||
t.Fatalf("copied policy exists = %v, err = %v", exists, err)
|
||||
}
|
||||
|
||||
staleCopy := &biz.Authority{AuthorityID: 911, AuthorityName: "stale-copy", ParentID: &rootID}
|
||||
if err := repo.CopyAuthority(ctx, 901, staleCopy); err == nil || err.Error() != "存在api不在权限列表中" {
|
||||
t.Fatalf("stale API copy error = %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Unscoped().Model(&authorityPO{}).Where("authority_id = ?", staleCopy.AuthorityID).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("failed copy persisted target authority: count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAuthorityDuplicateIDWinsOverStrictParentValidation(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
enableStrictAuthorityTestMode(data)
|
||||
db := data.gormDB.WithContext(context.Background())
|
||||
rootID := uint(888)
|
||||
if err := db.Create(&[]authorityPO{
|
||||
{AuthorityID: rootID, AuthorityName: "root", ParentID: authorityUintPointer(0)},
|
||||
{AuthorityID: 920, AuthorityName: "existing", ParentID: &rootID},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalidParent := uint(999999)
|
||||
ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: rootID})
|
||||
err := (&authorityAccessRepo{data: data}).CopyAuthority(ctx, 0, &biz.Authority{AuthorityID: 920, AuthorityName: "duplicate", ParentID: &invalidParent})
|
||||
if err == nil || err.Error() != "存在相同角色id" {
|
||||
t.Fatalf("duplicate copy error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuthoritiesRequiresCurrentAuthorityOutsideStrictMode(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := biz.NewActorContext(context.Background(), biz.Actor{AuthorityID: 999999})
|
||||
if _, err := (&authorityAccessRepo{data: data}).ListAuthorities(ctx); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("missing current authority error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAuthorityIgnoresOrphanedUserAssociation(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
db := data.gormDB.WithContext(ctx)
|
||||
if err := db.Create(&authorityPO{AuthorityID: 930, AuthorityName: "orphan-links"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&userAuthorityPO{SysUserID: 999999, SysAuthorityAuthorityID: 930}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&authorityAccessRepo{data: data}).DeleteAuthority(ctx, 930); err != nil {
|
||||
t.Fatalf("delete with orphaned user association: %v", err)
|
||||
}
|
||||
var authority authorityPO
|
||||
if err := db.Unscoped().Where("authority_id = ?", 930).First(&authority).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("deleted authority lookup error = %v", err)
|
||||
}
|
||||
var links int64
|
||||
if err := db.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", 930).Count(&links).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if links != 0 {
|
||||
t.Fatalf("orphaned user associations were not removed: %d", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAuthorityUsersKeepsPrimaryRoleFallbackBehavior(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
db := data.gormDB.WithContext(ctx)
|
||||
if err := db.Create(&[]authorityPO{{AuthorityID: 940, AuthorityName: "removed"}, {AuthorityID: 941, AuthorityName: "fallback"}}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := []userPO{
|
||||
{Username: "with-fallback", Password: "hash", AuthorityID: 940, Enable: 1},
|
||||
{Username: "without-fallback", Password: "hash", AuthorityID: 940, Enable: 1},
|
||||
}
|
||||
if err := db.Create(&users).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&[]userAuthorityPO{
|
||||
{SysUserID: users[0].ID, SysAuthorityAuthorityID: 940},
|
||||
{SysUserID: users[0].ID, SysAuthorityAuthorityID: 941},
|
||||
{SysUserID: users[1].ID, SysAuthorityAuthorityID: 940},
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (&authorityAccessRepo{data: data}).SetAuthorityUsers(ctx, 940, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persisted []userPO
|
||||
if err := db.Where("id IN ?", []uint{users[0].ID, users[1].ID}).Order("id").Find(&persisted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(persisted) != 2 || persisted[0].AuthorityID != 941 || persisted[1].AuthorityID != 940 {
|
||||
t.Fatalf("primary authority fallback = %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorityCustomDataScopeUsesCompatibleColumns(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
repo := &authorityAccessRepo{data: data}
|
||||
if err := repo.SetDataScope(ctx, 950, 5, []uint{7, 7, 8}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids, err := repo.DataScopeDepartmentIDs(ctx, 950)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 3 || ids[0] != 7 || ids[1] != 7 || ids[2] != 8 {
|
||||
t.Fatalf("custom department IDs = %v", ids)
|
||||
}
|
||||
if err = repo.SetDataScope(ctx, 950, 3, []uint{9}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids, err = repo.DataScopeDepartmentIDs(ctx, 950)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 0 {
|
||||
t.Fatalf("non-custom scope retained departments: %v", ids)
|
||||
}
|
||||
}
|
||||
@@ -172,11 +172,12 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
||||
return writeConfigDocument(configPath, &document)
|
||||
}
|
||||
|
||||
// persistDatabaseConfig writes only the database selected on the init page.
|
||||
// Initialization must not serialize the partially populated runtime config
|
||||
// back over the template: doing so removes all omitted/default settings from
|
||||
// data and admin and leaves the next startup with no visible configuration.
|
||||
func (d *Data) persistDatabaseConfig(database *conf.Data_Database) error {
|
||||
// persistDatabaseConfig writes only the database selected on the init page and
|
||||
// the freshly generated JWT signing key. Initialization must not serialize the
|
||||
// partially populated runtime config back over the template: doing so removes
|
||||
// all omitted/default settings from data and admin and leaves the next startup
|
||||
// with no visible configuration.
|
||||
func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey string) error {
|
||||
d.configMu.Lock()
|
||||
defer d.configMu.Unlock()
|
||||
|
||||
@@ -215,6 +216,11 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database) error {
|
||||
if err = setYAMLMapping(&document, "data", map[string]any{"database": databaseValue}); err != nil {
|
||||
return err
|
||||
}
|
||||
if signingKey != "" {
|
||||
if err = setYAMLMapping(&document, "admin", map[string]any{"jwt": map[string]any{"signing_key": signingKey}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writeConfigDocument(configPath, &document)
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,8 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
|
||||
c = &conf.Data{}
|
||||
}
|
||||
if c.Database == nil {
|
||||
// An empty database block is the Kratos equivalent of GVA's empty
|
||||
// Dbname: the service starts on the bootstrap database so /init/checkdb
|
||||
// An empty database block starts the service on the bootstrap database
|
||||
// so /init/checkdb
|
||||
// and /init/initdb remain available.
|
||||
c.Database = &conf.Data_Database{}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *
|
||||
return nil, 0, err
|
||||
}
|
||||
var pos []dataAccessLogPO
|
||||
if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]*biz.DataAccessLog, 0, len(pos))
|
||||
|
||||
@@ -32,15 +32,17 @@ type departmentPO struct {
|
||||
func (departmentPO) TableName() string { return "sys_departments" }
|
||||
|
||||
type userDepartmentPO struct {
|
||||
UserID uint `gorm:"primaryKey;column:sys_user_id"`
|
||||
DepartmentID uint `gorm:"primaryKey;column:sys_department_id"`
|
||||
// Match GVA's join model: no composite primary key, so repeated IDs in a
|
||||
// request are persisted in the same order/shape as the reference service.
|
||||
UserID uint `gorm:"column:sys_user_id"`
|
||||
DepartmentID uint `gorm:"column:sys_department_id"`
|
||||
}
|
||||
|
||||
func (userDepartmentPO) TableName() string { return "sys_user_departments" }
|
||||
|
||||
type authorityDepartmentPO struct {
|
||||
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
|
||||
DepartmentID uint `gorm:"primaryKey;column:department_id"`
|
||||
AuthorityID uint `gorm:"column:sys_authority_authority_id;index"`
|
||||
DepartmentID uint `gorm:"column:sys_department_id"`
|
||||
}
|
||||
|
||||
func (authorityDepartmentPO) TableName() string { return "sys_authority_departments" }
|
||||
@@ -152,6 +154,12 @@ func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*b
|
||||
items := make([]*biz.Department, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
item := deptFromPO(po)
|
||||
if name == "" {
|
||||
// Recursive GORM child queries initialize leaf collections to an empty
|
||||
// slice. Preserve that tree response shape while search and single-item
|
||||
// queries keep the model's nil children value.
|
||||
item.Children = []*biz.Department{}
|
||||
}
|
||||
nodes[po.ID] = item
|
||||
items = append(items, item)
|
||||
}
|
||||
@@ -161,7 +169,7 @@ func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*b
|
||||
if name != "" {
|
||||
return items, nil
|
||||
}
|
||||
var roots []*biz.Department
|
||||
roots := make([]*biz.Department, 0)
|
||||
for _, po := range pos {
|
||||
n := nodes[po.ID]
|
||||
if p := nodes[po.ParentID]; p != nil {
|
||||
|
||||
+93
-46
@@ -75,6 +75,10 @@ func (r *dictionaryRepo) CreateDictionary(ctx context.Context, v *biz.Dictionary
|
||||
}
|
||||
|
||||
func (r *dictionaryRepo) ImportDictionary(ctx context.Context, dictionary *biz.Dictionary, details []*biz.DictionaryDetail) error {
|
||||
var existing dictionaryPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("type = ?", dictionary.Type).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("存在相同的type,不允许导入")
|
||||
}
|
||||
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
po := dictionaryPO{Name: dictionary.Name, Type: dictionary.Type, Status: dictionary.Status, Desc: dictionary.Desc, ParentID: dictionary.ParentID}
|
||||
if err := tx.Create(&po).Error; err != nil {
|
||||
@@ -183,15 +187,13 @@ func (r *dictionaryRepo) FindDictionary(ctx context.Context, id uint, typ string
|
||||
}
|
||||
out := dictionaryFromPO(po)
|
||||
if details {
|
||||
out.Details, _, err = r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: out.ID})
|
||||
if err == nil {
|
||||
filtered := out.Details[:0]
|
||||
for _, item := range out.Details {
|
||||
if item.Status != nil && *item.Status {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
out.Details = filtered
|
||||
var detailPOs []dictionaryDetailPO
|
||||
if err = db.Where("sys_dictionary_id = ? AND status = ?", out.ID, true).Order("sort").Find(&detailPOs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Details = make([]*biz.DictionaryDetail, 0, len(detailPOs))
|
||||
for _, detail := range detailPOs {
|
||||
out.Details = append(out.Details, detailFromPO(detail))
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
@@ -203,11 +205,14 @@ func (r *dictionaryRepo) ExportDictionary(ctx context.Context, id uint) (*biz.Di
|
||||
return nil, err
|
||||
}
|
||||
value := dictionaryFromPO(po)
|
||||
details, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: id})
|
||||
if err != nil {
|
||||
var detailPOs []dictionaryDetailPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", id).Order("sort").Find(&detailPOs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value.Details = details
|
||||
value.Details = make([]*biz.DictionaryDetail, 0, len(detailPOs))
|
||||
for _, detail := range detailPOs {
|
||||
value.Details = append(value.Details, detailFromPO(detail))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, name, typ string, details bool) ([]*biz.Dictionary, int64, error) {
|
||||
@@ -221,19 +226,26 @@ func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, n
|
||||
return nil, 0, err
|
||||
}
|
||||
var pos []dictionaryPO
|
||||
if err := applyPagination(db.Order("id"), page, size, 0).Find(&pos).Error; err != nil {
|
||||
// Both dictionary-list endpoints are intentionally unpaged. The page and
|
||||
// size parameters remain in the repository seam for existing callers, but
|
||||
// the administration contract ignores them.
|
||||
if err := db.Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
parentIDs := make([]uint, 0, len(pos))
|
||||
dictionaryIDs := make([]uint, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
parentIDs = append(parentIDs, po.ID)
|
||||
dictionaryIDs = append(dictionaryIDs, po.ID)
|
||||
// The reference list-with-details endpoint only preloads details;
|
||||
// its ordinary list endpoint preloads one level of dictionary children.
|
||||
if !details {
|
||||
parentIDs = append(parentIDs, po.ID)
|
||||
}
|
||||
}
|
||||
childrenByParent := make(map[uint][]dictionaryPO)
|
||||
if len(parentIDs) > 0 {
|
||||
if !details && len(parentIDs) > 0 {
|
||||
var children []dictionaryPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("parent_id IN ?", parentIDs).Order("id").Find(&children).Error; err != nil {
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("parent_id IN ?", parentIDs).Find(&children).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for _, child := range children {
|
||||
@@ -255,13 +267,16 @@ func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, n
|
||||
out := make([]*biz.Dictionary, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
v := dictionaryFromPO(po)
|
||||
for _, child := range childrenByParent[po.ID] {
|
||||
v.Children = append(v.Children, dictionaryFromPO(child))
|
||||
}
|
||||
if details {
|
||||
v.Details = make([]*biz.DictionaryDetail, 0, len(detailsByDictionary[v.ID]))
|
||||
for _, detail := range detailsByDictionary[v.ID] {
|
||||
v.Details = append(v.Details, detailFromPO(detail))
|
||||
}
|
||||
} else {
|
||||
v.Children = make([]*biz.Dictionary, 0, len(childrenByParent[po.ID]))
|
||||
for _, child := range childrenByParent[po.ID] {
|
||||
v.Children = append(v.Children, dictionaryFromPO(child))
|
||||
}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
@@ -309,28 +324,25 @@ func (r *dictionaryRepo) UpdateDictionaryDetail(ctx context.Context, v *biz.Dict
|
||||
level = parent.Level + 1
|
||||
path = strings.Trim(strings.Join([]string{parent.Path, fmt.Sprint(parent.ID)}, ","), ",")
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&dictionaryDetailPO{}).Where("id = ?", v.ID).Updates(map[string]any{"label": v.Label, "value": v.Value, "extend": v.Extend, "status": v.Status, "sort": v.Sort, "sys_dictionary_id": v.DictionaryID, "parent_id": v.ParentID, "level": level, "path": path}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return r.updateDictionaryDetailChildren(tx, v.ID, level, path)
|
||||
})
|
||||
if err := db.Model(&dictionaryDetailPO{}).Where("id = ?", v.ID).Updates(map[string]any{"label": v.Label, "value": v.Value, "extend": v.Extend, "status": v.Status, "sort": v.Sort, "sys_dictionary_id": v.DictionaryID, "parent_id": v.ParentID, "level": level, "path": path}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return r.updateDictionaryDetailChildren(db, v.ID, level, path)
|
||||
}
|
||||
|
||||
// updateDictionaryDetailChildren recomputes descendants after a move, as the
|
||||
// The service does this recursively. Updating level as well as path matters when a
|
||||
// subtree is moved beneath a different depth.
|
||||
func (r *dictionaryRepo) updateDictionaryDetailChildren(tx *gorm.DB, parentID uint, parentLevel int, parentPath string) error {
|
||||
// updateDictionaryDetailChildren recomputes descendants after a move. Updating
|
||||
// level as well as path matters when a subtree is moved beneath a different depth.
|
||||
func (r *dictionaryRepo) updateDictionaryDetailChildren(db *gorm.DB, parentID uint, parentLevel int, parentPath string) error {
|
||||
var children []dictionaryDetailPO
|
||||
if err := tx.Where("parent_id = ?", parentID).Order("sort,id").Find(&children).Error; err != nil {
|
||||
if err := db.Where("parent_id = ?", parentID).Find(&children).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range children {
|
||||
childPath := strings.Trim(strings.Join([]string{parentPath, fmt.Sprint(parentID)}, ","), ",")
|
||||
if err := tx.Model(&dictionaryDetailPO{}).Where("id = ?", child.ID).Updates(map[string]any{"level": parentLevel + 1, "path": childPath}).Error; err != nil {
|
||||
if err := db.Model(&dictionaryDetailPO{}).Where("id = ?", child.ID).Updates(map[string]any{"level": parentLevel + 1, "path": childPath}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.updateDictionaryDetailChildren(tx, child.ID, parentLevel+1, childPath); err != nil {
|
||||
if err := r.updateDictionaryDetailChildren(db, child.ID, parentLevel+1, childPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -378,7 +390,7 @@ func (r *dictionaryRepo) ListDictionaryDetails(ctx context.Context, page, size i
|
||||
return nil, 0, err
|
||||
}
|
||||
var pos []dictionaryDetailPO
|
||||
if err := applyPagination(db.Order("sort,id"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
if err := applyRequiredPagination(db.Order("sort,id"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]*biz.DictionaryDetail, 0, len(pos))
|
||||
@@ -398,22 +410,57 @@ func (r *dictionaryRepo) DictionaryDetailTree(ctx context.Context, dictionaryID
|
||||
}
|
||||
dictionaryID = dictionary.ID
|
||||
}
|
||||
items, _, err := r.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: dictionaryID})
|
||||
if err != nil {
|
||||
var pos []dictionaryDetailPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ? AND parent_id IS NULL", dictionaryID).Order("sort").Find(&pos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := map[uint]*biz.DictionaryDetail{}
|
||||
for _, item := range items {
|
||||
item.Children = []*biz.DictionaryDetail{}
|
||||
byID[item.ID] = item
|
||||
}
|
||||
roots := []*biz.DictionaryDetail{}
|
||||
for _, item := range items {
|
||||
if item.ParentID != nil && byID[*item.ParentID] != nil {
|
||||
byID[*item.ParentID].Children = append(byID[*item.ParentID].Children, item)
|
||||
} else if item.ParentID == nil {
|
||||
roots = append(roots, item)
|
||||
roots := make([]*biz.DictionaryDetail, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
item := detailFromPO(po)
|
||||
if err := r.loadDictionaryDetailChildren(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roots = append(roots, item)
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
func (r *dictionaryRepo) DictionaryDetailsByParent(ctx context.Context, dictionaryID uint, parentID *uint, includeChildren bool) ([]*biz.DictionaryDetail, error) {
|
||||
db := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", dictionaryID)
|
||||
if parentID == nil {
|
||||
db = db.Where("parent_id IS NULL")
|
||||
} else {
|
||||
db = db.Where("parent_id = ?", *parentID)
|
||||
}
|
||||
var pos []dictionaryDetailPO
|
||||
if err := db.Order("sort").Find(&pos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*biz.DictionaryDetail, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
item := detailFromPO(po)
|
||||
if includeChildren {
|
||||
if err := r.loadDictionaryDetailChildren(ctx, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *dictionaryRepo) loadDictionaryDetailChildren(ctx context.Context, parent *biz.DictionaryDetail) error {
|
||||
var pos []dictionaryDetailPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("parent_id = ?", parent.ID).Order("sort").Find(&pos).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
parent.Children = make([]*biz.DictionaryDetail, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
child := detailFromPO(po)
|
||||
if err := r.loadDictionaryDetailChildren(ctx, child); err != nil {
|
||||
return err
|
||||
}
|
||||
parent.Children = append(parent.Children, child)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz"
|
||||
)
|
||||
|
||||
func TestDictionaryListPreservesPreloadCollectionShapes(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
repo := &dictionaryRepo{data: data}
|
||||
|
||||
if err := repo.CreateDictionary(ctx, &biz.Dictionary{Name: "status", Type: "status"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.CreateDictionary(ctx, &biz.Dictionary{Name: "kind", Type: "kind"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plain, _, err := repo.ListDictionaries(ctx, 1, 1, "", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plain) != 2 || plain[0].Children == nil || len(plain[0].Children) != 0 {
|
||||
t.Fatalf("ordinary dictionary list children = %#v, want an empty preloaded slice", plain)
|
||||
}
|
||||
if plain[0].Details != nil {
|
||||
t.Fatalf("ordinary dictionary list details = %#v, want nil", plain[0].Details)
|
||||
}
|
||||
|
||||
withDetails, _, err := repo.ListDictionaries(ctx, 1, 1, "", "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(withDetails) != 2 || withDetails[0].Details == nil || len(withDetails[0].Details) != 0 {
|
||||
t.Fatalf("dictionary details preload = %#v, want an empty preloaded slice", withDetails)
|
||||
}
|
||||
if withDetails[0].Children != nil {
|
||||
t.Fatalf("details-only dictionary list children = %#v, want nil", withDetails[0].Children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDictionaryRejectsDuplicateType(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
repo := &dictionaryRepo{data: data}
|
||||
|
||||
if err := repo.CreateDictionary(ctx, &biz.Dictionary{Name: "first", Type: "duplicate"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.ImportDictionary(ctx, &biz.Dictionary{Name: "second", Type: "duplicate"}, nil); err == nil || err.Error() != "存在相同的type,不允许导入" {
|
||||
t.Fatalf("duplicate import error = %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := data.gormDB.WithContext(ctx).Model(&dictionaryPO{}).Where("type = ?", "duplicate").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("duplicate dictionary count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictionaryTreeAndParentQueriesPreserveLoadedChildrenShape(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
repo := &dictionaryRepo{data: data}
|
||||
|
||||
dictionary := &biz.Dictionary{Name: "tree", Type: "tree"}
|
||||
if err := repo.CreateDictionary(ctx, dictionary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent := &biz.DictionaryDetail{Label: "parent", Value: "parent", DictionaryID: dictionary.ID, Sort: 2}
|
||||
if err := repo.CreateDictionaryDetail(ctx, parent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parentID := parent.ID
|
||||
child := &biz.DictionaryDetail{Label: "child", Value: "child", DictionaryID: dictionary.ID, ParentID: &parentID, Sort: 1}
|
||||
if err := repo.CreateDictionaryDetail(ctx, child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tree, err := repo.DictionaryDetailTree(ctx, dictionary.ID, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tree) != 1 || len(tree[0].Children) != 1 || tree[0].Children[0].Children == nil {
|
||||
t.Fatalf("dictionary tree = %#v, want recursively loaded child slices", tree)
|
||||
}
|
||||
|
||||
flat, err := repo.DictionaryDetailsByParent(ctx, dictionary.ID, &parentID, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(flat) != 1 || flat[0].Children != nil {
|
||||
t.Fatalf("flat parent query = %#v, want nil children", flat)
|
||||
}
|
||||
|
||||
loaded, err := repo.DictionaryDetailsByParent(ctx, dictionary.ID, &parentID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0].Children == nil || len(loaded[0].Children) != 0 {
|
||||
t.Fatalf("recursive parent query = %#v, want an empty loaded child slice", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictionaryDetailListAppliesZeroLimit(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
repo := &dictionaryRepo{data: data}
|
||||
|
||||
dictionary := &biz.Dictionary{Name: "limited", Type: "limited"}
|
||||
if err := repo.CreateDictionary(ctx, dictionary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.CreateDictionaryDetail(ctx, &biz.DictionaryDetail{Label: "one", Value: "one", DictionaryID: dictionary.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
items, total, err := repo.ListDictionaryDetails(ctx, 0, 0, biz.DictionaryDetailFilter{DictionaryID: dictionary.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 0 {
|
||||
t.Fatalf("zero-limit detail list: total=%d items=%#v", total, items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserListAppliesZeroLimitForNegativePageSize(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
if err := data.gormDB.WithContext(ctx).Create(&userPO{Username: "limited", Password: "hash", AuthorityID: 888, Enable: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
items, total, err := (&userRepo{data: data}).ListUsers(ctx, 1, -1, &biz.UserListFilter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 0 {
|
||||
t.Fatalf("negative-size user list: total=%d items=%#v", total, items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepartmentTreeAndFlatResultsPreserveChildrenShape(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
repo := &departmentRepo{data: data}
|
||||
|
||||
root := &biz.Department{Name: "root"}
|
||||
if err := repo.CreateDepartment(ctx, root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rootPO departmentPO
|
||||
if err := data.gormDB.WithContext(ctx).Where("name = ?", "root").First(&rootPO).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.CreateDepartment(ctx, &biz.Department{Name: "child", ParentID: rootPO.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tree, err := repo.ListDepartments(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tree) != 1 || len(tree[0].Children) != 1 || tree[0].Children[0].Children == nil || len(tree[0].Children[0].Children) != 0 {
|
||||
t.Fatalf("department tree children = %#v, want every loaded node to expose a slice", tree)
|
||||
}
|
||||
|
||||
flat, err := repo.ListDepartments(ctx, "child")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(flat) != 1 || flat[0].Children != nil {
|
||||
t.Fatalf("flat department search children = %#v, want nil", flat)
|
||||
}
|
||||
|
||||
found, err := repo.FindDepartment(ctx, rootPO.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found.Children != nil {
|
||||
t.Fatalf("single department children = %#v, want nil", found.Children)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func errorFromPO(v errorRecordPO) *biz.ErrorRecord {
|
||||
}
|
||||
func (r *auditRecorderRepo) CreateError(ctx context.Context, v *biz.ErrorRecord) error {
|
||||
if !r.data.databaseReady.Load() {
|
||||
// GVA silently ignores error records before GVA_DB is initialized.
|
||||
// Silently ignore error records before the database is initialized.
|
||||
return nil
|
||||
}
|
||||
if v.Status == "" {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
)
|
||||
|
||||
func errorString(value string) *string { return &value }
|
||||
|
||||
func newErrorRecordRepos(t *testing.T) (*auditRecorderRepo, *auditQueryRepo) {
|
||||
t.Helper()
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&errorRecordPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
data := &Data{gormDB: newReloadableDB(db, nil)}
|
||||
data.databaseReady.Store(true)
|
||||
return &auditRecorderRepo{data: data}, &auditQueryRepo{data: data}
|
||||
}
|
||||
|
||||
func TestCreateErrorBeforeDatabaseInitializationIsNoop(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
recorder := &auditRecorderRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
|
||||
form := "后端"
|
||||
if err = recorder.CreateError(context.Background(), &biz.ErrorRecord{Form: &form}); err != nil {
|
||||
t.Fatalf("uninitialized error write = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateErrorPreservesNullableFieldsAndDefaultStatus(t *testing.T) {
|
||||
recorder, query := newErrorRecordRepos(t)
|
||||
createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC)
|
||||
form := "前端"
|
||||
if err := recorder.CreateError(context.Background(), &biz.ErrorRecord{
|
||||
ID: 41,
|
||||
CreatedAt: createdAt,
|
||||
Form: &form,
|
||||
Level: "error",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := query.FindError(context.Background(), 41)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Form == nil || *got.Form != form {
|
||||
t.Fatalf("form = %#v, want %q", got.Form, form)
|
||||
}
|
||||
if got.Info != nil || got.Solution != nil {
|
||||
t.Fatalf("nullable fields = (%#v, %#v), want nil", got.Info, got.Solution)
|
||||
}
|
||||
if got.Status != "未处理" {
|
||||
t.Fatalf("status = %q, want 未处理", got.Status)
|
||||
}
|
||||
if !got.CreatedAt.Equal(createdAt) {
|
||||
t.Fatalf("created_at = %v, want %v", got.CreatedAt, createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateErrorDistinguishesOmittedAndExplicitEmptyFields(t *testing.T) {
|
||||
recorder, query := newErrorRecordRepos(t)
|
||||
form, info, solution := "后端", "错误内容", "解决方案"
|
||||
if err := recorder.CreateError(context.Background(), &biz.ErrorRecord{
|
||||
Form: &form,
|
||||
Info: &info,
|
||||
Solution: &solution,
|
||||
Level: "error",
|
||||
RequestID: "request-old",
|
||||
TraceID: "trace-old",
|
||||
Status: "处理中",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
items, _, err := query.ListErrors(context.Background(), 1, 10, nil)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("list before update = (%d, %v)", len(items), err)
|
||||
}
|
||||
id := items[0].ID
|
||||
newForm := "服务端"
|
||||
if err = query.UpdateError(context.Background(), &biz.ErrorRecord{ID: id, Form: &newForm}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := query.FindError(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Info == nil || *got.Info != info || got.Solution == nil || *got.Solution != solution {
|
||||
t.Fatalf("omitted fields changed: info=%#v solution=%#v", got.Info, got.Solution)
|
||||
}
|
||||
|
||||
changedCreatedAt := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
if err = query.UpdateError(context.Background(), &biz.ErrorRecord{
|
||||
ID: id,
|
||||
CreatedAt: changedCreatedAt,
|
||||
Form: errorString(""),
|
||||
Info: errorString(""),
|
||||
Solution: errorString(""),
|
||||
RequestID: "request-new",
|
||||
TraceID: "trace-new",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = query.FindError(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Form == nil || *got.Form != "" || got.Info == nil || *got.Info != "" || got.Solution == nil || *got.Solution != "" {
|
||||
t.Fatalf("explicit empty fields were not persisted: form=%#v info=%#v solution=%#v", got.Form, got.Info, got.Solution)
|
||||
}
|
||||
if got.Level != "error" || got.Status != "处理中" {
|
||||
t.Fatalf("zero-value fields changed: level=%q status=%q", got.Level, got.Status)
|
||||
}
|
||||
if got.RequestID != "request-new" || got.TraceID != "trace-new" {
|
||||
t.Fatalf("trace fields = (%q, %q), want updated values", got.RequestID, got.TraceID)
|
||||
}
|
||||
if !got.CreatedAt.Equal(changedCreatedAt) {
|
||||
t.Fatalf("created_at = %v, want %v", got.CreatedAt, changedCreatedAt)
|
||||
}
|
||||
}
|
||||
+40
-14
@@ -62,9 +62,15 @@ type exportRepo struct{ data *Data }
|
||||
func NewExportRepo(data *Data) biz.ExportRepo { return &exportRepo{data: data} }
|
||||
func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []exportJoinPO) *biz.ExportTemplate {
|
||||
v := &biz.ExportTemplate{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DBName: po.DBName, Name: po.Name, TableName: po.DBTableName, TemplateID: po.TemplateID, TemplateInfo: po.TemplateInfo, SQL: po.SQL, ImportSQL: po.ImportSQL, Limit: po.Limit, Order: po.Order}
|
||||
if conditions != nil {
|
||||
v.Conditions = make([]biz.ExportCondition, 0, len(conditions))
|
||||
}
|
||||
for _, x := range conditions {
|
||||
v.Conditions = append(v.Conditions, biz.ExportCondition{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator})
|
||||
}
|
||||
if joins != nil {
|
||||
v.Joins = make([]biz.ExportJoin, 0, len(joins))
|
||||
}
|
||||
for _, x := range joins {
|
||||
v.Joins = append(v.Joins, biz.ExportJoin{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, Join: x.Join, Table: x.Table, On: x.On})
|
||||
}
|
||||
@@ -73,12 +79,14 @@ func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []e
|
||||
func exportToPO(v *biz.ExportTemplate) exportTemplatePO {
|
||||
return exportTemplatePO{ID: v.ID, DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order}
|
||||
}
|
||||
func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs bool) error {
|
||||
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportJoinPO{}).Error; err != nil {
|
||||
return err
|
||||
func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs, replace, forceTemplateID bool) error {
|
||||
if replace {
|
||||
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportJoinPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
conditions := make([]exportConditionPO, 0, len(v.Conditions))
|
||||
for _, x := range v.Conditions {
|
||||
@@ -86,7 +94,16 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
|
||||
if resetIDs {
|
||||
id = 0
|
||||
}
|
||||
conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator})
|
||||
templateID := x.TemplateID
|
||||
// GORM's has-many association on Create always writes the parent's
|
||||
// TemplateID into the child foreign key (references:TemplateID).
|
||||
// Preserve that observable behavior for creates; updates intentionally
|
||||
// keep the request's value after the compatible update deletes and
|
||||
// recreates relations.
|
||||
if forceTemplateID || templateID == "" {
|
||||
templateID = v.TemplateID
|
||||
}
|
||||
conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, From: x.From, Column: x.Column, Operator: x.Operator})
|
||||
}
|
||||
if len(conditions) > 0 {
|
||||
create := tx
|
||||
@@ -105,7 +122,11 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
|
||||
if resetIDs {
|
||||
id = 0
|
||||
}
|
||||
joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, Join: x.Join, Table: x.Table, On: x.On})
|
||||
templateID := x.TemplateID
|
||||
if forceTemplateID || templateID == "" {
|
||||
templateID = v.TemplateID
|
||||
}
|
||||
joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, Join: x.Join, Table: x.Table, On: x.On})
|
||||
}
|
||||
if len(joins) > 0 {
|
||||
create := tx
|
||||
@@ -123,7 +144,7 @@ func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *biz.ExportTemp
|
||||
return err
|
||||
}
|
||||
v.ID = po.ID
|
||||
return r.saveRelations(tx, v, false)
|
||||
return r.saveRelations(tx, v, false, false, true)
|
||||
})
|
||||
}
|
||||
func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemplate) error {
|
||||
@@ -132,7 +153,7 @@ func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemp
|
||||
if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return r.saveRelations(tx, v, true)
|
||||
return r.saveRelations(tx, v, true, true, false)
|
||||
})
|
||||
}
|
||||
func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error {
|
||||
@@ -142,10 +163,10 @@ func (r *exportRepo) FindExportTemplate(ctx context.Context, id uint, tid string
|
||||
var po exportTemplatePO
|
||||
db := r.data.gormDB.WithContext(ctx)
|
||||
var err error
|
||||
if id != 0 {
|
||||
err = db.First(&po, id).Error
|
||||
} else {
|
||||
if tid != "" {
|
||||
err = db.Where("template_id = ?", tid).First(&po).Error
|
||||
} else {
|
||||
err = db.Where("id = ?", id).First(&po).Error
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -181,7 +202,12 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q
|
||||
return nil, 0, err
|
||||
}
|
||||
var pos []exportTemplatePO
|
||||
if err := applyPagination(db, page, size, 0).Find(&pos).Error; err != nil {
|
||||
// This module applies pageSize and its raw offset formula verbatim instead
|
||||
// of using the shared PageInfo normalization.
|
||||
if size != 0 {
|
||||
db = db.Limit(size).Offset(size * (page - 1))
|
||||
}
|
||||
if err := db.Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]*biz.ExportTemplate, 0, len(pos))
|
||||
|
||||
@@ -25,7 +25,7 @@ func (loginLogPO) TableName() string { return "sys_login_logs" }
|
||||
func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *biz.LoginLog) error {
|
||||
if !r.data.databaseReady.Load() {
|
||||
// The login endpoint remains reachable before database initialization;
|
||||
// GVA skips this audit write while GVA_DB is nil.
|
||||
// skip the audit write until storage is ready.
|
||||
return nil
|
||||
}
|
||||
return r.data.gormDB.WithContext(ctx).Create(&loginLogPO{Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID}).Error
|
||||
@@ -48,7 +48,7 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *biz.
|
||||
return nil, 0, err
|
||||
}
|
||||
var pos []loginLogPO
|
||||
if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ids := make([]uint, 0, len(pos))
|
||||
|
||||
+14
-11
@@ -73,12 +73,6 @@ func (r *mediaRepo) FindMediaByHash(ctx context.Context, userID uint, hash strin
|
||||
return mediaFromPO(po), nil
|
||||
}
|
||||
func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*biz.MediaFile, int64, error) {
|
||||
if filter.Page < 1 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.PageSize < 1 {
|
||||
filter.PageSize = 10
|
||||
}
|
||||
db := r.data.gormDB.WithContext(ctx).Model(&mediaPO{})
|
||||
if filter.Keyword != "" {
|
||||
db = db.Where("name LIKE ?", "%"+filter.Keyword+"%")
|
||||
@@ -110,7 +104,19 @@ func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*b
|
||||
if filter.Desc {
|
||||
orderKey += " desc"
|
||||
}
|
||||
if err := applyPagination(db.Order(orderKey), filter.Page, filter.PageSize, 100).Find(&pos).Error; err != nil {
|
||||
limit := filter.PageSize
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
offset := 0
|
||||
if limit > 0 {
|
||||
page := filter.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
offset = limit * (page - 1)
|
||||
}
|
||||
if err := db.Limit(limit).Offset(offset).Order(orderKey).Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]*biz.MediaFile, 0, len(pos))
|
||||
@@ -141,9 +147,6 @@ func (r *mediaRepo) CreateMediaBatch(ctx context.Context, items []*biz.MediaFile
|
||||
for _, v := range items {
|
||||
pos = append(pos, mediaToPO(v))
|
||||
}
|
||||
if len(pos) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.data.gormDB.WithContext(ctx).Create(&pos).Error
|
||||
}
|
||||
func (r *mediaRepo) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) error {
|
||||
@@ -178,7 +181,7 @@ func (r *mediaRepo) DeleteCategory(ctx context.Context, id uint) error {
|
||||
}
|
||||
func (r *mediaRepo) ListCategories(ctx context.Context) ([]*biz.AttachmentCategory, error) {
|
||||
var pos []categoryPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Order("id").Find(&pos).Error; err != nil {
|
||||
if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byID := map[uint]*biz.AttachmentCategory{}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz"
|
||||
)
|
||||
|
||||
func TestMediaListKeepsZeroPageSizeContract(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
repo := &mediaRepo{data: data}
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateMedia(ctx, &biz.MediaFile{Name: "one.txt"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
items, total, err := repo.ListMedia(ctx, biz.MediaFilter{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 0 {
|
||||
t.Fatalf("zero-size media page = %d items with total %d, want 0/1", len(items), total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaBatchImportRejectsEmptySlice(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
err := (&mediaRepo{data: data}).CreateMediaBatch(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("empty media import unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
+56
-34
@@ -24,35 +24,35 @@ func menuFromPO(po menuPO) *biz.Menu {
|
||||
}
|
||||
|
||||
func menuToPO(v *biz.Menu) menuPO {
|
||||
return menuPO{ID: v.ID, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType}
|
||||
return menuPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, ParentID: v.ParentID, Path: v.Path, Name: v.Name, Hidden: v.Hidden, Component: v.Component, Sort: v.Sort, ActiveName: v.ActiveName, KeepAlive: v.KeepAlive, DefaultMenu: v.DefaultMenu, Title: v.Title, Icon: v.Icon, CloseTab: v.CloseTab, TransitionType: v.TransitionType}
|
||||
}
|
||||
|
||||
func replaceMenuRelations(tx *gorm.DB, menu *biz.Menu) error {
|
||||
// The compatible update rebuilds menu-button rows but does not remove the
|
||||
// existing authority-button rows that point at the old button IDs. Keep
|
||||
// that observable behavior here; menu deletion itself still cleans those
|
||||
// rows in Delete below, matching the administration endpoint behavior.
|
||||
if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuButtonPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuParameterPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
buttons := make([]menuButtonPO, 0, len(menu.Buttons))
|
||||
for _, button := range menu.Buttons {
|
||||
buttons = append(buttons, menuButtonPO{Name: button.Name, Description: button.Description, MenuID: menu.ID})
|
||||
if err := tx.Unscoped().Where("sys_base_menu_id = ?", menu.ID).Delete(&menuButtonPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(buttons) > 0 {
|
||||
if err := tx.Create(&buttons).Error; err != nil {
|
||||
return createMenuRelations(tx, menu)
|
||||
}
|
||||
|
||||
func createMenuRelations(tx *gorm.DB, menu *biz.Menu) error {
|
||||
parameters := make([]menuParameterPO, 0, len(menu.Parameters))
|
||||
for _, parameter := range menu.Parameters {
|
||||
parameters = append(parameters, menuParameterPO{ID: parameter.ID, CreatedAt: parameter.CreatedAt, UpdatedAt: parameter.UpdatedAt, MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
|
||||
}
|
||||
if len(parameters) > 0 {
|
||||
if err := tx.Create(¶meters).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
parameters := make([]menuParameterPO, 0, len(menu.Parameters))
|
||||
for _, parameter := range menu.Parameters {
|
||||
parameters = append(parameters, menuParameterPO{MenuID: menu.ID, Type: parameter.Type, Key: parameter.Key, Value: parameter.Value})
|
||||
buttons := make([]menuButtonPO, 0, len(menu.Buttons))
|
||||
for _, button := range menu.Buttons {
|
||||
buttons = append(buttons, menuButtonPO{ID: button.ID, CreatedAt: button.CreatedAt, UpdatedAt: button.UpdatedAt, Name: button.Name, Description: button.Description, MenuID: menu.ID})
|
||||
}
|
||||
if len(parameters) > 0 {
|
||||
return tx.Create(¶meters).Error
|
||||
if len(buttons) > 0 {
|
||||
return tx.Create(&buttons).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (r *menuRepo) Create(ctx context.Context, v *biz.Menu) error {
|
||||
return err
|
||||
}
|
||||
v.ID = po.ID
|
||||
return replaceMenuRelations(tx, v)
|
||||
return createMenuRelations(tx, v)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -140,29 +140,25 @@ func (r *menuRepo) Delete(ctx context.Context, id uint) error {
|
||||
if count > 0 {
|
||||
return errors.New("此菜单有角色正在作为首页,不可删除")
|
||||
}
|
||||
var buttonIDs []uint
|
||||
if err := tx.Model(&menuButtonPO{}).Where("sys_base_menu_id = ?", id).Pluck("id", &buttonIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_menu_id = ?", id).Delete(&authorityButtonPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuButtonPO{}).Error; err != nil {
|
||||
if err := tx.Delete(&menuPO{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuParameterPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_base_menu_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil {
|
||||
if err := tx.Where("sys_base_menu_id = ?", id).Delete(&menuButtonPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&menuPO{}, id).Error
|
||||
if err := tx.Where("sys_menu_id = ?", id).Delete(&authorityButtonPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("sys_base_menu_id = ?", id).Delete(&authorityMenuPO{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error {
|
||||
var parameters []menuParameterPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Order("id").Find(¶meters).Error; err != nil {
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(¶meters).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
menu.Parameters = make([]*biz.MenuParameter, 0, len(parameters))
|
||||
@@ -170,7 +166,7 @@ func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error {
|
||||
menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter))
|
||||
}
|
||||
var buttons []menuButtonPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Order("id").Find(&buttons).Error; err != nil {
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(&buttons).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
menu.Buttons = make([]*biz.MenuButton, 0, len(buttons))
|
||||
@@ -194,7 +190,7 @@ func (r *menuRepo) Find(ctx context.Context, id uint) (*biz.Menu, error) {
|
||||
|
||||
func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) {
|
||||
var pos []menuPO
|
||||
db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).Order("sort,id")
|
||||
db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).Order("sort")
|
||||
config := r.data.runtime.Admin()
|
||||
if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth {
|
||||
var authority authorityPO
|
||||
@@ -224,7 +220,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) {
|
||||
menu.Buttons = []*biz.MenuButton{}
|
||||
}
|
||||
var parameters []menuParameterPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Order("id").Find(¶meters).Error; err != nil {
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(¶meters).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, parameter := range parameters {
|
||||
@@ -232,7 +228,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) {
|
||||
menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter))
|
||||
}
|
||||
var buttons []menuButtonPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Order("id").Find(&buttons).Error; err != nil {
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(&buttons).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, button := range buttons {
|
||||
@@ -264,6 +260,9 @@ func (r *menuRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) e
|
||||
if err := (&authorityAccessRepo{data: r.data}).checkAuthorityIDAuth(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", id).First(&authorityPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
config := r.data.runtime.Admin()
|
||||
if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth {
|
||||
var authority authorityPO
|
||||
@@ -307,6 +306,29 @@ func (r *menuRepo) AuthorityMenuIDs(ctx context.Context, id uint) ([]uint, error
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ListAuthorityMenus reads the authority-menu query. It reads the
|
||||
// target authority's relation rows first and then fetches only those base
|
||||
// menus. In particular, it deliberately does not call List: List applies the
|
||||
// current request actor's strict-auth filter, which would incorrectly hide
|
||||
// menus when an administrator inspects a different (child) authority.
|
||||
func (r *menuRepo) ListAuthorityMenus(ctx context.Context, authorityID uint) ([]*biz.Menu, error) {
|
||||
var pos []menuPO
|
||||
db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).
|
||||
Where("id IN (?)", r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).
|
||||
Select("sys_base_menu_id").Where("sys_authority_authority_id = ?", authorityID)).
|
||||
Order("sort")
|
||||
if err := db.Find(&pos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*biz.Menu, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
// This endpoint does not preload Parameters or MenuBtn. Keep
|
||||
// both relations nil so the response shape remains compatible.
|
||||
out = append(out, menuFromPO(po))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *menuRepo) MenuRoleIDs(ctx context.Context, id uint) ([]uint, error) {
|
||||
var ids []uint
|
||||
err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_base_menu_id = ?", id).Pluck("sys_authority_authority_id", &ids).Error
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
)
|
||||
|
||||
func newMenuTestData(t *testing.T) *Data {
|
||||
t.Helper()
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&menuPO{}, &menuParameterPO{}, &menuButtonPO{}, &authorityButtonPO{}, &authorityPO{}, &authorityMenuPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &Data{
|
||||
gormDB: newReloadableDB(db, nil),
|
||||
runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{}),
|
||||
}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
return data
|
||||
}
|
||||
|
||||
func TestMenuCreatePreservesSubmittedTimestamps(t *testing.T) {
|
||||
data := newMenuTestData(t)
|
||||
ctx := context.Background()
|
||||
createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
updatedAt := time.Date(2025, time.February, 3, 4, 5, 6, 0, time.UTC)
|
||||
menu := &biz.Menu{CreatedAt: createdAt, UpdatedAt: updatedAt, Name: "created", Path: "created", Component: "view/created.vue", Title: "Created"}
|
||||
|
||||
if err := (&menuRepo{data: data}).Create(ctx, menu); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored menuPO
|
||||
if err := data.gormDB.WithContext(ctx).First(&stored, menu.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stored.CreatedAt.Equal(createdAt) || !stored.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("stored timestamps = (%v, %v), want (%v, %v)", stored.CreatedAt, stored.UpdatedAt, createdAt, updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAuthorityMenusRejectsMissingAuthority(t *testing.T) {
|
||||
data := newMenuTestData(t)
|
||||
err := (&menuRepo{data: data}).SetAuthorityMenus(context.Background(), 999, nil)
|
||||
if err == nil {
|
||||
t.Fatal("SetAuthorityMenus missing authority succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMenuRolesPreservesDuplicateAuthorityIDs(t *testing.T) {
|
||||
data := newMenuTestData(t)
|
||||
ctx := context.Background()
|
||||
if err := (&menuRepo{data: data}).SetMenuRoles(ctx, 10, []uint{888, 888}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).
|
||||
Where("sys_base_menu_id = ? AND sys_authority_authority_id = ?", 10, 888).
|
||||
Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("authority-menu rows = %d, want duplicate role IDs preserved", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuUpdateRebuildsRelationsWithoutChangingRequestedIdentity(t *testing.T) {
|
||||
data := newMenuTestData(t)
|
||||
ctx := context.Background()
|
||||
createdAt := time.Date(2025, time.March, 4, 5, 6, 7, 0, time.UTC)
|
||||
updatedAt := time.Date(2025, time.April, 5, 6, 7, 8, 0, time.UTC)
|
||||
|
||||
if err := data.gormDB.WithContext(ctx).Create(&menuPO{ID: 10, Name: "before", Path: "before", Component: "view/before.vue", Title: "Before"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&menuParameterPO{ID: 21, CreatedAt: createdAt, UpdatedAt: updatedAt, MenuID: 10, Type: "query", Key: "old", Value: "old"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&menuButtonPO{ID: 31, CreatedAt: createdAt, UpdatedAt: updatedAt, MenuID: 10, Name: "edit", Description: "old"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&authorityButtonPO{AuthorityID: 888, MenuID: 10, ButtonID: 31}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo := &menuRepo{data: data}
|
||||
err := repo.Update(ctx, &biz.Menu{
|
||||
ID: 10,
|
||||
Name: "after",
|
||||
Path: "after",
|
||||
Component: "view/after.vue",
|
||||
Title: "After",
|
||||
Parameters: []*biz.MenuParameter{{
|
||||
ID: 21, CreatedAt: createdAt, UpdatedAt: updatedAt, Type: "params", Key: "new", Value: "value",
|
||||
}},
|
||||
Buttons: []*biz.MenuButton{{
|
||||
ID: 31, CreatedAt: createdAt, UpdatedAt: updatedAt, Name: "edit", Description: "new",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var parameter menuParameterPO
|
||||
if err = data.gormDB.WithContext(ctx).First(¶meter, 21).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parameter.MenuID != 10 || parameter.Type != "params" || parameter.Key != "new" || parameter.Value != "value" {
|
||||
t.Fatalf("parameter = %+v", parameter)
|
||||
}
|
||||
if !parameter.CreatedAt.Equal(createdAt) || !parameter.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("parameter timestamps = (%v, %v), want (%v, %v)", parameter.CreatedAt, parameter.UpdatedAt, createdAt, updatedAt)
|
||||
}
|
||||
|
||||
var button menuButtonPO
|
||||
if err = data.gormDB.WithContext(ctx).First(&button, 31).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if button.MenuID != 10 || button.Name != "edit" || button.Description != "new" {
|
||||
t.Fatalf("button = %+v", button)
|
||||
}
|
||||
if !button.CreatedAt.Equal(createdAt) || !button.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("button timestamps = (%v, %v), want (%v, %v)", button.CreatedAt, button.UpdatedAt, createdAt, updatedAt)
|
||||
}
|
||||
|
||||
var links int64
|
||||
if err = data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).
|
||||
Where("authority_id = ? AND sys_menu_id = ? AND sys_base_menu_btn_id = ?", 888, 10, 31).
|
||||
Count(&links).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if links != 1 {
|
||||
t.Fatalf("authority-button links = %d, want 1", links)
|
||||
}
|
||||
}
|
||||
+168
-5
@@ -2,6 +2,7 @@ package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -11,6 +12,9 @@ func migrateAll(db *gorm.DB) error {
|
||||
if err := migrateLegacyIgnoreAPITable(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
|
||||
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
|
||||
@@ -35,6 +39,134 @@ func migrateAll(db *gorm.DB) error {
|
||||
return reconcileReferenceIndexes(db)
|
||||
}
|
||||
|
||||
// migrateLegacyAuthorityDepartmentColumns preserves data created by early
|
||||
// Kra builds, which used shortened join-column names and a composite primary
|
||||
// key. The administration connection model has neither a primary key nor a
|
||||
// uniqueness constraint, so rebuild the small table before AutoMigrate.
|
||||
func migrateLegacyAuthorityDepartmentColumns(db *gorm.DB) error {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
const (
|
||||
table = "sys_authority_departments"
|
||||
backup = "sys_authority_departments_kra_legacy"
|
||||
)
|
||||
// MySQL and Oracle auto-commit DDL. If a prior process stopped between the
|
||||
// rename and cleanup steps, restore the untouched backup first and retry the
|
||||
// migration from a known state.
|
||||
if clean.Migrator().HasTable(backup) {
|
||||
if clean.Migrator().HasTable(table) {
|
||||
if err := clean.Migrator().DropTable(table); err != nil {
|
||||
return fmt.Errorf("remove incomplete authority-department table: %w", err)
|
||||
}
|
||||
}
|
||||
if err := clean.Migrator().RenameTable(backup, table); err != nil {
|
||||
return fmt.Errorf("restore authority-department backup: %w", err)
|
||||
}
|
||||
}
|
||||
if !clean.Migrator().HasTable(table) {
|
||||
return nil
|
||||
}
|
||||
|
||||
authorityColumn := "sys_authority_authority_id"
|
||||
if !tableHasColumn(clean, table, authorityColumn) {
|
||||
if !tableHasColumn(clean, table, "authority_id") {
|
||||
return fmt.Errorf("authority-department table has no authority column")
|
||||
}
|
||||
authorityColumn = "authority_id"
|
||||
}
|
||||
departmentColumn := "sys_department_id"
|
||||
if !tableHasColumn(clean, table, departmentColumn) {
|
||||
if !tableHasColumn(clean, table, "department_id") {
|
||||
return fmt.Errorf("authority-department table has no department column")
|
||||
}
|
||||
departmentColumn = "department_id"
|
||||
}
|
||||
hasPrimaryKey, err := tableHasPrimaryKey(clean, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if authorityColumn == "sys_authority_authority_id" && departmentColumn == "sys_department_id" && !hasPrimaryKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
type relation struct {
|
||||
AuthorityID uint `gorm:"column:authority_id"`
|
||||
DepartmentID uint `gorm:"column:department_id"`
|
||||
}
|
||||
var rows []relation
|
||||
selectColumns := authorityColumn + " AS authority_id, " + departmentColumn + " AS department_id"
|
||||
if err := clean.Table(table).Select(selectColumns).Scan(&rows).Error; err != nil {
|
||||
return fmt.Errorf("read legacy authority-department rows: %w", err)
|
||||
}
|
||||
|
||||
rebuild := func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().RenameTable(table, backup); err != nil {
|
||||
return fmt.Errorf("rename legacy authority-department table: %w", err)
|
||||
}
|
||||
if err := tx.AutoMigrate(&authorityDepartmentPO{}); err != nil {
|
||||
return fmt.Errorf("create authority-department table: %w", err)
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
items := make([]authorityDepartmentPO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, authorityDepartmentPO{AuthorityID: row.AuthorityID, DepartmentID: row.DepartmentID})
|
||||
}
|
||||
if err := tx.Create(&items).Error; err != nil {
|
||||
return fmt.Errorf("copy authority-department rows: %w", err)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&authorityDepartmentPO{}).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("verify authority-department rows: %w", err)
|
||||
}
|
||||
if count != int64(len(rows)) {
|
||||
return fmt.Errorf("verify authority-department rows: got %d want %d", count, len(rows))
|
||||
}
|
||||
if err := tx.Migrator().DropTable(backup); err != nil {
|
||||
return fmt.Errorf("drop legacy authority-department table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch clean.Dialector.Name() {
|
||||
case "mysql", "oracle":
|
||||
if err := rebuild(clean); err != nil {
|
||||
restoreErr := restoreAuthorityDepartmentBackup(clean, table, backup)
|
||||
if restoreErr != nil {
|
||||
return fmt.Errorf("%v; restore authority-department backup: %w", err, restoreErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return clean.Transaction(rebuild)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreAuthorityDepartmentBackup(db *gorm.DB, table, backup string) error {
|
||||
if db.Migrator().HasTable(table) {
|
||||
if err := db.Migrator().DropTable(table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable(backup) {
|
||||
return db.Migrator().RenameTable(backup, table)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableHasPrimaryKey(db *gorm.DB, table string) (bool, error) {
|
||||
columns, err := db.Migrator().ColumnTypes(table)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, column := range columns {
|
||||
if primary, ok := column.PrimaryKey(); ok && primary {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Older builds used a status label outside the administration page's supported
|
||||
// state set, so normalize existing rows during migration.
|
||||
func normalizeErrorRecordStatuses(db *gorm.DB) error {
|
||||
@@ -55,18 +187,32 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
|
||||
Path string
|
||||
Method string
|
||||
}
|
||||
query := clean.Table("sys_authority_apis sa").
|
||||
Select("sa.authority_id, a.path, a.method").
|
||||
Joins("JOIN sys_apis a ON a.id = sa.api_id")
|
||||
baseQuery := func() *gorm.DB {
|
||||
return clean.Table("sys_authority_apis sa").
|
||||
Select("sa.authority_id, a.path, a.method").
|
||||
Joins("JOIN sys_apis a ON a.id = sa.api_id")
|
||||
}
|
||||
query := baseQuery()
|
||||
// Early Kra schemas stored sys_apis without soft-delete timestamps. The
|
||||
// legacy-policy migration must run before assuming that column exists;
|
||||
// otherwise an upgrade from those schemas cannot start on MySQL.
|
||||
if clean.Migrator().HasColumn(&apiPO{}, "deleted_at") {
|
||||
if tableHasColumn(clean, "sys_apis", "deleted_at") {
|
||||
query = query.Where("a.deleted_at IS NULL")
|
||||
}
|
||||
var rows []legacyPolicy
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return err
|
||||
// A few MySQL-compatible drivers report stale/incomplete metadata from
|
||||
// INFORMATION_SCHEMA during startup. If the optional soft-delete column
|
||||
// was reported present but the join still rejects it, retry using only
|
||||
// columns shared by every legacy schema. This migration must never block
|
||||
// startup of an older database solely because deleted_at is absent.
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unknown column") && strings.Contains(strings.ToLower(err.Error()), "deleted_at") {
|
||||
if retryErr := baseQuery().Find(&rows).Error; retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method)
|
||||
@@ -83,6 +229,23 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableHasColumn deliberately inspects the physical table rather than the
|
||||
// model schema. Legacy databases may predate soft-delete columns even though
|
||||
// the current PO includes gorm.DeletedAt. Metadata inspection failures are
|
||||
// treated as "unknown" so callers use the portable query shape.
|
||||
func tableHasColumn(db *gorm.DB, table, column string) bool {
|
||||
columns, err := db.Migrator().ColumnTypes(table)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range columns {
|
||||
if strings.EqualFold(item.Name(), column) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key
|
||||
// shape (path, method) to the compatible model shape (ID/timestamps/soft
|
||||
// delete). AutoMigrate can add columns but cannot replace an existing
|
||||
|
||||
@@ -98,3 +98,60 @@ func TestReconcileRootAuthorityAPIsUsesIndependentQueries(t *testing.T) {
|
||||
t.Fatalf("root rules = %#v, want only GET /allowed", rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLegacyAuthorityDepartmentColumns(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE sys_authority_departments (authority_id integer, department_id integer, PRIMARY KEY (authority_id, department_id))`,
|
||||
`INSERT INTO sys_authority_departments (authority_id, department_id) VALUES (888, 7)`,
|
||||
} {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = migrateLegacyAuthorityDepartmentColumns(db); err != nil {
|
||||
t.Fatalf("migrate legacy authority-department columns: %v", err)
|
||||
}
|
||||
if err = db.AutoMigrate(&authorityDepartmentPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var relation authorityDepartmentPO
|
||||
if err = db.First(&relation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if relation.AuthorityID != 888 || relation.DepartmentID != 7 {
|
||||
t.Fatalf("migrated relation = %#v", relation)
|
||||
}
|
||||
for _, column := range []string{"sys_authority_authority_id", "sys_department_id"} {
|
||||
if !tableHasColumn(db, "sys_authority_departments", column) {
|
||||
t.Fatalf("missing compatible column %s", column)
|
||||
}
|
||||
}
|
||||
hasPrimaryKey, err := tableHasPrimaryKey(db, "sys_authority_departments")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasPrimaryKey {
|
||||
t.Fatal("authority-department table retained the legacy primary key")
|
||||
}
|
||||
if err = db.Create(&authorityDepartmentPO{AuthorityID: 888, DepartmentID: 7}).Error; err != nil {
|
||||
t.Fatalf("duplicate administration relation was rejected: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ? AND sys_department_id = ?", 888, 7).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("duplicate relation count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q *
|
||||
return nil, 0, err
|
||||
}
|
||||
var pos []operationPO
|
||||
if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
users := auditUsers(ctx, r.data.gormDB.WithContext(ctx), operationUserIDs(pos))
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDepartmentTreeAndSearchPreserveCollectionShapes(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
root := departmentPO{Name: "root", ParentID: 0, Ancestors: "0"}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&root).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := departmentPO{Name: "child", ParentID: root.ID, Ancestors: "0"}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&child).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &departmentRepo{data: data}
|
||||
|
||||
tree, err := repo.ListDepartments(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tree) != 1 || len(tree[0].Children) != 1 || tree[0].Children[0].Children == nil {
|
||||
t.Fatalf("tree collection shape = %+v", tree)
|
||||
}
|
||||
|
||||
search, err := repo.ListDepartments(ctx, "child")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(search) != 1 || search[0].Children != nil {
|
||||
t.Fatalf("search collection shape = %+v", search)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositionListKeepsZeroLimitBehavior(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
if err := data.gormDB.WithContext(ctx).Create(&positionPO{Name: "operator"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, total, err := (&positionRepo{data: data}).ListPositions(ctx, 0, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 0 {
|
||||
t.Fatalf("zero-limit result: total=%d items=%+v", total, items)
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,29 @@ package data
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
func applyPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB {
|
||||
if size <= 0 {
|
||||
return db
|
||||
}
|
||||
func paginationLimitOffset(page, size, maxSize int) (limit, offset int) {
|
||||
limit = size
|
||||
if maxSize > 0 && size > maxSize {
|
||||
size = maxSize
|
||||
limit = maxSize
|
||||
}
|
||||
if limit <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
return db.Offset((page - 1) * size).Limit(size)
|
||||
return limit, (page - 1) * limit
|
||||
}
|
||||
|
||||
func applyPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB {
|
||||
limit, offset := paginationLimitOffset(page, size, maxSize)
|
||||
if limit == 0 {
|
||||
return db
|
||||
}
|
||||
return db.Offset(offset).Limit(limit)
|
||||
}
|
||||
|
||||
func applyRequiredPagination(db *gorm.DB, page, size, maxSize int) *gorm.DB {
|
||||
limit, offset := paginationLimitOffset(page, size, maxSize)
|
||||
return db.Offset(offset).Limit(limit)
|
||||
}
|
||||
|
||||
+18
-15
@@ -28,31 +28,34 @@ type parameterPO struct {
|
||||
func (parameterPO) TableName() string { return "sys_params" }
|
||||
|
||||
func (r *parameterRepo) CreateParameter(ctx context.Context, v *biz.SystemParameter) error {
|
||||
po := parameterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}
|
||||
po := parameterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}
|
||||
if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
v.ID = po.ID
|
||||
v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
|
||||
return nil
|
||||
}
|
||||
func (r *parameterRepo) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error {
|
||||
// The compatible update uses a struct, so zero-value optional fields (notably desc)
|
||||
// are intentionally ignored rather than clearing an existing value.
|
||||
return r.data.gormDB.WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(¶meterPO{Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}).Error
|
||||
return r.data.gormDB.WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(¶meterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}).Error
|
||||
}
|
||||
func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []uint) error {
|
||||
return r.data.gormDB.WithContext(ctx).Delete(¶meterPO{}, ids).Error
|
||||
}
|
||||
func (r *parameterRepo) FindParameter(ctx context.Context, id uint, key string) (*biz.SystemParameter, error) {
|
||||
var po parameterPO
|
||||
db := r.data.gormDB.WithContext(ctx)
|
||||
var err error
|
||||
if id != 0 {
|
||||
err = db.First(&po, id).Error
|
||||
} else {
|
||||
err = db.Where(map[string]any{"key": key}).First(&po).Error
|
||||
func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 1 {
|
||||
return r.data.gormDB.WithContext(ctx).Delete(¶meterPO{}, "id = ?", ids[0]).Error
|
||||
}
|
||||
if err != nil {
|
||||
return r.data.gormDB.WithContext(ctx).Delete(&[]parameterPO{}, "id in ?", ids).Error
|
||||
}
|
||||
func (r *parameterRepo) FindParameterByID(ctx context.Context, id string) (*biz.SystemParameter, error) {
|
||||
var po parameterPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parameterFromPO(po), nil
|
||||
}
|
||||
func (r *parameterRepo) FindParameterByKey(ctx context.Context, key string) (*biz.SystemParameter, error) {
|
||||
var po parameterPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where(parameterPO{Key: key}).First(&po).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parameterFromPO(po), nil
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
)
|
||||
|
||||
func TestParameterRepositoryPreservesModelMetadata(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
repo := ¶meterRepo{data: data}
|
||||
createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.Local)
|
||||
value := &biz.SystemParameter{ID: 41, CreatedAt: createdAt, Name: "name", Key: "key", Value: "value"}
|
||||
|
||||
if err := repo.CreateParameter(context.Background(), value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.ID != 41 || !value.CreatedAt.Equal(createdAt) {
|
||||
t.Fatalf("created parameter metadata = ID %d, CreatedAt %v", value.ID, value.CreatedAt)
|
||||
}
|
||||
|
||||
nextCreatedAt := createdAt.Add(time.Hour)
|
||||
value.CreatedAt = nextCreatedAt
|
||||
value.Name = "updated"
|
||||
if err := repo.UpdateParameter(context.Background(), value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var persisted parameterPO
|
||||
if err := data.gormDB.WithContext(context.Background()).First(&persisted, 41).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted.Name != "updated" || !persisted.CreatedAt.Equal(nextCreatedAt) {
|
||||
t.Fatalf("updated parameter = %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParameterRepositoryKeepsReferenceEmptyQuerySemantics(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
repo := ¶meterRepo{data: data}
|
||||
ctx := context.Background()
|
||||
first := &biz.SystemParameter{Name: "first", Key: "first", Value: "1"}
|
||||
if err := repo.CreateParameter(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
byEmptyKey, err := repo.FindParameterByKey(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if byEmptyKey.ID != first.ID {
|
||||
t.Fatalf("empty key returned ID %d, want first row %d", byEmptyKey.ID, first.ID)
|
||||
}
|
||||
if _, err = repo.FindParameterByID(ctx, ""); err == nil {
|
||||
t.Fatal("empty ID unexpectedly matched a parameter")
|
||||
}
|
||||
if err = repo.DeleteParameters(ctx, nil); err != nil {
|
||||
t.Fatalf("empty bulk delete must be a successful no-op: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err = data.gormDB.WithContext(ctx).Model(¶meterPO{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("empty bulk delete removed rows: count=%d", count)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
@@ -26,9 +27,9 @@ type menuButtonPO struct {
|
||||
func (menuButtonPO) TableName() string { return "sys_base_menu_btns" }
|
||||
|
||||
type authorityButtonPO struct {
|
||||
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
|
||||
MenuID uint `gorm:"primaryKey;column:sys_menu_id"`
|
||||
ButtonID uint `gorm:"primaryKey;column:sys_base_menu_btn_id"`
|
||||
AuthorityID uint `gorm:"column:authority_id"`
|
||||
MenuID uint `gorm:"column:sys_menu_id"`
|
||||
ButtonID uint `gorm:"column:sys_base_menu_btn_id"`
|
||||
}
|
||||
|
||||
func (authorityButtonPO) TableName() string { return "sys_authority_btns" }
|
||||
@@ -62,9 +63,15 @@ func (r *permissionRepo) SetAuthorityButtons(ctx context.Context, aid uint, butt
|
||||
})
|
||||
}
|
||||
func (r *permissionRepo) SelectedButtons(ctx context.Context, aid, menuID uint) ([]uint, error) {
|
||||
var rows []authorityButtonPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []uint
|
||||
err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Pluck("sys_base_menu_btn_id", &ids).Error
|
||||
return ids, err
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.ButtonID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
func (r *permissionRepo) SetSelectedButtons(ctx context.Context, aid, menuID uint, ids []uint) error {
|
||||
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@@ -81,10 +88,16 @@ func (r *permissionRepo) SetSelectedButtons(ctx context.Context, aid, menuID uin
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func (r *permissionRepo) CanRemoveButton(ctx context.Context, id uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("sys_base_menu_btn_id = ?", id).Count(&count).Error
|
||||
return count == 0, err
|
||||
func (r *permissionRepo) CanRemoveButton(ctx context.Context, id string) (bool, error) {
|
||||
var row authorityButtonPO
|
||||
err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_btn_id = ?", id).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, errors.New("此按钮正在被使用无法删除")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
func (r *permissionRepo) AuthorityButtonIDs(ctx context.Context, aid uint) ([]uint, error) {
|
||||
var ids []uint
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
)
|
||||
|
||||
func TestPermissionSelectedButtonsEmptyShape(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&authorityButtonPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
|
||||
ids, err := (&permissionRepo{data: data}).SelectedButtons(context.Background(), 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ids != nil {
|
||||
t.Fatalf("selected buttons = %#v, want nil for an empty compatible response", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionCanRemoveButtonUsesRawQueryID(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&authorityButtonPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
repo := &permissionRepo{data: data}
|
||||
if err := data.gormDB.WithContext(context.Background()).Create(&authorityButtonPO{AuthorityID: 888, MenuID: 10, ButtonID: 31}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
allowed, err := repo.CanRemoveButton(context.Background(), "31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatal("assigned button was reported removable")
|
||||
}
|
||||
allowed, err = repo.CanRemoveButton(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatal("empty query ID should not match an assigned button")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionSetSelectedButtonsPreservesDuplicates(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&authorityButtonPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := &Data{gormDB: newReloadableDB(db, nil), runtime: conf.NewRuntime(&conf.Data{Database: &conf.Data_Database{Driver: "sqlite"}}, &conf.AdminBackend{})}
|
||||
t.Cleanup(func() { data.gormDB.close() })
|
||||
repo := &permissionRepo{data: data}
|
||||
|
||||
if err := repo.SetSelectedButtons(context.Background(), 888, 10, []uint{31, 31}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected, err := repo.SelectedButtons(context.Background(), 888, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(selected) != 2 || selected[0] != 31 || selected[1] != 31 {
|
||||
t.Fatalf("selected = %#v, want duplicate button IDs preserved", selected)
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,10 @@ type positionPO struct {
|
||||
func (positionPO) TableName() string { return "sys_positions" }
|
||||
|
||||
type userPositionPO struct {
|
||||
UserID uint `gorm:"primaryKey;column:sys_user_id"`
|
||||
PositionID uint `gorm:"primaryKey;column:sys_position_id"`
|
||||
// GVA does not declare a key on this explicit join model. Do not let a
|
||||
// database uniqueness constraint change duplicate-assignment behavior.
|
||||
UserID uint `gorm:"column:sys_user_id"`
|
||||
PositionID uint `gorm:"column:sys_position_id"`
|
||||
}
|
||||
|
||||
func (userPositionPO) TableName() string { return "sys_user_positions" }
|
||||
@@ -82,7 +84,10 @@ func (r *positionRepo) ListPositions(ctx context.Context, page, size int, q *biz
|
||||
return nil, 0, err
|
||||
}
|
||||
var pos []positionPO
|
||||
if err := applyPagination(db.Order("sort"), page, size, 0).Find(&pos).Error; err != nil {
|
||||
// The compatible position endpoint applies the supplied LIMIT/OFFSET
|
||||
// directly (it does not normalize missing page values through the shared
|
||||
// pagination helper).
|
||||
if err := db.Order("sort").Limit(size).Offset(size * (page - 1)).Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]*biz.Position, 0, len(pos))
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"kra/internal/biz"
|
||||
"kra/internal/conf"
|
||||
"kra/pkg/adminauth"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type runtimeSettings struct{ runtime *conf.Runtime }
|
||||
@@ -94,6 +96,34 @@ func (i *tokenIssuer) IssueToken(user *biz.User, authorityID uint, mustChangePas
|
||||
return &biz.IssuedToken{Value: token, ExpiresAt: claims.ExpiresAt.Time, TTL: expires}, nil
|
||||
}
|
||||
|
||||
func (i *tokenIssuer) ReissueToken(source *biz.AuthClaims, authorityID uint) (*biz.IssuedToken, error) {
|
||||
if source == nil {
|
||||
return nil, errors.New("nil JWT claims")
|
||||
}
|
||||
settings := i.settings.JWTSettings()
|
||||
claims := &adminauth.Claims{
|
||||
UUID: source.UUID,
|
||||
ID: source.ID,
|
||||
Username: source.Username,
|
||||
NickName: source.NickName,
|
||||
AuthorityID: authorityID,
|
||||
BufferTime: int64(source.BufferTime / time.Second),
|
||||
UserType: source.UserType,
|
||||
MustChangePwd: source.MustChangePwd,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings(append([]string(nil), source.Audience...)),
|
||||
Issuer: source.Issuer,
|
||||
NotBefore: jwt.NewNumericDate(source.NotBefore),
|
||||
ExpiresAt: jwt.NewNumericDate(source.ExpiresAt),
|
||||
},
|
||||
}
|
||||
token, err := adminauth.Sign(settings.SigningKey, claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &biz.IssuedToken{Value: token, ExpiresAt: source.ExpiresAt, TTL: time.Until(source.ExpiresAt)}, nil
|
||||
}
|
||||
|
||||
func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) {
|
||||
claims, err := adminauth.Parse(token, i.settings.JWTSettings().SigningKey)
|
||||
if err != nil {
|
||||
@@ -110,5 +140,7 @@ func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) {
|
||||
return nil, biz.ErrTokenInvalid
|
||||
}
|
||||
}
|
||||
return &biz.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, UserType: claims.UserType, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, Issuer: claims.Issuer, ExpiresAt: claims.ExpiresAt.Time}, nil
|
||||
audience := make([]string, len(claims.Audience))
|
||||
copy(audience, claims.Audience)
|
||||
return &biz.AuthClaims{UUID: claims.UUID, ID: claims.ID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, UserType: claims.UserType, BufferTime: time.Duration(claims.BufferTime) * time.Second, MustChangePwd: claims.MustChangePwd, Issuer: claims.Issuer, Audience: audience, NotBefore: claims.NotBefore.Time, ExpiresAt: claims.ExpiresAt.Time}, nil
|
||||
}
|
||||
|
||||
+20
-19
@@ -19,25 +19,25 @@ type securityConfigPO struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
CaptchaOpen int
|
||||
CaptchaTimeout int
|
||||
KeyLong int
|
||||
ImgWidth int
|
||||
ImgHeight int
|
||||
PwdMinLength int
|
||||
PwdRequireUpper bool
|
||||
PwdRequireLower bool
|
||||
PwdRequireDigit bool
|
||||
PwdRequireSpecial bool
|
||||
LimitEnable bool
|
||||
LimitWindow int
|
||||
LimitCount int
|
||||
LockEnable bool
|
||||
LockThreshold int
|
||||
LockDuration int
|
||||
PwdExpireEnable bool
|
||||
PwdExpireDays int
|
||||
ForceNewUserChangePassword bool
|
||||
CaptchaOpen int `gorm:"default:0"`
|
||||
CaptchaTimeout int `gorm:"default:3600"`
|
||||
KeyLong int `gorm:"default:6"`
|
||||
ImgWidth int `gorm:"default:240"`
|
||||
ImgHeight int `gorm:"default:80"`
|
||||
PwdMinLength int `gorm:"default:8"`
|
||||
PwdRequireUpper bool `gorm:"default:false"`
|
||||
PwdRequireLower bool `gorm:"default:false"`
|
||||
PwdRequireDigit bool `gorm:"default:false"`
|
||||
PwdRequireSpecial bool `gorm:"default:false"`
|
||||
LimitEnable bool `gorm:"default:false"`
|
||||
LimitWindow int `gorm:"default:60"`
|
||||
LimitCount int `gorm:"default:30"`
|
||||
LockEnable bool `gorm:"default:false"`
|
||||
LockThreshold int `gorm:"default:5"`
|
||||
LockDuration int `gorm:"default:30"`
|
||||
PwdExpireEnable bool `gorm:"default:false"`
|
||||
PwdExpireDays int `gorm:"default:90"`
|
||||
ForceNewUserChangePassword bool `gorm:"default:false"`
|
||||
}
|
||||
|
||||
func (securityConfigPO) TableName() string { return "sys_security_config" }
|
||||
@@ -54,6 +54,7 @@ func securityToPO(v *biz.SecurityConfig) securityConfigPO {
|
||||
func (r *securityRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig, error) {
|
||||
if !r.data.databaseReady.Load() {
|
||||
po := defaultSecurityConfig()
|
||||
po.ID = 0
|
||||
return securityFromPO(po), errors.New("数据库未初始化")
|
||||
}
|
||||
var po securityConfigPO
|
||||
|
||||
@@ -67,15 +67,18 @@ type menuPO struct {
|
||||
func (menuPO) TableName() string { return "sys_base_menus" }
|
||||
|
||||
type userAuthorityPO struct {
|
||||
SysUserID uint `gorm:"primaryKey;column:sys_user_id"`
|
||||
SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"`
|
||||
// GVA's explicit join model deliberately has no primary key or uniqueness
|
||||
// constraint. Keep duplicate IDs representable; association replacement
|
||||
// and validation are handled by the service transaction instead.
|
||||
SysUserID uint `gorm:"column:sys_user_id"`
|
||||
SysAuthorityAuthorityID uint `gorm:"column:sys_authority_authority_id"`
|
||||
}
|
||||
|
||||
func (userAuthorityPO) TableName() string { return "sys_user_authority" }
|
||||
|
||||
type authorityMenuPO struct {
|
||||
SysAuthorityAuthorityID uint `gorm:"primaryKey;column:sys_authority_authority_id"`
|
||||
SysBaseMenuID uint `gorm:"primaryKey;column:sys_base_menu_id"`
|
||||
SysAuthorityAuthorityID uint `gorm:"column:sys_authority_authority_id"`
|
||||
SysBaseMenuID uint `gorm:"column:sys_base_menu_id"`
|
||||
}
|
||||
|
||||
func (authorityMenuPO) TableName() string { return "sys_authority_menus" }
|
||||
|
||||
@@ -232,10 +232,20 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.data.persistDatabaseConfig(config); err != nil {
|
||||
signingKey := uuid.NewString()
|
||||
if err := r.data.persistDatabaseConfig(config, signingKey); err != nil {
|
||||
return fmt.Errorf("persist database configuration: %w", err)
|
||||
}
|
||||
r.data.activateDatabase(candidate, config)
|
||||
currentData, currentAdmin := r.data.runtime.Values()
|
||||
if currentAdmin == nil {
|
||||
currentAdmin = &conf.AdminBackend{}
|
||||
}
|
||||
if currentAdmin.Jwt == nil {
|
||||
currentAdmin.Jwt = &conf.AdminBackend_JWT{}
|
||||
}
|
||||
currentAdmin.Jwt.SigningKey = signingKey
|
||||
r.data.runtime.Replace(currentData, currentAdmin)
|
||||
activated = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@ package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestDatabaseConnectionConfigured(t *testing.T) {
|
||||
@@ -49,3 +54,58 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
|
||||
t.Fatal("an active configured database must not require initialization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistDatabaseConfigPreservesTemplateAndRotatesJWTKey(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configPath := filepath.Join(root, "config.yaml")
|
||||
original := `server:
|
||||
http:
|
||||
addr: 0.0.0.0:8000
|
||||
data:
|
||||
database:
|
||||
driver: mysql
|
||||
name: old
|
||||
redis:
|
||||
addr: 127.0.0.1:6379
|
||||
admin:
|
||||
jwt:
|
||||
signing_key: old-key
|
||||
issuer: kra
|
||||
zap:
|
||||
director: logs
|
||||
extension:
|
||||
preserved: true
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := conf.NewRuntime(&conf.Data{}, &conf.AdminBackend{ConfigPath: configPath})
|
||||
data := &Data{runtime: runtime}
|
||||
if err := data.persistDatabaseConfig(&conf.Data_Database{Driver: "mysql", Host: "db", Port: "3306", User: "root", Password: "secret", Name: "kra", Config: "parseTime=True"}, "new-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err = yaml.Unmarshal(raw, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := value["admin"].(map[string]any)
|
||||
jwt := admin["jwt"].(map[string]any)
|
||||
if jwt["signing_key"] != "new-key" || jwt["issuer"] != "kra" {
|
||||
t.Fatalf("unexpected jwt config: %#v", jwt)
|
||||
}
|
||||
dataConfig := value["data"].(map[string]any)
|
||||
database := dataConfig["database"].(map[string]any)
|
||||
if database["name"] != "kra" || database["password"] != "secret" {
|
||||
t.Fatalf("unexpected database config: %#v", database)
|
||||
}
|
||||
if _, ok := dataConfig["redis"]; !ok {
|
||||
t.Fatalf("redis config was removed: %#v", dataConfig)
|
||||
}
|
||||
if _, ok := value["extension"]; !ok || !strings.Contains(string(raw), "preserved: true") {
|
||||
t.Fatalf("extension config was removed: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,25 +41,110 @@ func TestUserAuthorityWritesAreAtomic(t *testing.T) {
|
||||
t.Fatalf("authority links = %d, err = %v", links, err)
|
||||
}
|
||||
|
||||
created.NickName = "must rollback"
|
||||
if err = repo.UpdateUserWithAuthorities(ctx, created, []uint{888, 123456}); err == nil {
|
||||
t.Fatal("expected invalid authority update to fail")
|
||||
// With strict authority checks disabled, GVA writes the supplied join rows
|
||||
// without first requiring every role record to exist.
|
||||
created.NickName = "compatible update"
|
||||
if err = repo.UpdateUserWithAuthorities(ctx, created, []uint{888, 123456}); err != nil {
|
||||
t.Fatalf("missing authority ID was rejected in non-strict mode: %v", err)
|
||||
}
|
||||
var persisted userPO
|
||||
if err = data.gormDB.WithContext(ctx).First(&persisted, created.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted.NickName != "before" {
|
||||
t.Fatalf("user update was not rolled back: %q", persisted.NickName)
|
||||
if persisted.NickName != "compatible update" {
|
||||
t.Fatalf("user update was not persisted: %q", persisted.NickName)
|
||||
}
|
||||
var missingAuthorityLinks int64
|
||||
if err = data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).
|
||||
Where("sys_user_id = ? AND sys_authority_authority_id = ?", created.ID, 123456).
|
||||
Count(&missingAuthorityLinks).Error; err != nil || missingAuthorityLinks != 1 {
|
||||
t.Fatalf("missing-authority link count = %d, err = %v", missingAuthorityLinks, err)
|
||||
}
|
||||
|
||||
invalid := &biz.User{Username: "invalid", Password: "hash", AuthorityID: 888, Enable: 1}
|
||||
if _, err = repo.CreateUserWithAuthorities(ctx, invalid, []uint{888, 123456}); err == nil {
|
||||
t.Fatal("expected invalid authority create to fail")
|
||||
withNewAuthority := &biz.User{Username: "with-new-authority", Password: "hash", AuthorityID: 888, Enable: 1}
|
||||
createdWithNewAuthority, err := repo.CreateUserWithAuthorities(ctx, withNewAuthority, []uint{888, 123456})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var users int64
|
||||
if err = data.gormDB.WithContext(ctx).Model(&userPO{}).Where("username = ?", "invalid").Count(&users).Error; err != nil || users != 0 {
|
||||
t.Fatalf("partial user persisted: count=%d err=%v", users, err)
|
||||
if err = data.gormDB.WithContext(ctx).Model(&userPO{}).Where("username = ?", "with-new-authority").Count(&users).Error; err != nil || users != 1 {
|
||||
t.Fatalf("created user count=%d err=%v", users, err)
|
||||
}
|
||||
var generated authorityPO
|
||||
if err = data.gormDB.WithContext(ctx).First(&generated, "authority_id = ?", 123456).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if generated.DataScope != 1 || generated.DefaultRouter != "dashboard" {
|
||||
t.Fatalf("generated authority defaults = %+v", generated)
|
||||
}
|
||||
links = 0
|
||||
if err = data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_user_id = ?", createdWithNewAuthority.ID).Count(&links).Error; err != nil || links != 2 {
|
||||
t.Fatalf("generated authority links = %d, err = %v", links, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserLoadMatchesIndependentAuthorityAndDepartmentPreloads(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
department := departmentPO{Name: "primary-only"}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&department).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
po := userPO{UUID: "00000000-0000-0000-0000-000000000001", Username: "orphaned-role", Password: "hash", AuthorityID: 700001, DeptID: department.ID, Enable: 1}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&po).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := (&userRepo{data: data}).FindUserByID(ctx, po.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Authority.AuthorityID != 0 {
|
||||
t.Fatalf("missing primary authority should preload as zero value: %+v", loaded.Authority)
|
||||
}
|
||||
if loaded.Department == nil || loaded.Department.ID != department.ID {
|
||||
t.Fatalf("primary department was not independently loaded: %+v", loaded.Department)
|
||||
}
|
||||
if len(loaded.Departments) != 0 {
|
||||
t.Fatalf("primary-only department leaked into many-to-many departments: %+v", loaded.Departments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRegistrationDoesNotAddPrimaryAuthorityToRequestedAssociations(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
if err := data.gormDB.WithContext(ctx).Create(&[]authorityPO{{AuthorityID: 888, AuthorityName: "primary"}, {AuthorityID: 999, AuthorityName: "requested"}}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := (&userRepo{data: data}).CreateUserWithAuthorities(ctx, &biz.User{Username: "association-shape", Password: "hash", AuthorityID: 888, Enable: 1}, []uint{999})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var links []userAuthorityPO
|
||||
if err = data.gormDB.WithContext(ctx).Where("sys_user_id = ?", created.ID).Find(&links).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(links) != 1 || links[0].SysAuthorityAuthorityID != 999 {
|
||||
t.Fatalf("registration authority links = %+v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRouterLookupAcceptsAssignedOrphanMenu(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
menu := menuPO{Name: "orphan-default", ParentID: 999999}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&menu).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := data.gormDB.WithContext(ctx).Create(&authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: menu.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hasMenu, err := (&userRepo{data: data}).HasAuthorityMenu(ctx, 888, "orphan-default")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasMenu {
|
||||
t.Fatal("assigned default menu was not found")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
-42
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (r *userRepo) FindUserByUsername(ctx context.Context, username string) (*biz.User, error) {
|
||||
@@ -36,9 +37,20 @@ func (r *userRepo) FindUserByID(ctx context.Context, id uint) (*biz.User, error)
|
||||
return r.loadUser(ctx, &po)
|
||||
}
|
||||
|
||||
func (r *userRepo) FindUserByUUID(ctx context.Context, value string) (*biz.User, error) {
|
||||
var po userPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("uuid = ?", value).First(&po).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, biz.ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return r.loadUser(ctx, &po)
|
||||
}
|
||||
|
||||
func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error) {
|
||||
var authority authorityPO
|
||||
if err := r.data.gormDB.WithContext(ctx).First(&authority, "authority_id = ?", po.AuthorityID).Error; err != nil {
|
||||
if err := r.data.gormDB.WithContext(ctx).First(&authority, "authority_id = ?", po.AuthorityID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
var authorityPOs []authorityPO
|
||||
@@ -58,13 +70,17 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error)
|
||||
return nil, err
|
||||
}
|
||||
departments := make([]biz.Department, 0, len(departmentPOs))
|
||||
var primary *biz.Department
|
||||
for _, item := range departmentPOs {
|
||||
value := deptFromPO(item)
|
||||
departments = append(departments, *value)
|
||||
if item.ID == po.DeptID {
|
||||
copy := *value
|
||||
primary = ©
|
||||
}
|
||||
var primary *biz.Department
|
||||
if po.DeptID != 0 {
|
||||
var primaryPO departmentPO
|
||||
if err := r.data.gormDB.WithContext(ctx).First(&primaryPO, po.DeptID).Error; err == nil {
|
||||
primary = deptFromPO(primaryPO)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var positionPOs []positionPO
|
||||
@@ -200,12 +216,21 @@ func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*biz.User, er
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *userRepo) HasAuthorityMenu(ctx context.Context, authorityID uint, name string) (bool, error) {
|
||||
var count int64
|
||||
err := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).
|
||||
Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id").
|
||||
Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.name = ?", authorityID, name).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*biz.Menu, error) {
|
||||
var pos []menuPO
|
||||
err := r.data.gormDB.WithContext(ctx).Table("sys_base_menus").
|
||||
Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id").
|
||||
Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.deleted_at IS NULL", authorityID).
|
||||
Order("sys_base_menus.sort asc, sys_base_menus.id asc").Scan(&pos).Error
|
||||
Order("sys_base_menus.sort").Scan(&pos).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -214,15 +239,15 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b
|
||||
for _, po := range pos {
|
||||
menu := menuFromPO(po)
|
||||
menu.MenuID = po.ID
|
||||
menu.Children = []*biz.Menu{}
|
||||
menu.Children = nil
|
||||
menu.Parameters = []*biz.MenuParameter{}
|
||||
menu.Buttons = []*biz.MenuButton{}
|
||||
menu.Buttons = nil
|
||||
byID[po.ID] = menu
|
||||
menuIDs = append(menuIDs, po.ID)
|
||||
}
|
||||
if len(menuIDs) > 0 {
|
||||
var parameters []menuParameterPO
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Order("id").Find(¶meters).Error; err != nil {
|
||||
if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(¶meters).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, parameter := range parameters {
|
||||
@@ -240,7 +265,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b
|
||||
menu.Buttons = append(menu.Buttons, value)
|
||||
}
|
||||
}
|
||||
roots := make([]*biz.Menu, 0)
|
||||
var roots []*biz.Menu
|
||||
for _, po := range pos {
|
||||
menu := byID[po.ID]
|
||||
if parent := byID[po.ParentID]; parent != nil {
|
||||
@@ -281,7 +306,7 @@ func (r *userRepo) ListUsers(ctx context.Context, page, pageSize int, filter *bi
|
||||
return nil, 0, err
|
||||
}
|
||||
var pos []userPO
|
||||
if err := applyPagination(db.Order(order), page, pageSize, 100).Find(&pos).Error; err != nil {
|
||||
if err := applyRequiredPagination(db.Order(order), page, pageSize, 100).Find(&pos).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
users, err := r.loadUsers(ctx, pos)
|
||||
@@ -307,9 +332,7 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User
|
||||
}
|
||||
now := time.Now()
|
||||
po := userPO{UUID: user.UUID, Username: user.Username, Password: user.Password, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Phone: user.Phone, Email: user.Email, Enable: user.Enable, PasswordUpdatedAt: &now, MustChangePassword: user.MustChangePassword}
|
||||
if len(authorityIDs) == 0 {
|
||||
authorityIDs = []uint{user.AuthorityID}
|
||||
}
|
||||
var createdAuthorities []biz.Authority
|
||||
if err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// The compatible registration rejects an existing non-deleted username before creating the
|
||||
// account. The username column is intentionally not unique for legacy
|
||||
@@ -321,34 +344,36 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := validateAuthorities(tx, authorityIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(authorityIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
links := make([]userAuthorityPO, 0, len(authorityIDs))
|
||||
for _, authorityID := range authorityIDs {
|
||||
// Creating a user through the compatible administration endpoint lets
|
||||
// GORM save the many-to-many authority values supplied by the request.
|
||||
// With an ID-only value this is an upsert: an existing role is left
|
||||
// untouched, while a missing role is materialized with model defaults.
|
||||
associated := authorityPO{AuthorityID: authorityID}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&associated).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
createdAuthorities = append(createdAuthorities, toBizAuthority(associated))
|
||||
links = append(links, userAuthorityPO{SysUserID: po.ID, SysAuthorityAuthorityID: authorityID})
|
||||
}
|
||||
return tx.Create(&links).Error
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.FindUserByID(ctx, po.ID)
|
||||
}
|
||||
|
||||
func validateAuthorities(tx *gorm.DB, authorityIDs []uint) error {
|
||||
for _, authorityID := range authorityIDs {
|
||||
var count int64
|
||||
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", authorityID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return errors.New("角色不存在")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// Registration returns the just-created entity, not a second hydrated
|
||||
// query. The reference handler returns the request's authority IDs and
|
||||
// leaves related authority/department records at their zero values; keep
|
||||
// that response contract while retaining the persisted timestamps/UUID.
|
||||
created := baseBizUser(&po)
|
||||
created.Authorities = createdAuthorities
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (r *userRepo) UpdateUser(ctx context.Context, user *biz.User) error {
|
||||
@@ -416,14 +441,6 @@ func (r *userRepo) FillDepartmentNamePaths(ctx context.Context, user *biz.User)
|
||||
parts = append(parts, user.Departments[i].Name)
|
||||
user.Departments[i].NamePath = strings.Join(parts, "/")
|
||||
}
|
||||
if user.Department != nil {
|
||||
for i := range user.Departments {
|
||||
if user.Departments[i].ID == user.Department.ID {
|
||||
user.Department.NamePath = user.Departments[i].NamePath
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -504,9 +521,6 @@ func setUserAuthorities(tx *gorm.DB, id uint, authorityIDs []uint) error {
|
||||
if len(authorityIDs) == 0 {
|
||||
return errors.New("角色不能为空")
|
||||
}
|
||||
if err := validateAuthorities(tx, authorityIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sys_user_id = ?", id).Delete(&userAuthorityPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ type versionPO struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
Name string `gorm:"column:version_name"`
|
||||
Code string `gorm:"column:version_code"`
|
||||
Description string
|
||||
Data string `gorm:"column:version_data;type:text"`
|
||||
Name *string `gorm:"column:version_name"`
|
||||
Code *string `gorm:"column:version_code"`
|
||||
Description *string
|
||||
Data *string `gorm:"column:version_data;type:text"`
|
||||
}
|
||||
|
||||
func (versionPO) TableName() string { return "sys_versions" }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersionRepositoryPreservesNullableFields(t *testing.T) {
|
||||
data := newTransactionTestData(t)
|
||||
ctx := context.Background()
|
||||
if err := data.gormDB.WithContext(ctx).Exec(
|
||||
"INSERT INTO sys_versions (created_at, updated_at, version_name, version_code, description, version_data) VALUES (CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, NULL)",
|
||||
).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
value, err := (&versionRepo{data: data}).FindVersion(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.Name != nil || value.Code != nil || value.Description != nil || value.Data != nil {
|
||||
t.Fatalf("nullable version fields = %#v/%#v/%#v/%#v, want nil", value.Name, value.Code, value.Description, value.Data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user