This commit is contained in:
2026-08-17 02:12:17 +08:00
parent 560ae46b82
commit 6a05803e08
96 changed files with 2888 additions and 685 deletions
+6 -6
View File
@@ -23,10 +23,10 @@ type AnnouncementFilter struct {
type AnnouncementRepo interface {
Create(context.Context, *Announcement) error
Delete(context.Context, uint) error
DeleteByIDs(context.Context, []uint) error
Delete(context.Context, string) error
DeleteByIDs(context.Context, []string) error
Update(context.Context, *Announcement) error
Find(context.Context, uint) (*Announcement, error)
Find(context.Context, string) (*Announcement, error)
List(context.Context, AnnouncementFilter) ([]*Announcement, int64, error)
UserOptions(context.Context) ([]UserOption, error)
}
@@ -50,15 +50,15 @@ func (uc *AnnouncementUsecase) Update(ctx context.Context, item *Announcement) e
return uc.repo.Update(ctx, item)
}
func (uc *AnnouncementUsecase) Delete(ctx context.Context, id uint) error {
func (uc *AnnouncementUsecase) Delete(ctx context.Context, id string) error {
return uc.repo.Delete(ctx, id)
}
func (uc *AnnouncementUsecase) DeleteByIDs(ctx context.Context, ids []uint) error {
func (uc *AnnouncementUsecase) DeleteByIDs(ctx context.Context, ids []string) error {
return uc.repo.DeleteByIDs(ctx, ids)
}
func (uc *AnnouncementUsecase) Find(ctx context.Context, id uint) (*Announcement, error) {
func (uc *AnnouncementUsecase) Find(ctx context.Context, id string) (*Announcement, error) {
return uc.repo.Find(ctx, id)
}
+7 -1
View File
@@ -79,7 +79,13 @@ func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff
for _, item := range stored {
storedSet[key(item)] = item
}
diff := &APISyncDiff{Ignored: ignored}
// The sync API always returns JSON arrays for all three collections,
// including when no routes were added or removed.
diff := &APISyncDiff{
Added: make([]*API, 0),
Deleted: make([]*API, 0),
Ignored: ignored,
}
added := make(map[string]bool)
for _, item := range routes {
routeKey := key(item)
+5 -8
View File
@@ -142,17 +142,14 @@ func (uc *AuthenticationUsecase) SwitchAuthority(ctx context.Context, claims *Au
if err := uc.users.SetUserAuthority(ctx, claims.ID, authorityID); err != nil {
return nil, err
}
user, err := uc.users.User(ctx, claims.ID)
if err != nil {
return nil, err
}
// IssueToken receives the remaining lifetime rather than zero so the new
// token expires at the same instant as the token it replaced.
remaining := time.Until(claims.ExpiresAt)
issued, err := uc.issuer.IssueToken(user, authorityID, claims.MustChangePwd, remaining)
// Role switching signs the current claims again after replacing only the
// authority ID. It must not reload mutable profile fields or reset JWT
// timing/buffer claims as if this were a fresh login.
issued, err := uc.issuer.ReissueToken(claims, authorityID)
if err != nil {
return nil, err
}
user := &User{ID: claims.ID, UUID: claims.UUID, Username: claims.Username, NickName: claims.NickName, AuthorityID: authorityID, MustChangePassword: claims.MustChangePwd}
return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: claims.MustChangePwd}, nil
}
+60
View File
@@ -18,6 +18,10 @@ func (r *authenticationUserRepo) FindUserByUsername(context.Context, string) (*U
return r.user, nil
}
func (*authenticationUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) {
return true, nil
}
type authenticationSecurityRepo struct{ SecurityRepo }
func (*authenticationSecurityRepo) SecurityConfig(context.Context) (*SecurityConfig, error) {
@@ -48,11 +52,42 @@ func (*authenticationIssuer) IssueToken(*User, uint, bool, time.Duration) (*Issu
return &IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil
}
func (*authenticationIssuer) ReissueToken(claims *AuthClaims, _ uint) (*IssuedToken, error) {
return &IssuedToken{Value: "token", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
}
type authenticationAudit struct {
AuditRecordRepo
logins []*LoginLog
}
type switchAuthorityUserRepo struct {
UserRepo
setUserID, setAuthorityID uint
findCalls int
}
func (r *switchAuthorityUserRepo) SetUserAuthority(_ context.Context, userID, authorityID uint) error {
r.setUserID, r.setAuthorityID = userID, authorityID
return nil
}
func (r *switchAuthorityUserRepo) FindUserByID(context.Context, uint) (*User, error) {
r.findCalls++
return &User{NickName: "changed-in-database"}, nil
}
type switchAuthorityIssuer struct {
TokenIssuer
claims *AuthClaims
authorityID uint
}
func (i *switchAuthorityIssuer) ReissueToken(claims *AuthClaims, authorityID uint) (*IssuedToken, error) {
i.claims, i.authorityID = claims, authorityID
return &IssuedToken{Value: "switched", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
}
func (a *authenticationAudit) RecordLogin(_ context.Context, value *LoginLog) error {
a.logins = append(a.logins, value)
return nil
@@ -77,3 +112,28 @@ func TestLoginRecordsSuccessBeforeMultipointCacheFailure(t *testing.T) {
t.Fatalf("expected successful login audit before cache failure, got %+v", audit.logins)
}
}
func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T) {
repo := &switchAuthorityUserRepo{}
issuer := &switchAuthorityIssuer{}
uc := NewAuthenticationUsecase(NewUserUsecase(repo), nil, issuer, nil)
expiresAt := time.Now().Add(time.Hour).Truncate(time.Second)
claims := &AuthClaims{ID: 7, UUID: "uuid", Username: "admin", NickName: "token-nickname", AuthorityID: 888, MustChangePwd: true, ExpiresAt: expiresAt}
result, err := uc.SwitchAuthority(context.Background(), claims, 999)
if err != nil {
t.Fatal(err)
}
if repo.setUserID != 7 || repo.setAuthorityID != 999 {
t.Fatalf("stored authority switch = user:%d authority:%d", repo.setUserID, repo.setAuthorityID)
}
if repo.findCalls != 0 {
t.Fatalf("switch reloaded user %d times", repo.findCalls)
}
if issuer.claims != claims || issuer.authorityID != 999 {
t.Fatalf("reissue input = claims:%p authority:%d", issuer.claims, issuer.authorityID)
}
if result.User.NickName != "token-nickname" || result.User.AuthorityID != 999 || !result.NeedChangePassword || !result.ExpiresAt.Equal(expiresAt) {
t.Fatalf("switch result = %+v", result)
}
}
+1
View File
@@ -31,6 +31,7 @@ type Authority struct {
DataScope int
DefaultRouter string
Children []*Authority
Menus []*Menu
}
type AuthorityUsecase struct{ AuthorityAccessRepo }
+2 -34
View File
@@ -37,6 +37,7 @@ type DictionaryDetailRepo interface {
FindDictionaryDetail(context.Context, uint) (*DictionaryDetail, error)
ListDictionaryDetails(context.Context, int, int, DictionaryDetailFilter) ([]*DictionaryDetail, int64, error)
DictionaryDetailTree(context.Context, uint, string) ([]*DictionaryDetail, error)
DictionaryDetailsByParent(context.Context, uint, *uint, bool) ([]*DictionaryDetail, error)
}
type Dictionary struct {
@@ -78,38 +79,5 @@ func (uc *DictionaryUsecase) ImportDictionary(ctx context.Context, dictionary *D
}
func (uc *DictionaryUsecase) DictionaryDetailsByParent(ctx context.Context, dictionaryID uint, parentID *uint, includeChildren bool) ([]*DictionaryDetail, error) {
items, _, err := uc.ListDictionaryDetails(ctx, 0, 0, DictionaryDetailFilter{DictionaryID: dictionaryID})
if err != nil {
return nil, err
}
direct := make([]*DictionaryDetail, 0)
for _, item := range items {
if (parentID == nil && item.ParentID == nil) || (parentID != nil && item.ParentID != nil && *item.ParentID == *parentID) {
direct = append(direct, item)
}
}
if !includeChildren {
return direct, nil
}
children := make(map[uint][]*DictionaryDetail)
for _, item := range items {
item.Children = []*DictionaryDetail{}
if item.ParentID != nil {
children[*item.ParentID] = append(children[*item.ParentID], item)
}
}
var attach func(*DictionaryDetail)
attach = func(item *DictionaryDetail) {
item.Children = children[item.ID]
if item.Children == nil {
item.Children = []*DictionaryDetail{}
}
for _, child := range item.Children {
attach(child)
}
}
for _, item := range direct {
attach(item)
}
return direct, nil
return uc.DictionaryRepo.DictionaryDetailsByParent(ctx, dictionaryID, parentID, includeChildren)
}
+3
View File
@@ -82,6 +82,8 @@ type AuthClaims struct {
BufferTime time.Duration
MustChangePwd bool
Issuer string
Audience []string
NotBefore time.Time
ExpiresAt time.Time
}
@@ -96,5 +98,6 @@ var (
type TokenIssuer interface {
IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error)
ReissueToken(*AuthClaims, uint) (*IssuedToken, error)
ParseToken(string) (*AuthClaims, error)
}
+10 -5
View File
@@ -132,10 +132,11 @@ func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string,
if limit <= 0 {
limit = 100
}
// Multipart chunks are staged on the local filesystem, so listOssFiles
// never exposes them. Kra's storage abstraction may stage them through
// the selected backend; hide the internal prefix to preserve the API.
chunkRoot := ".chunks"
// Multipart chunks are an internal staging detail. The compatible local
// implementation keeps them outside the configured object-storage root, so
// the public listing must not expose them even when the selected backend is
// also used for staging.
chunkRoot := "uploads/chunks"
if uc.settings != nil {
if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" {
chunkRoot = configured
@@ -145,7 +146,11 @@ func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string,
result := make([]*StoredFile, 0, limit)
nextCursor := cursor
for {
items, next, more, err := uc.files.List(ctx, prefix, nextCursor, limit)
// Ask only for the remaining visible capacity. Otherwise a page that is
// partially filtered could fill the response halfway through the next
// storage page while returning that page's end cursor, skipping objects.
remaining := limit - len(result)
items, next, more, err := uc.files.List(ctx, prefix, nextCursor, remaining)
if err != nil {
return nil, "", false, err
}
+81
View File
@@ -0,0 +1,81 @@
package biz
import (
"context"
"io"
"strings"
"testing"
)
type listStorage struct {
items []*StoredFile
limits []int
}
func (*listStorage) Put(context.Context, string, io.Reader) (*StoredFile, error) {
return nil, nil
}
func (*listStorage) Open(context.Context, string) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("")), nil
}
func (*listStorage) Delete(context.Context, string) error { return nil }
func (*listStorage) Compose(context.Context, []string, string) (*StoredFile, string, error) {
return nil, "", nil
}
func (*listStorage) DeletePrefix(context.Context, string) error { return nil }
func (s *listStorage) List(_ context.Context, _ string, cursor string, limit int) ([]*StoredFile, string, bool, error) {
s.limits = append(s.limits, limit)
start := 0
for start < len(s.items) && s.items[start].Path <= cursor {
start++
}
end := start + limit
if end > len(s.items) {
end = len(s.items)
}
page := s.items[start:end]
more := end < len(s.items)
next := ""
if len(page) > 0 {
next = page[len(page)-1].Path
}
return page, next, more, nil
}
func TestListStorageFiltersChunksWithoutSkippingVisibleObjects(t *testing.T) {
storage := &listStorage{items: []*StoredFile{
{Path: "uploads/chunks/1/00000000"},
{Path: "uploads/chunks/1/00000001"},
{Path: "visible/a"},
{Path: "visible/b"},
{Path: "visible/c"},
{Path: "visible/d"},
{Path: "visible/e"},
}}
uc := NewMediaUsecase(nil, storage, nil)
first, cursor, more, err := uc.ListStorage(context.Background(), "", "", 3)
if err != nil {
t.Fatal(err)
}
if got := []string{first[0].Path, first[1].Path, first[2].Path}; strings.Join(got, ",") != "visible/a,visible/b,visible/c" {
t.Fatalf("first page = %v", got)
}
if cursor != "visible/c" || !more {
t.Fatalf("first cursor/more = %q/%v", cursor, more)
}
if len(storage.limits) != 2 || storage.limits[0] != 3 || storage.limits[1] != 2 {
t.Fatalf("storage limits = %v, want [3 2]", storage.limits)
}
second, cursor, more, err := uc.ListStorage(context.Background(), "", cursor, 3)
if err != nil {
t.Fatal(err)
}
if got := []string{second[0].Path, second[1].Path}; strings.Join(got, ",") != "visible/d,visible/e" {
t.Fatalf("second page = %v", got)
}
if cursor != "visible/e" || more {
t.Fatalf("second cursor/more = %q/%v", cursor, more)
}
}
+9 -24
View File
@@ -59,6 +59,11 @@ type MenuRepo interface {
List(context.Context) ([]*Menu, error)
SetAuthorityMenus(context.Context, uint, []uint) error
AuthorityMenuIDs(context.Context, uint) ([]uint, error)
// ListAuthorityMenus returns the base menus assigned to the target
// authority. Unlike List, it must not apply the current actor's strict
// menu filter: the administration endpoint is explicitly querying another
// role's assignments.
ListAuthorityMenus(context.Context, uint) ([]*Menu, error)
MenuRoleIDs(context.Context, uint) ([]uint, error)
DefaultRouterRoleIDs(context.Context, uint) ([]uint, error)
SetMenuRoles(context.Context, uint, []uint) error
@@ -79,10 +84,10 @@ func (uc *MenuUsecase) Tree(ctx context.Context) ([]*Menu, error) {
}
byID := make(map[uint]*Menu, len(items))
for _, item := range items {
item.Children = []*Menu{}
item.Children = nil
byID[item.ID] = item
}
roots := make([]*Menu, 0)
var roots []*Menu
for _, item := range items {
if parent := byID[item.ParentID]; parent != nil {
parent.Children = append(parent.Children, item)
@@ -114,31 +119,11 @@ func (uc *MenuUsecase) SetAuthorityMenus(ctx context.Context, authorityID uint,
}
func (uc *MenuUsecase) AuthorityMenus(ctx context.Context, authorityID uint) ([]*Menu, error) {
ids, err := uc.repo.AuthorityMenuIDs(ctx, authorityID)
items, err := uc.repo.ListAuthorityMenus(ctx, authorityID)
if err != nil {
return nil, err
}
items, err := uc.repo.List(ctx)
if err != nil {
return nil, err
}
selected := make(map[uint]struct{}, len(ids))
for _, id := range ids {
selected[id] = struct{}{}
}
result := make([]*Menu, 0, len(ids))
for _, item := range items {
if _, ok := selected[item.ID]; ok {
// The authority-menu endpoint returns menu relations only. Keep its
// existing contract: button details are loaded by the dedicated
// authority-button endpoints. The compatible query does not preload either
// relation here, so parameters must remain absent as well.
item.Buttons = nil
item.Parameters = nil
result = append(result, item)
}
}
return result, nil
return items, nil
}
func (uc *MenuUsecase) RoleIDs(ctx context.Context, menuID uint) ([]uint, error) {
+3 -2
View File
@@ -20,8 +20,9 @@ type SystemParameter struct {
type ParameterRepo interface {
CreateParameter(context.Context, *SystemParameter) error
UpdateParameter(context.Context, *SystemParameter) error
DeleteParameters(context.Context, []uint) error
FindParameter(context.Context, uint, string) (*SystemParameter, error)
DeleteParameters(context.Context, []string) error
FindParameterByID(context.Context, string) (*SystemParameter, error)
FindParameterByKey(context.Context, string) (*SystemParameter, error)
ListParameters(context.Context, int, int, *SystemParameter) ([]*SystemParameter, int64, error)
}
+1 -1
View File
@@ -8,7 +8,7 @@ type PermissionRepo interface {
AuthorityButtonIDs(context.Context, uint) ([]uint, error)
SelectedButtons(context.Context, uint, uint) ([]uint, error)
SetSelectedButtons(context.Context, uint, uint, []uint) error
CanRemoveButton(context.Context, uint) (bool, error)
CanRemoveButton(context.Context, string) (bool, error)
}
type PermissionUsecase struct{ PermissionRepo }
+8 -1
View File
@@ -99,7 +99,7 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
uc.mu.RUnlock()
value, err := uc.repo.SecurityConfig(ctx)
if err != nil {
// GVA returns its default value together with the database-not-ready
// Return the default value together with the database-not-ready
// error. Callers such as login/runtime policy consumers intentionally
// ignore the error and continue with that default, while the HTTP
// settings endpoint still reports the failure.
@@ -115,6 +115,13 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
return value, nil
}
// Security returns the persisted row for the administration endpoint. Runtime
// policy consumers use Current so their hot path remains cached, while the
// settings page mirrors the reference behavior and always observes storage.
func (uc *SecurityUsecase) Security(ctx context.Context) (*SecurityConfig, error) {
return uc.repo.SecurityConfig(ctx)
}
func (uc *SecurityUsecase) ValidatePassword(value *SecurityConfig, password string) error {
if value.PwdMinLength > 0 && utf8.RuneCountInString(password) < value.PwdMinLength {
return fmt.Errorf("密码长度不能少于 %d 位", value.PwdMinLength)
+29
View File
@@ -81,3 +81,32 @@ func TestUpdateSecurityDoesNotBackfillWithoutDisabledToEnabledTransition(t *test
t.Fatalf("call order = %v, want [get save]", got)
}
}
func TestSecurityReadsPersistedValueWhileCurrentUsesCache(t *testing.T) {
repo := &securityUpdateRepo{current: &SecurityConfig{ID: 1, LimitCount: 10}}
uc := NewSecurityUsecase(repo, nil, nil, nil)
cached, err := uc.Current(context.Background())
if err != nil {
t.Fatal(err)
}
if cached.LimitCount != 10 {
t.Fatalf("initial cached limit = %d", cached.LimitCount)
}
repo.current.LimitCount = 20
current, err := uc.Current(context.Background())
if err != nil {
t.Fatal(err)
}
if current.LimitCount != 10 {
t.Fatalf("Current() bypassed cache: %d", current.LimitCount)
}
fresh, err := uc.Security(context.Background())
if err != nil {
t.Fatal(err)
}
if fresh.LimitCount != 20 {
t.Fatalf("Security() returned stale value: %d", fresh.LimitCount)
}
}
+21 -28
View File
@@ -38,6 +38,8 @@ type UserListFilter struct {
type UserRepo interface {
FindUserByUsername(context.Context, string) (*User, error)
FindUserByID(context.Context, uint) (*User, error)
FindUserByUUID(context.Context, string) (*User, error)
HasAuthorityMenu(context.Context, uint, string) (bool, error)
MenusByAuthority(context.Context, uint) ([]*Menu, error)
ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error)
CreateUser(context.Context, *User) (*User, error)
@@ -77,23 +79,29 @@ func (uc *UserUsecase) User(ctx context.Context, id uint) (*User, error) {
return user, nil
}
func (uc *UserUsecase) fallbackDefaultRouter(ctx context.Context, user *User) {
if user == nil || user.Authority.DefaultRouter == "" {
return
}
menus, err := uc.repo.MenusByAuthority(ctx, user.AuthorityID)
if err != nil || !menuNameExists(menus, user.Authority.DefaultRouter) {
user.Authority.DefaultRouter = "404"
// UserByUUID resolves the identity from the UUID embedded in the JWT rather
// than from the mutable numeric ID used by administrative records.
func (uc *UserUsecase) UserByUUID(ctx context.Context, uuid string) (*User, error) {
user, err := uc.repo.FindUserByUUID(ctx, uuid)
if err != nil {
return nil, err
}
_ = uc.repo.FillDepartmentNamePaths(ctx, user)
uc.fallbackDefaultRouter(ctx, user)
return user, nil
}
func menuNameExists(menus []*Menu, name string) bool {
for _, menu := range menus {
if menu.Name == name || menuNameExists(menu.Children, name) {
return true
}
func (uc *UserUsecase) fallbackDefaultRouter(ctx context.Context, user *User) {
if user == nil {
return
}
hasMenu, err := uc.repo.HasAuthorityMenu(ctx, user.AuthorityID, user.Authority.DefaultRouter)
if err != nil {
return
}
if !hasMenu {
user.Authority.DefaultRouter = "404"
}
return false
}
func (uc *UserUsecase) Menus(ctx context.Context, authorityID uint) ([]*Menu, error) {
@@ -104,10 +112,6 @@ func (uc *UserUsecase) ListUsers(ctx context.Context, page, pageSize int, filter
return uc.repo.ListUsers(ctx, page, pageSize, filter)
}
func (uc *UserUsecase) CreateUser(ctx context.Context, user *User, authorityIDs []uint) (*User, error) {
if user.AuthorityID == 0 && len(authorityIDs) > 0 {
user.AuthorityID = authorityIDs[0]
}
authorityIDs = includeAuthority(authorityIDs, user.AuthorityID)
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
@@ -132,17 +136,6 @@ func (uc *UserUsecase) UpdateSelfUser(ctx context.Context, user *User) error {
return uc.repo.UpdateSelfUser(ctx, user)
}
func includeAuthority(ids []uint, primary uint) []uint {
if primary == 0 {
return ids
}
for _, id := range ids {
if id == primary {
return ids
}
}
return append(ids, primary)
}
func (uc *UserUsecase) DeleteUser(ctx context.Context, id uint) error {
return uc.repo.DeleteUser(ctx, id)
}
+37
View File
@@ -0,0 +1,37 @@
package biz
import (
"context"
"errors"
"testing"
)
type defaultRouterUserRepo struct {
UserRepo
hasMenu bool
err error
}
func (r *defaultRouterUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) {
return r.hasMenu, r.err
}
func TestFallbackDefaultRouterMatchesMenuLookupOutcome(t *testing.T) {
user := &User{AuthorityID: 888, Authority: Authority{DefaultRouter: "dashboard"}}
NewUserUsecase(&defaultRouterUserRepo{hasMenu: false}).fallbackDefaultRouter(context.Background(), user)
if user.Authority.DefaultRouter != "404" {
t.Fatalf("missing default route = %q", user.Authority.DefaultRouter)
}
user.Authority.DefaultRouter = "dashboard"
NewUserUsecase(&defaultRouterUserRepo{err: errors.New("database unavailable")}).fallbackDefaultRouter(context.Background(), user)
if user.Authority.DefaultRouter != "dashboard" {
t.Fatalf("lookup error changed default route to %q", user.Authority.DefaultRouter)
}
user.Authority.DefaultRouter = ""
NewUserUsecase(&defaultRouterUserRepo{}).fallbackDefaultRouter(context.Background(), user)
if user.Authority.DefaultRouter != "404" {
t.Fatalf("empty missing default route = %q", user.Authority.DefaultRouter)
}
}
+4 -4
View File
@@ -30,10 +30,10 @@ type Version struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
Code string
Description string
Data string
Name *string
Code *string
Description *string
Data *string
}
type VersionBundle struct {
Name string