fix: align admin behavior and Kratos integrations

This commit is contained in:
2026-08-14 14:14:51 +08:00
parent 8c4c6ba661
commit e0b0e1d8c8
123 changed files with 4334 additions and 1877 deletions
+2
View File
@@ -1 +1,3 @@
# Biz
本目录只包含领域对象、用例和仓储接口,不依赖 HTTP Handler、数据库驱动或对象存储 SDK。跨多表、必须原子完成的业务动作通过具有业务含义的仓储方法表达。
+98 -19
View File
@@ -2,6 +2,7 @@ package biz
import (
"context"
"strings"
"time"
)
@@ -13,9 +14,28 @@ type API struct {
Description string
APIGroup string
Method string
OrderKey string
Desc bool
StrictAll bool
}
type AccessRepo interface {
type APIRepo interface {
CreateAPI(context.Context, *API) error
UpdateAPI(context.Context, *API) error
DeleteAPIs(context.Context, []uint) error
FindAPI(context.Context, uint) (*API, error)
ListAPIs(context.Context, int, int, *API) ([]*API, int64, error)
APIRoleIDs(context.Context, string, string) ([]uint, error)
SetAPIRoles(context.Context, string, string, []uint) error
Authorize(context.Context, uint, string, string) (bool, error)
PolicyPaths(context.Context, uint) ([]*API, error)
SetPolicyPaths(context.Context, uint, []*API) error
IgnoredAPIs(context.Context) ([]*API, error)
SetAPIIgnored(context.Context, string, string, bool) error
ApplyAPISync(context.Context, []*API, []*API) error
}
type AuthorityAccessRepo interface {
CreateAuthority(context.Context, *Authority) error
CopyAuthority(context.Context, uint, *Authority) error
UpdateAuthority(context.Context, *Authority) error
@@ -26,21 +46,9 @@ type AccessRepo interface {
SetDataScope(context.Context, uint, int, []uint) error
DataScopeDepartmentIDs(context.Context, uint) ([]uint, error)
ResolveDataScope(context.Context, uint, uint) (DataScope, error)
}
CreateAPI(context.Context, *API) error
UpdateAPI(context.Context, *API) error
DeleteAPIs(context.Context, []uint) error
FindAPI(context.Context, uint) (*API, error)
ListAPIs(context.Context, int, int, *API) ([]*API, int64, error)
APIRoleIDs(context.Context, uint) ([]uint, error)
SetAPIRoles(context.Context, uint, []uint) error
Authorize(context.Context, uint, string, string) (bool, error)
PolicyPaths(context.Context, uint) ([]*API, error)
SetPolicyPaths(context.Context, uint, []*API) error
IgnoredAPIs(context.Context) ([]*API, error)
SetAPIIgnored(context.Context, string, string, bool) error
ApplyAPISync(context.Context, []*API, []*API) error
type PermissionRepo interface {
Buttons(context.Context, uint) ([]*MenuButton, error)
SetAuthorityButtons(context.Context, uint, map[uint][]uint) error
AuthorityButtonIDs(context.Context, uint) ([]uint, error)
@@ -49,14 +57,85 @@ type AccessRepo interface {
CanRemoveButton(context.Context, uint) (bool, error)
}
type AccessUsecase struct{ repo AccessRepo }
type AccessRepo interface {
AuthorityAccessRepo
APIRepo
PermissionRepo
}
func NewAccessUsecase(repo AccessRepo) *AccessUsecase { return &AccessUsecase{repo: repo} }
type AccessUsecase struct{ AccessRepo }
func NewAccessUsecase(repo AccessRepo) *AccessUsecase { return &AccessUsecase{AccessRepo: repo} }
func (uc *AccessUsecase) Repo() AccessRepo { return uc.repo }
func (uc *AccessUsecase) Authorize(ctx context.Context, authorityID uint, path, method string) (bool, error) {
if authorityID == 888 {
return true, nil
}
return uc.repo.Authorize(ctx, authorityID, path, method)
return uc.AccessRepo.Authorize(ctx, authorityID, path, method)
}
type APISyncDiff struct {
Added []*API
Deleted []*API
Ignored []*API
}
func (uc *AccessUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff, error) {
stored, _, err := uc.ListAPIs(ctx, 0, 0, nil)
if err != nil {
return nil, err
}
ignored, err := uc.IgnoredAPIs(ctx)
if err != nil {
return nil, err
}
key := func(value *API) string { return strings.ToUpper(value.Method) + " " + value.Path }
ignoreSet := make(map[string]bool, len(ignored))
for _, item := range ignored {
ignoreSet[key(item)] = true
}
routeSet := make(map[string]*API, len(routes))
for _, item := range routes {
if !ignoreSet[key(item)] {
routeSet[key(item)] = item
}
}
storedSet := make(map[string]*API, len(stored))
for _, item := range stored {
storedSet[key(item)] = item
}
diff := &APISyncDiff{Ignored: ignored}
for routeKey, item := range routeSet {
if storedSet[routeKey] == nil {
diff.Added = append(diff.Added, item)
}
}
for storedKey, item := range storedSet {
if routeSet[storedKey] == nil && !ignoreSet[storedKey] {
diff.Deleted = append(diff.Deleted, item)
}
}
return diff, nil
}
func (uc *AccessUsecase) AuthorityTree(ctx context.Context) ([]*Authority, error) {
items, err := uc.ListAuthorities(ctx)
if err != nil {
return nil, err
}
byID := make(map[uint]*Authority, len(items))
for _, item := range items {
item.Children = nil
byID[item.AuthorityID] = item
}
roots := make([]*Authority, 0)
for _, item := range items {
if item.ParentID != nil && *item.ParentID != 0 && byID[*item.ParentID] != nil {
parent := byID[*item.ParentID]
parent.Children = append(parent.Children, item)
} else {
roots = append(roots, item)
}
}
return roots, nil
}
+19
View File
@@ -0,0 +1,19 @@
package biz
import "context"
type actorContextKey struct{}
type Actor struct {
UserID uint
AuthorityID uint
}
func NewActorContext(ctx context.Context, actor Actor) context.Context {
return context.WithValue(ctx, actorContextKey{}, actor)
}
func ActorFromContext(ctx context.Context) (Actor, bool) {
actor, ok := ctx.Value(actorContextKey{}).(Actor)
return actor, ok
}
+24
View File
@@ -2,6 +2,7 @@ package biz
import (
"context"
"errors"
"time"
)
@@ -23,6 +24,29 @@ type APITokenRepo interface {
CreateAPIToken(context.Context, *APIToken) error
ListAPITokens(context.Context, int, int, uint, *bool) ([]*APIToken, int64, error)
DisableAPIToken(context.Context, uint) (string, error)
DisableAndBlacklistAPIToken(context.Context, uint) error
BlacklistToken(context.Context, string) error
IsTokenDisabled(context.Context, string) (bool, error)
}
func (uc *SettingsUsecase) PrepareAPIToken(ctx context.Context, userID, authorityID uint, days int) (*User, time.Duration, error) {
user, allowed, err := uc.UserHasAuthority(ctx, userID, authorityID)
if err != nil {
return nil, 0, err
}
if !allowed {
return nil, 0, errors.New("用户不具备该角色权限")
}
duration := time.Duration(days) * 24 * time.Hour
if days == -1 {
duration = 100 * 365 * 24 * time.Hour
}
if duration <= 0 {
return nil, 0, errors.New("有效天数必须大于0或为-1")
}
return user, duration, nil
}
func (uc *SettingsUsecase) DisableToken(ctx context.Context, id uint) error {
return uc.SettingsRepo.DisableAndBlacklistAPIToken(ctx, id)
}
+47 -18
View File
@@ -14,7 +14,16 @@ type OperationRecord struct {
Agent, ErrorMessage, Body, Response string
UserID uint
RequestID, TraceID, DeviceID string
User *User
}
type OperationLogRepo interface {
RecordOperation(context.Context, *OperationRecord) error
ListOperations(context.Context, int, int, *OperationRecord) ([]*OperationRecord, int64, error)
FindOperation(context.Context, uint) (*OperationRecord, error)
DeleteOperations(context.Context, []uint) error
}
type LoginLog struct {
ID uint
CreatedAt time.Time
@@ -22,7 +31,17 @@ type LoginLog struct {
Status bool
ErrorMessage, Agent string
UserID uint
User *User
FilterByStatus bool
}
type LoginLogRepo interface {
RecordLogin(context.Context, *LoginLog) error
ListLogins(context.Context, int, int, *LoginLog) ([]*LoginLog, int64, error)
FindLogin(context.Context, uint) (*LoginLog, error)
DeleteLogins(context.Context, []uint) error
}
type DataAccessLog struct {
ID uint
CreatedAt time.Time
@@ -31,6 +50,13 @@ type DataAccessLog struct {
Scope int
RequestID, Method, Path, Detail string
}
type DataAccessLogRepo interface {
RecordDataAccess(context.Context, *DataAccessLog) error
ListDataAccess(context.Context, int, int, *DataAccessLog) ([]*DataAccessLog, int64, error)
DeleteDataAccess(context.Context, []uint) error
}
type LogDate struct {
Date string
FileCount int
@@ -48,33 +74,36 @@ type LogContent struct {
Size int64
ModifiedAt time.Time
}
type LogFileRepo interface {
LogDates(context.Context, string) ([]LogDate, error)
LogFiles(context.Context, string) ([]LogFile, error)
LogContent(context.Context, string, string, *int64) (*LogContent, error)
}
type ErrorRecord struct {
ID uint
CreatedAt time.Time
Form, Info, Level, RequestID, TraceID, Solution, Status string
CreatedAtRange []time.Time
}
type AuditRepo interface {
RecordOperation(context.Context, *OperationRecord) error
ListOperations(context.Context, int, int, *OperationRecord) ([]*OperationRecord, int64, error)
FindOperation(context.Context, uint) (*OperationRecord, error)
DeleteOperations(context.Context, []uint) error
RecordLogin(context.Context, *LoginLog) error
ListLogins(context.Context, int, int, *LoginLog) ([]*LoginLog, int64, error)
FindLogin(context.Context, uint) (*LoginLog, error)
DeleteLogins(context.Context, []uint) error
RecordDataAccess(context.Context, *DataAccessLog) error
ListDataAccess(context.Context, int, int, *DataAccessLog) ([]*DataAccessLog, int64, error)
DeleteDataAccess(context.Context, []uint) error
LogDates(context.Context, string) ([]LogDate, error)
LogFiles(context.Context, string) ([]LogFile, error)
LogContent(context.Context, string, string, *int64) (*LogContent, error)
type ErrorRecordRepo interface {
CreateError(context.Context, *ErrorRecord) error
UpdateError(context.Context, *ErrorRecord) error
DeleteErrors(context.Context, []uint) error
FindError(context.Context, uint) (*ErrorRecord, error)
ListErrors(context.Context, int, int, *ErrorRecord) ([]*ErrorRecord, int64, error)
}
type AuditUsecase struct{ repo AuditRepo }
func NewAuditUsecase(repo AuditRepo) *AuditUsecase { return &AuditUsecase{repo: repo} }
func (uc *AuditUsecase) Repo() AuditRepo { return uc.repo }
type AuditRepo interface {
OperationLogRepo
LoginLogRepo
DataAccessLogRepo
LogFileRepo
ErrorRecordRepo
}
type AuditUsecase struct{ AuditRepo }
func NewAuditUsecase(repo AuditRepo) *AuditUsecase { return &AuditUsecase{AuditRepo: repo} }
+57 -20
View File
@@ -5,19 +5,6 @@ import (
"time"
)
type Dictionary struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
Type string
Status bool
Desc string
ParentID *uint
Children []*Dictionary
Details []*DictionaryDetail
}
type DictionaryDetail struct {
ID uint
CreatedAt time.Time
@@ -34,16 +21,66 @@ type DictionaryDetail struct {
Children []*DictionaryDetail
}
type DictionaryRepo interface {
CreateDictionary(context.Context, *Dictionary) error
UpdateDictionary(context.Context, *Dictionary) error
DeleteDictionary(context.Context, uint) error
FindDictionary(context.Context, uint, string, bool) (*Dictionary, error)
ListDictionaries(context.Context, int, int, string, string, bool) ([]*Dictionary, int64, error)
type DictionaryDetailFilter struct {
DictionaryID uint
Label string
Value string
Status *bool
ParentID *uint
Level *int
}
type DictionaryDetailRepo interface {
CreateDictionaryDetail(context.Context, *DictionaryDetail) error
UpdateDictionaryDetail(context.Context, *DictionaryDetail) error
DeleteDictionaryDetail(context.Context, uint) error
FindDictionaryDetail(context.Context, uint) (*DictionaryDetail, error)
ListDictionaryDetails(context.Context, int, int, uint, string, string) ([]*DictionaryDetail, int64, error)
ListDictionaryDetails(context.Context, int, int, DictionaryDetailFilter) ([]*DictionaryDetail, int64, error)
DictionaryDetailTree(context.Context, uint, string) ([]*DictionaryDetail, error)
}
type Dictionary struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
Type string
Status bool
Desc string
ParentID *uint
Children []*Dictionary
Details []*DictionaryDetail
}
type DictionaryRepo interface {
DictionaryMetadataRepo
DictionaryDetailRepo
}
type DictionaryMetadataRepo interface {
CreateDictionary(context.Context, *Dictionary) error
ImportDictionary(context.Context, *Dictionary, []*DictionaryDetail) error
UpdateDictionary(context.Context, *Dictionary) error
DeleteDictionary(context.Context, uint) error
FindDictionary(context.Context, uint, string, *bool, bool) (*Dictionary, error)
ExportDictionary(context.Context, uint) (*Dictionary, error)
ListDictionaries(context.Context, int, int, string, string, bool) ([]*Dictionary, int64, error)
}
func (uc *SettingsUsecase) ImportDictionary(ctx context.Context, dictionary *Dictionary, details []*DictionaryDetail) error {
return uc.SettingsRepo.ImportDictionary(ctx, dictionary, details)
}
func (uc *SettingsUsecase) DictionaryDetailsByParent(ctx context.Context, dictionaryID, parentID uint) ([]*DictionaryDetail, error) {
items, _, err := uc.ListDictionaryDetails(ctx, 0, 0, DictionaryDetailFilter{DictionaryID: dictionaryID})
if err != nil {
return nil, err
}
out := make([]*DictionaryDetail, 0)
for _, item := range items {
if (parentID == 0 && item.ParentID == nil) || (item.ParentID != nil && *item.ParentID == parentID) {
out = append(out, item)
}
}
return out, nil
}
+1
View File
@@ -9,4 +9,5 @@ var (
// ErrInvalidCredentials is returned for any failed login, regardless of
// whether the user exists, to avoid leaking which accounts are registered.
ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials")
ErrUserDisabled = errors.Forbidden("USER_DISABLED", "user disabled")
)
+5 -3
View File
@@ -16,6 +16,8 @@ type ExportTemplate struct {
Order string
Conditions []ExportCondition
Joins []ExportJoin
StartCreatedAt *time.Time
EndCreatedAt *time.Time
}
type ExportRepo interface {
CreateExportTemplate(context.Context, *ExportTemplate) error
@@ -24,9 +26,9 @@ type ExportRepo interface {
FindExportTemplate(context.Context, uint, string) (*ExportTemplate, error)
ListExportTemplates(context.Context, int, int, *ExportTemplate) ([]*ExportTemplate, int64, error)
QueryExport(context.Context, *ExportTemplate, map[string]string) ([]map[string]any, string, error)
PreviewExport(context.Context, *ExportTemplate, map[string]string) (string, error)
ImportExportRows(context.Context, *ExportTemplate, []map[string]any) error
}
type ExportUsecase struct{ repo ExportRepo }
type ExportUsecase struct{ ExportRepo }
func NewExportUsecase(repo ExportRepo) *ExportUsecase { return &ExportUsecase{repo: repo} }
func (uc *ExportUsecase) Repo() ExportRepo { return uc.repo }
func NewExportUsecase(repo ExportRepo) *ExportUsecase { return &ExportUsecase{ExportRepo: repo} }
+18 -45
View File
@@ -13,62 +13,35 @@ import (
"github.com/google/uuid"
)
type MediaFile struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
CategoryID int
URL, Tag, Key string
Size int64
Mime, MD5 string
UserID uint
}
type AttachmentCategory struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
ParentID uint
Children []*AttachmentCategory
}
type MediaMetadataRepo interface {
CreateMedia(context.Context, *MediaFile) error
FindMedia(context.Context, uint) (*MediaFile, error)
FindMediaByHash(context.Context, uint, string) (*MediaFile, error)
ListMedia(context.Context, int, int, string, int, string, uint) ([]*MediaFile, int64, error)
UpdateMediaName(context.Context, uint, string) error
DeleteMedia(context.Context, uint) error
MediaKeyReferences(context.Context, string) (int64, error)
CreateMediaBatch(context.Context, []*MediaFile) error
SaveCategory(context.Context, *AttachmentCategory) error
DeleteCategory(context.Context, uint) error
ListCategories(context.Context) ([]*AttachmentCategory, error)
}
type MediaRepo interface {
MediaMetadataRepo
UploadRepo
}
type MediaUsecase struct {
repo MediaRepo
MediaRepo
files FileStorage
}
func NewMediaUsecase(repo MediaRepo, files FileStorage) *MediaUsecase {
return &MediaUsecase{repo: repo, files: files}
return &MediaUsecase{MediaRepo: repo, files: files}
}
func (uc *MediaUsecase) Repo() MediaRepo { return uc.repo }
var blockedExtensions = map[string]bool{".exe": true, ".dll": true, ".bat": true, ".cmd": true, ".sh": true, ".php": true, ".jsp": true, ".asp": true, ".aspx": true, ".html": true, ".htm": true, ".svg": true}
var allowedMediaExtensions = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".bmp": true, ".ico": true, ".avif": true,
".mp3": true, ".wav": true, ".ogg": true, ".m4a": true, ".flac": true, ".aac": true,
".mp4": true, ".webm": true, ".mov": true, ".avi": true, ".mkv": true,
".txt": true, ".md": true, ".csv": true, ".json": true, ".log": true, ".pdf": true,
".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true,
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true, ".tgz": true, ".bin": true,
}
func validateMediaName(name string) error {
if name == "" || filepath.Base(name) != name {
return errors.New("文件名不合法")
if name == "" || strings.TrimSpace(name) != name || strings.ContainsAny(name, `/\`) {
return errors.New("file extension is not allowed")
}
if blockedExtensions[strings.ToLower(filepath.Ext(name))] {
return errors.New("文件类型不允许上传")
if !allowedMediaExtensions[strings.ToLower(filepath.Ext(name))] {
return errors.New("file extension is not allowed")
}
return nil
}
@@ -85,7 +58,7 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime stri
}
media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, MD5: hex.EncodeToString(hash.Sum(nil)), UserID: userID}
if save {
if err = uc.repo.CreateMedia(ctx, media); err != nil {
if err = uc.CreateMedia(ctx, media); err != nil {
_ = uc.files.Delete(ctx, key)
return nil, err
}
@@ -93,11 +66,11 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime stri
return media, nil
}
func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error {
media, err := uc.repo.FindMedia(ctx, id)
media, err := uc.FindMedia(ctx, id)
if err != nil {
return err
}
count, err := uc.repo.MediaKeyReferences(ctx, media.Key)
count, err := uc.MediaKeyReferences(ctx, media.Key)
if err != nil {
return err
}
@@ -106,7 +79,7 @@ func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error {
return err
}
}
return uc.repo.DeleteMedia(ctx, id)
return uc.DeleteMedia(ctx, id)
}
func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, limit int) ([]*StoredFile, string, bool, error) {
return uc.files.List(ctx, prefix, cursor, limit)
+53
View File
@@ -0,0 +1,53 @@
package biz
import (
"context"
"time"
)
type MediaFile struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
CategoryID int
URL, Tag, Key string
Size int64
Mime, MD5 string
UserID uint
}
type MediaFilter struct {
Page, PageSize int
Keyword string
CategoryID int
Tag string
UserID uint
StartCreatedAt time.Time
EndCreatedAt time.Time
OrderKey string
Desc bool
}
type AttachmentCategory struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
ParentID uint
Children []*AttachmentCategory
}
type MediaMetadataRepo interface {
CreateMedia(context.Context, *MediaFile) error
FindMedia(context.Context, uint) (*MediaFile, error)
FindMediaByHash(context.Context, uint, string) (*MediaFile, error)
ListMedia(context.Context, MediaFilter) ([]*MediaFile, int64, error)
UpdateMediaName(context.Context, uint, string) error
DeleteMedia(context.Context, uint) error
MediaKeyReferences(context.Context, string) (int64, error)
CreateMediaBatch(context.Context, []*MediaFile) error
SaveCategory(context.Context, *AttachmentCategory) error
DeleteCategory(context.Context, uint) error
ListCategories(context.Context) ([]*AttachmentCategory, error)
}
+17 -44
View File
@@ -15,33 +15,6 @@ import (
"github.com/google/uuid"
)
type UploadSession struct {
ID uint
UserID uint
FileName, FileHash string
FileSize, ChunkSize int64
ChunkTotal int
Status, StorageKey string
MediaID uint
}
type UploadChunk struct {
Index int
Hash string
Size int64
}
type UploadRepo interface {
FindUploadingSession(context.Context, uint, string) (*UploadSession, error)
CreateUploadSession(context.Context, *UploadSession) error
FindUploadSession(context.Context, uint) (*UploadSession, error)
CompleteUploadSession(context.Context, uint, string, uint) error
DeleteUploadSession(context.Context, uint) error
UpsertChunk(context.Context, uint, *UploadChunk) error
ListChunks(context.Context, uint) ([]*UploadChunk, error)
DeleteChunks(context.Context, uint) error
StaleUploadSessionIDs(context.Context, time.Time) ([]uint, error)
DeleteUploadData(context.Context, uint) error
}
func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash string, size, chunkSize int64, total int) (*UploadSession, *MediaFile, []int, error) {
if err := validateMediaName(name); err != nil {
return nil, nil, nil, err
@@ -49,22 +22,22 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash
if size <= 0 || chunkSize <= 0 || total <= 0 {
return nil, nil, nil, errors.New("上传参数不合法")
}
if media, err := uc.repo.FindMediaByHash(ctx, userID, hash); err == nil {
if media, err := uc.FindMediaByHash(ctx, userID, hash); err == nil {
copy := *media
copy.ID = 0
copy.Name = name
if err = uc.repo.CreateMedia(ctx, &copy); err == nil {
if err = uc.CreateMedia(ctx, &copy); err == nil {
return nil, &copy, nil, nil
}
}
session, err := uc.repo.FindUploadingSession(ctx, userID, hash)
session, err := uc.FindUploadingSession(ctx, userID, hash)
if err != nil {
session = &UploadSession{UserID: userID, FileName: name, FileHash: hash, FileSize: size, ChunkSize: chunkSize, ChunkTotal: total, Status: "uploading"}
if err = uc.repo.CreateUploadSession(ctx, session); err != nil {
if err = uc.CreateUploadSession(ctx, session); err != nil {
return nil, nil, nil, err
}
}
chunks, err := uc.repo.ListChunks(ctx, session.ID)
chunks, err := uc.ListChunks(ctx, session.ID)
if err != nil {
return nil, nil, nil, err
}
@@ -76,7 +49,7 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash
return session, nil, indices, nil
}
func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, index int, expected string, reader io.Reader) error {
session, err := uc.repo.FindUploadSession(ctx, uploadID)
session, err := uc.FindUploadSession(ctx, uploadID)
if err != nil || session.UserID != userID {
return errors.New("上传会话不存在或无权操作")
}
@@ -94,14 +67,14 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in
_ = uc.files.Delete(ctx, key)
return errors.New("分片校验失败")
}
return uc.repo.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: stored.Size})
return uc.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: stored.Size})
}
func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uint, mime string) (*MediaFile, error) {
session, err := uc.repo.FindUploadSession(ctx, uploadID)
session, err := uc.FindUploadSession(ctx, uploadID)
if err != nil || session.UserID != userID {
return nil, errors.New("上传会话不存在或无权操作")
}
chunks, err := uc.repo.ListChunks(ctx, uploadID)
chunks, err := uc.ListChunks(ctx, uploadID)
if err != nil || len(chunks) != session.ChunkTotal {
return nil, errors.New("分片不完整")
}
@@ -124,34 +97,34 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin
return nil, errors.New("整文件校验失败")
}
media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(ext, "."), Key: key, Size: stored.Size, Mime: mime, MD5: hash, UserID: userID}
if err = uc.repo.CreateMedia(ctx, media); err != nil {
if err = uc.CreateMedia(ctx, media); err != nil {
_ = uc.files.Delete(ctx, key)
return nil, err
}
_ = uc.repo.CompleteUploadSession(ctx, uploadID, key, media.ID)
_ = uc.repo.DeleteChunks(ctx, uploadID)
_ = uc.CompleteUploadSession(ctx, uploadID, key, media.ID)
_ = uc.DeleteChunks(ctx, uploadID)
_ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID))
return media, nil
}
func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) error {
session, err := uc.repo.FindUploadSession(ctx, uploadID)
session, err := uc.FindUploadSession(ctx, uploadID)
if err != nil || session.UserID != userID {
return errors.New("上传会话不存在或无权操作")
}
_ = uc.repo.DeleteChunks(ctx, uploadID)
_ = uc.DeleteChunks(ctx, uploadID)
_ = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", uploadID))
return uc.repo.DeleteUploadSession(ctx, uploadID)
return uc.DeleteUploadSession(ctx, uploadID)
}
func (uc *MediaUsecase) CleanupStale(ctx context.Context, ttlHours int) error {
if ttlHours <= 0 {
ttlHours = 24
}
ids, err := uc.repo.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour))
ids, err := uc.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour))
if err != nil {
return err
}
for _, id := range ids {
if err = uc.repo.DeleteUploadData(ctx, id); err != nil {
if err = uc.DeleteUploadData(ctx, id); err != nil {
return err
}
if err = uc.files.DeletePrefix(ctx, fmt.Sprintf(".chunks/%d", id)); err != nil {
+4
View File
@@ -47,6 +47,7 @@ type MenuRepo interface {
SetAuthorityMenus(context.Context, uint, []uint) error
AuthorityMenuIDs(context.Context, uint) ([]uint, error)
MenuRoleIDs(context.Context, uint) ([]uint, error)
DefaultRouterRoleIDs(context.Context, uint) ([]uint, error)
SetMenuRoles(context.Context, uint, []uint) error
}
@@ -128,6 +129,9 @@ func (uc *MenuUsecase) AuthorityMenus(ctx context.Context, authorityID uint) ([]
func (uc *MenuUsecase) RoleIDs(ctx context.Context, menuID uint) ([]uint, error) {
return uc.repo.MenuRoleIDs(ctx, menuID)
}
func (uc *MenuUsecase) DefaultRouterRoleIDs(ctx context.Context, menuID uint) ([]uint, error) {
return uc.repo.DefaultRouterRoleIDs(ctx, menuID)
}
func (uc *MenuUsecase) SetRoles(ctx context.Context, menuID uint, ids []uint) error {
return uc.repo.SetMenuRoles(ctx, menuID, ids)
+40 -59
View File
@@ -14,8 +14,28 @@ type Department struct {
Ancestors string
Sort int
LeaderID uint
Leader *User
Status bool
Children []*Department
NamePath string
}
type DepartmentRepo interface {
CreateDepartment(context.Context, *Department) error
UpdateDepartment(context.Context, *Department) error
DeleteDepartment(context.Context, uint) error
FindDepartment(context.Context, uint) (*Department, error)
ListDepartments(context.Context, string) ([]*Department, error)
DepartmentUserIDs(context.Context, uint) ([]uint, error)
SetDepartmentUsers(context.Context, uint, []uint) error
SetUserDepartments(context.Context, uint, []uint, uint) error
}
func (uc *OrganizationUsecase) Departments(ctx context.Context, name string) ([]*Department, error) {
return uc.ListDepartments(ctx, name)
}
func (uc *OrganizationUsecase) Department(ctx context.Context, id uint) (*Department, error) {
return uc.FindDepartment(ctx, id)
}
type Position struct {
@@ -29,76 +49,37 @@ type Position struct {
Remark string
}
type OrganizationRepo interface {
CreateDepartment(context.Context, *Department) error
UpdateDepartment(context.Context, *Department) error
DeleteDepartment(context.Context, uint) error
FindDepartment(context.Context, uint) (*Department, error)
ListDepartments(context.Context, string) ([]*Department, error)
DepartmentUserIDs(context.Context, uint) ([]uint, error)
SetDepartmentUsers(context.Context, uint, []uint) error
SetUserDepartments(context.Context, uint, []uint, uint) error
type PositionListFilter struct {
Name string
Code string
Status *bool
}
type PositionRepo interface {
CreatePosition(context.Context, *Position) error
UpdatePosition(context.Context, *Position) error
DeletePosition(context.Context, uint) error
FindPosition(context.Context, uint) (*Position, error)
ListPositions(context.Context, int, int, *Position) ([]*Position, int64, error)
ListPositions(context.Context, int, int, *PositionListFilter) ([]*Position, int64, error)
PositionUserIDs(context.Context, uint) ([]uint, error)
SetPositionUsers(context.Context, uint, []uint) error
SetUserPositions(context.Context, uint, []uint) error
}
type OrganizationUsecase struct{ repo OrganizationRepo }
func NewOrganizationUsecase(repo OrganizationRepo) *OrganizationUsecase {
return &OrganizationUsecase{repo: repo}
}
func (uc *OrganizationUsecase) Departments(ctx context.Context, name string) ([]*Department, error) {
return uc.repo.ListDepartments(ctx, name)
}
func (uc *OrganizationUsecase) CreateDepartment(ctx context.Context, value *Department) error {
return uc.repo.CreateDepartment(ctx, value)
}
func (uc *OrganizationUsecase) UpdateDepartment(ctx context.Context, value *Department) error {
return uc.repo.UpdateDepartment(ctx, value)
}
func (uc *OrganizationUsecase) DeleteDepartment(ctx context.Context, id uint) error {
return uc.repo.DeleteDepartment(ctx, id)
}
func (uc *OrganizationUsecase) Department(ctx context.Context, id uint) (*Department, error) {
return uc.repo.FindDepartment(ctx, id)
}
func (uc *OrganizationUsecase) DepartmentUserIDs(ctx context.Context, id uint) ([]uint, error) {
return uc.repo.DepartmentUserIDs(ctx, id)
}
func (uc *OrganizationUsecase) SetDepartmentUsers(ctx context.Context, id uint, ids []uint) error {
return uc.repo.SetDepartmentUsers(ctx, id, ids)
}
func (uc *OrganizationUsecase) SetUserDepartments(ctx context.Context, id uint, ids []uint, primary uint) error {
return uc.repo.SetUserDepartments(ctx, id, ids, primary)
}
func (uc *OrganizationUsecase) Positions(ctx context.Context, page, size int, filter *Position) ([]*Position, int64, error) {
return uc.repo.ListPositions(ctx, page, size, filter)
}
func (uc *OrganizationUsecase) CreatePosition(ctx context.Context, value *Position) error {
return uc.repo.CreatePosition(ctx, value)
}
func (uc *OrganizationUsecase) UpdatePosition(ctx context.Context, value *Position) error {
return uc.repo.UpdatePosition(ctx, value)
}
func (uc *OrganizationUsecase) DeletePosition(ctx context.Context, id uint) error {
return uc.repo.DeletePosition(ctx, id)
func (uc *OrganizationUsecase) Positions(ctx context.Context, page, size int, filter *PositionListFilter) ([]*Position, int64, error) {
return uc.ListPositions(ctx, page, size, filter)
}
func (uc *OrganizationUsecase) Position(ctx context.Context, id uint) (*Position, error) {
return uc.repo.FindPosition(ctx, id)
return uc.FindPosition(ctx, id)
}
func (uc *OrganizationUsecase) PositionUserIDs(ctx context.Context, id uint) ([]uint, error) {
return uc.repo.PositionUserIDs(ctx, id)
type OrganizationRepo interface {
DepartmentRepo
PositionRepo
}
func (uc *OrganizationUsecase) SetPositionUsers(ctx context.Context, id uint, ids []uint) error {
return uc.repo.SetPositionUsers(ctx, id, ids)
}
func (uc *OrganizationUsecase) SetUserPositions(ctx context.Context, id uint, ids []uint) error {
return uc.repo.SetUserPositions(ctx, id, ids)
type OrganizationUsecase struct{ OrganizationRepo }
func NewOrganizationUsecase(repo OrganizationRepo) *OrganizationUsecase {
return &OrganizationUsecase{OrganizationRepo: repo}
}
+9 -7
View File
@@ -6,13 +6,15 @@ import (
)
type SystemParameter struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
Key string
Value string
Desc string
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
Key string
Value string
Desc string
StartCreatedAt *time.Time
EndCreatedAt *time.Time
}
type ParameterRepo interface {
+43 -1
View File
@@ -1,6 +1,9 @@
package biz
import "context"
import (
"context"
"errors"
)
type SecurityConfig struct {
ID uint
@@ -29,3 +32,42 @@ type SecurityRepo interface {
SecurityConfig(context.Context) (*SecurityConfig, error)
SaveSecurityConfig(context.Context, *SecurityConfig) error
}
func (uc *SettingsUsecase) UpdateSecurity(ctx context.Context, value *SecurityConfig) error {
if value.KeyLong < 1 || value.ImgWidth < 1 || value.ImgHeight < 1 || value.PwdMinLength < 1 || value.LimitWindow < 1 || value.LimitCount < 1 || value.LockThreshold < 1 || value.LockDuration < 1 || value.PwdExpireDays < 1 {
return errors.New("安全配置数值必须大于0")
}
return uc.SaveSecurityConfig(ctx, value)
}
func (uc *SettingsUsecase) ValidatePassword(value *SecurityConfig, password string) error {
if len([]rune(password)) < value.PwdMinLength {
return errors.New("密码长度不足")
}
hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false
for _, ch := range password {
switch {
case ch >= 'A' && ch <= 'Z':
hasUpper = true
case ch >= 'a' && ch <= 'z':
hasLower = true
case ch >= '0' && ch <= '9':
hasDigit = true
default:
hasSpecial = true
}
}
if value.PwdRequireUpper && !hasUpper {
return errors.New("密码必须包含大写字母")
}
if value.PwdRequireLower && !hasLower {
return errors.New("密码必须包含小写字母")
}
if value.PwdRequireDigit && !hasDigit {
return errors.New("密码必须包含数字")
}
if value.PwdRequireSpecial && !hasSpecial {
return errors.New("密码必须包含特殊字符")
}
return nil
}
+4 -3
View File
@@ -7,7 +7,8 @@ type SettingsRepo interface {
SecurityRepo
}
type SettingsUsecase struct{ repo SettingsRepo }
type SettingsUsecase struct{ SettingsRepo }
func NewSettingsUsecase(repo SettingsRepo) *SettingsUsecase { return &SettingsUsecase{repo: repo} }
func (uc *SettingsUsecase) Repo() SettingsRepo { return uc.repo }
func NewSettingsUsecase(repo SettingsRepo) *SettingsUsecase {
return &SettingsUsecase{SettingsRepo: repo}
}
+4
View File
@@ -12,6 +12,7 @@ type InitializationRepo interface {
Initialize(context.Context, *DatabaseConfig) error
PersistConfig(context.Context) error
PersistAdminConfig(context.Context, []byte) error
PersistRuntimeConfig(context.Context, []byte, []byte) error
ReloadConfig(context.Context) error
}
@@ -28,4 +29,7 @@ func (uc *SystemUsecase) PersistConfig(ctx context.Context) error { return uc.re
func (uc *SystemUsecase) PersistAdminConfig(ctx context.Context, value []byte) error {
return uc.repo.PersistAdminConfig(ctx, value)
}
func (uc *SystemUsecase) PersistRuntimeConfig(ctx context.Context, data, admin []byte) error {
return uc.repo.PersistRuntimeConfig(ctx, data, admin)
}
func (uc *SystemUsecase) ReloadConfig(ctx context.Context) error { return uc.repo.ReloadConfig(ctx) }
+108 -3
View File
@@ -2,7 +2,13 @@ package biz
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"time"
"github.com/robfig/cron/v3"
)
type TimedTask struct {
@@ -28,6 +34,37 @@ type TimedTaskLog struct {
DurationMS int64
Status, ErrorMsg, Output string
}
const (
TaskExecutorMethod = "method"
TaskExecutorHTTP = "http"
TaskMethodClearDB = "ClearDB"
TaskMethodUploads = "CleanStaleUploads"
)
type TaskMethod struct {
Name string
Description string
}
var registeredTaskMethods = []TaskMethod{
{Name: TaskMethodClearDB, Description: "清理数据库过期日志(操作记录、JWT黑名单、定时任务日志)"},
{Name: TaskMethodUploads, Description: "清理过期大文件上传会话"},
}
func RegisteredTaskMethods() []TaskMethod {
return append([]TaskMethod(nil), registeredTaskMethods...)
}
func registeredTaskMethod(name string) bool {
for _, method := range registeredTaskMethods {
if method.Name == name {
return true
}
}
return false
}
type TaskRepo interface {
CreateTask(context.Context, *TimedTask) error
UpdateTask(context.Context, *TimedTask) error
@@ -40,7 +77,75 @@ type TaskRepo interface {
CleanupLogs(context.Context) error
TaskNameExists(context.Context, string, uint) (bool, error)
}
type TaskUsecase struct{ repo TaskRepo }
type TaskUsecase struct{ TaskRepo }
func NewTaskUsecase(repo TaskRepo) *TaskUsecase { return &TaskUsecase{repo: repo} }
func (uc *TaskUsecase) Repo() TaskRepo { return uc.repo }
func NewTaskUsecase(repo TaskRepo) *TaskUsecase { return &TaskUsecase{TaskRepo: repo} }
func (uc *TaskUsecase) Validate(value *TimedTask) error {
if value.Name == "" {
return errors.New("任务名不能为空")
}
var err error
if value.WithSeconds {
_, err = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor).Parse(value.Spec)
} else {
_, err = cron.ParseStandard(value.Spec)
}
if err != nil {
return fmt.Errorf("cron 表达式非法: %w", err)
}
switch value.ExecutorType {
case TaskExecutorMethod:
if !registeredTaskMethod(value.MethodName) {
return errors.New("方法未注册")
}
if len(value.Params) > 0 && !json.Valid(value.Params) {
return errors.New("params 必须是合法 JSON")
}
case TaskExecutorHTTP:
parsed, parseErr := url.Parse(value.HTTPURL)
if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
return errors.New("httpUrl 必须是合法的 http/https 地址")
}
if len(value.HTTPHeader) > 0 {
headers := map[string]string{}
if json.Unmarshal(value.HTTPHeader, &headers) != nil {
return errors.New("httpHeader 必须是 JSON 对象")
}
}
default:
return errors.New("executorType 必须为 method 或 http")
}
return nil
}
func (uc *TaskUsecase) Create(ctx context.Context, value *TimedTask) error {
if err := uc.Validate(value); err != nil {
return err
}
exists, err := uc.TaskNameExists(ctx, value.Name, 0)
if err != nil {
return err
}
if exists {
return fmt.Errorf("任务名 %s 已存在", value.Name)
}
return uc.CreateTask(ctx, value)
}
func (uc *TaskUsecase) Update(ctx context.Context, value *TimedTask) error {
if value.ID == 0 {
return errors.New("缺少任务 ID")
}
if err := uc.Validate(value); err != nil {
return err
}
exists, err := uc.TaskNameExists(ctx, value.Name, value.ID)
if err != nil {
return err
}
if exists {
return fmt.Errorf("任务名 %s 已存在", value.Name)
}
return uc.UpdateTask(ctx, value)
}
+35
View File
@@ -0,0 +1,35 @@
package biz
import (
"context"
"time"
)
type UploadSession struct {
ID uint
UserID uint
FileName, FileHash string
FileSize, ChunkSize int64
ChunkTotal int
Status, StorageKey string
MediaID uint
}
type UploadChunk struct {
Index int
Hash string
Size int64
}
type UploadRepo interface {
FindUploadingSession(context.Context, uint, string) (*UploadSession, error)
CreateUploadSession(context.Context, *UploadSession) error
FindUploadSession(context.Context, uint) (*UploadSession, error)
CompleteUploadSession(context.Context, uint, string, uint) error
DeleteUploadSession(context.Context, uint) error
UpsertChunk(context.Context, uint, *UploadChunk) error
ListChunks(context.Context, uint) ([]*UploadChunk, error)
DeleteChunks(context.Context, uint) error
StaleUploadSessionIDs(context.Context, time.Time) ([]uint, error)
DeleteUploadData(context.Context, uint) error
}
+57 -5
View File
@@ -41,6 +41,7 @@ type Authority struct {
ParentID *uint
DataScope int
DefaultRouter string
Children []*Authority
}
type UserRepo interface {
@@ -49,13 +50,17 @@ type UserRepo interface {
MenusByAuthority(context.Context, uint) ([]*Menu, error)
ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error)
CreateUser(context.Context, *User) (*User, error)
CreateUserWithAuthorities(context.Context, *User, []uint) (*User, error)
UpdateUser(context.Context, *User) error
UpdateSelfUser(context.Context, *User) error
UpdateUserWithAuthorities(context.Context, *User, []uint) error
DeleteUser(context.Context, uint) error
UpdatePassword(context.Context, uint, string, bool) error
SetUserAuthorities(context.Context, uint, []uint) error
SetUserAuthority(context.Context, uint, uint) error
SetUserSetting(context.Context, uint, map[string]any) error
ListAuthorities(context.Context) ([]*Authority, error)
FillDepartmentNamePaths(context.Context, *User) error
}
func (uc *SystemUsecase) Login(ctx context.Context, username, password string) (*User, error) {
@@ -63,11 +68,37 @@ func (uc *SystemUsecase) Login(ctx context.Context, username, password string) (
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil {
return nil, ErrInvalidCredentials
}
uc.fallbackDefaultRouter(ctx, u)
return u, nil
}
func (uc *SystemUsecase) User(ctx context.Context, id uint) (*User, error) {
return uc.repo.FindUserByID(ctx, id)
user, err := uc.repo.FindUserByID(ctx, id)
if err != nil {
return nil, err
}
_ = uc.repo.FillDepartmentNamePaths(ctx, user)
uc.fallbackDefaultRouter(ctx, user)
return user, nil
}
func (uc *SystemUsecase) 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"
}
}
func menuNameExists(menus []*Menu, name string) bool {
for _, menu := range menus {
if menu.Name == name || menuNameExists(menu.Children, name) {
return true
}
}
return false
}
func (uc *SystemUsecase) Menus(ctx context.Context, authorityID uint) ([]*Menu, error) {
@@ -77,16 +108,37 @@ func (uc *SystemUsecase) Menus(ctx context.Context, authorityID uint) ([]*Menu,
func (uc *SystemUsecase) ListUsers(ctx context.Context, page, pageSize int, filter *UserListFilter) ([]*User, int64, error) {
return uc.repo.ListUsers(ctx, page, pageSize, filter)
}
func (uc *SystemUsecase) CreateUser(ctx context.Context, user *User) (*User, error) {
func (uc *SystemUsecase) 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
}
user.Password = string(hash)
return uc.repo.CreateUser(ctx, user)
return uc.repo.CreateUserWithAuthorities(ctx, user, authorityIDs)
}
func (uc *SystemUsecase) UpdateUser(ctx context.Context, user *User) error {
return uc.repo.UpdateUser(ctx, user)
func (uc *SystemUsecase) UpdateUser(ctx context.Context, user *User, authorityIDs []uint) error {
authorityIDs = includeAuthority(authorityIDs, user.AuthorityID)
return uc.repo.UpdateUserWithAuthorities(ctx, user, authorityIDs)
}
func (uc *SystemUsecase) 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 *SystemUsecase) DeleteUser(ctx context.Context, id uint) error {
return uc.repo.DeleteUser(ctx, id)
+6 -4
View File
@@ -2,9 +2,12 @@ package biz
import (
"context"
"errors"
"time"
)
var ErrInvalidVersion = errors.New("版本信息格式错误")
type Version struct {
ID uint
CreatedAt time.Time
@@ -27,11 +30,10 @@ type VersionRepo interface {
CreateVersion(context.Context, *Version) error
DeleteVersions(context.Context, []uint) error
FindVersion(context.Context, uint) (*Version, error)
ListVersions(context.Context, int, int, string, string) ([]*Version, int64, error)
ListVersions(context.Context, int, int, string, string, []*time.Time) ([]*Version, int64, error)
BuildVersionBundle(context.Context, []uint, []uint, []uint) (*VersionBundle, error)
ImportVersionBundle(context.Context, *VersionBundle) error
}
type VersionUsecase struct{ repo VersionRepo }
type VersionUsecase struct{ VersionRepo }
func NewVersionUsecase(repo VersionRepo) *VersionUsecase { return &VersionUsecase{repo: repo} }
func (uc *VersionUsecase) Repo() VersionRepo { return uc.repo }
func NewVersionUsecase(repo VersionRepo) *VersionUsecase { return &VersionUsecase{VersionRepo: repo} }