优化
This commit is contained in:
@@ -12,9 +12,6 @@ func NewAccessControlUsecase(authorities AuthorityAccessRepo, apis APIRepo) *Acc
|
||||
}
|
||||
|
||||
func (uc *AccessControlUsecase) Authorize(ctx context.Context, authorityID uint, path, method string) (bool, error) {
|
||||
if authorityID == 888 {
|
||||
return true, nil
|
||||
}
|
||||
return uc.apis.Authorize(ctx, authorityID, path, method)
|
||||
}
|
||||
|
||||
|
||||
+21
-4
@@ -2,7 +2,6 @@ package biz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -39,6 +38,17 @@ type APIUsecase struct{ APIRepo }
|
||||
|
||||
func NewAPIUsecase(repo APIRepo) *APIUsecase { return &APIUsecase{APIRepo: repo} }
|
||||
|
||||
// DeleteAPI preserves the single-delete contract used by the legacy admin:
|
||||
// the target is looked up first, so deleting a missing API returns the
|
||||
// repository's not-found error instead of silently succeeding on an empty
|
||||
// UPDATE/DELETE. Bulk deletion intentionally keeps separate semantics.
|
||||
func (uc *APIUsecase) DeleteAPI(ctx context.Context, id uint) error {
|
||||
if _, err := uc.FindAPI(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.DeleteAPIs(ctx, []uint{id})
|
||||
}
|
||||
|
||||
type APISyncDiff struct {
|
||||
Added []*API
|
||||
Deleted []*API
|
||||
@@ -54,7 +64,7 @@ func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := func(value *API) string { return strings.ToUpper(value.Method) + " " + value.Path }
|
||||
key := func(value *API) string { return value.Method + " " + value.Path }
|
||||
ignoreSet := make(map[string]bool, len(ignored))
|
||||
for _, item := range ignored {
|
||||
ignoreSet[key(item)] = true
|
||||
@@ -70,12 +80,19 @@ func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff
|
||||
storedSet[key(item)] = item
|
||||
}
|
||||
diff := &APISyncDiff{Ignored: ignored}
|
||||
for routeKey, item := range routeSet {
|
||||
added := make(map[string]bool)
|
||||
for _, item := range routes {
|
||||
routeKey := key(item)
|
||||
if ignoreSet[routeKey] || added[routeKey] {
|
||||
continue
|
||||
}
|
||||
if storedSet[routeKey] == nil {
|
||||
diff.Added = append(diff.Added, item)
|
||||
added[routeKey] = true
|
||||
}
|
||||
}
|
||||
for storedKey, item := range storedSet {
|
||||
for _, item := range stored {
|
||||
storedKey := key(item)
|
||||
if routeSet[storedKey] == nil && !ignoreSet[storedKey] {
|
||||
diff.Deleted = append(diff.Deleted, item)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ var (
|
||||
type OperationRecord struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
IP, Method, Path string
|
||||
Status int
|
||||
LatencyMS int64
|
||||
@@ -30,6 +31,7 @@ type OperationRecord struct {
|
||||
type LoginLog struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Username, IP string
|
||||
Status bool
|
||||
ErrorMessage, Agent string
|
||||
@@ -41,6 +43,7 @@ type LoginLog struct {
|
||||
type DataAccessLog struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
EventType, TargetTable, Operation string
|
||||
UserID, AuthorityID uint
|
||||
Scope int
|
||||
@@ -79,7 +82,7 @@ func NewLogViewerUsecase(repo LogFileRepo) *LogViewerUsecase {
|
||||
|
||||
type ErrorRecord struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
Form, Info, Level, RequestID, TraceID, Solution, Status string
|
||||
CreatedAtRange []time.Time
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
|
||||
}
|
||||
|
||||
uc.security.ClearLoginState(ctx, attempt.Username)
|
||||
if config != nil && config.PwdExpireEnable && user.PasswordUpdatedAt != nil && time.Since(*user.PasswordUpdatedAt) > time.Duration(config.PwdExpireDays)*24*time.Hour {
|
||||
if config != nil && config.PwdExpireEnable && config.PwdExpireDays > 0 && user.PasswordUpdatedAt != nil && time.Now().After((*user.PasswordUpdatedAt).AddDate(0, 0, config.PwdExpireDays)) {
|
||||
user.MustChangePassword = true
|
||||
}
|
||||
issued, err := uc.issuer.IssueToken(user, user.AuthorityID, user.MustChangePassword, 0)
|
||||
@@ -127,42 +127,45 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
|
||||
return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: user.MustChangePassword}, nil
|
||||
}
|
||||
|
||||
func (uc *AuthenticationUsecase) SwitchAuthority(ctx context.Context, userID, authorityID uint) (*AuthenticationResult, error) {
|
||||
if err := uc.users.SetUserAuthority(ctx, userID, authorityID); err != nil {
|
||||
// SwitchAuthority changes the current user's active role and re-signs the
|
||||
// current claims. Role switching keeps the original JWT expiry; it
|
||||
// does not treat a role switch as a fresh login or rotate the multipoint
|
||||
// session. Keeping that distinction is important to the admin UI, which
|
||||
// replaces the token returned in the response but expects its original TTL.
|
||||
func (uc *AuthenticationUsecase) SwitchAuthority(ctx context.Context, claims *AuthClaims, authorityID uint) (*AuthenticationResult, error) {
|
||||
if claims == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err := uc.users.SetUserAuthority(ctx, claims.ID, authorityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := uc.users.User(ctx, userID)
|
||||
user, err := uc.users.User(ctx, claims.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
issued, err := uc.issuer.IssueToken(user, authorityID, user.MustChangePassword, 0)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uc.security.UseMultipoint() {
|
||||
oldToken, cacheErr := uc.security.ActiveToken(ctx, user.Username)
|
||||
if cacheErr != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, cacheErr)
|
||||
}
|
||||
if cacheErr = uc.security.RotateActiveToken(ctx, user.Username, oldToken, issued.Value, issued.TTL); cacheErr != nil {
|
||||
if errors.Is(cacheErr, ErrTokenRevoke) {
|
||||
return nil, cacheErr
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrLoginState, cacheErr)
|
||||
}
|
||||
}
|
||||
return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: user.MustChangePassword}, nil
|
||||
return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: claims.MustChangePwd}, nil
|
||||
}
|
||||
|
||||
func (uc *AuthenticationUsecase) AuthenticateToken(ctx context.Context, token string) (*TokenAuthentication, error) {
|
||||
claims, err := uc.issuer.ParseToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The blacklist is checked before parsing the JWT. Besides avoiding work,
|
||||
// this makes a revoked-but-expired token report the revocation reason (and
|
||||
// not the generic expiry message), which the frontend uses to decide whether
|
||||
// to clear a session.
|
||||
disabled, err := uc.security.tokens.IsTokenDisabled(ctx, token)
|
||||
if err != nil || disabled {
|
||||
return nil, ErrTokenDisabled
|
||||
}
|
||||
claims, err := uc.issuer.ParseToken(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &TokenAuthentication{Claims: claims}
|
||||
if claims.BufferTime <= 0 || time.Until(claims.ExpiresAt) >= claims.BufferTime {
|
||||
return result, nil
|
||||
@@ -172,7 +175,10 @@ func (uc *AuthenticationUsecase) AuthenticateToken(ctx context.Context, token st
|
||||
if err != nil {
|
||||
return result, nil
|
||||
}
|
||||
if err = uc.security.RotateActiveToken(ctx, claims.Username, token, issued.Value, issued.TTL); err != nil {
|
||||
// Refreshing updates the active Redis token but does not blacklist the old JWT;
|
||||
// both tokens remain valid until their own expiry. Passing an empty old token
|
||||
// preserves that behavior while still updating the multipoint session.
|
||||
if err = uc.security.RotateActiveToken(ctx, claims.Username, "", issued.Value, issued.TTL); err != nil {
|
||||
return result, nil
|
||||
}
|
||||
result.Refreshed = issued
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package biz
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuthorityAccessRepo interface {
|
||||
CreateAuthority(context.Context, *Authority) error
|
||||
@@ -15,6 +18,21 @@ type AuthorityAccessRepo interface {
|
||||
ResolveDataScope(context.Context, uint, uint) (DataScope, error)
|
||||
}
|
||||
|
||||
// Authority is the domain representation of an administration role. Keep the
|
||||
// audit timestamps because exports and API clients use them even though the
|
||||
// administration screens do not display them.
|
||||
type Authority struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
AuthorityID uint
|
||||
AuthorityName string
|
||||
ParentID *uint
|
||||
DataScope int
|
||||
DefaultRouter string
|
||||
Children []*Authority
|
||||
}
|
||||
|
||||
type AuthorityUsecase struct{ AuthorityAccessRepo }
|
||||
|
||||
func NewAuthorityUsecase(repo AuthorityAccessRepo) *AuthorityUsecase {
|
||||
@@ -36,11 +54,11 @@ func (uc *AuthorityUsecase) AuthorityTree(ctx context.Context) ([]*Authority, er
|
||||
if item.ParentID != nil && *item.ParentID != 0 && byID[*item.ParentID] != nil {
|
||||
parent := byID[*item.ParentID]
|
||||
parent.Children = append(parent.Children, item)
|
||||
} else if item.ParentID == nil || *item.ParentID == 0 {
|
||||
} else if item.ParentID != nil && *item.ParentID == 0 {
|
||||
roots = append(roots, item)
|
||||
}
|
||||
}
|
||||
// In GVA strict-authority mode a non-root actor receives its direct
|
||||
// In strict-authority mode a non-root actor receives its direct
|
||||
// children as the top-level result even though their parent is omitted.
|
||||
if len(roots) == 0 {
|
||||
if actor, ok := ActorFromContext(ctx); ok {
|
||||
|
||||
@@ -12,7 +12,7 @@ type DictionaryDetail struct {
|
||||
Label string
|
||||
Value string
|
||||
Extend string
|
||||
Status bool
|
||||
Status *bool
|
||||
Sort int
|
||||
DictionaryID uint
|
||||
ParentID *uint
|
||||
@@ -45,7 +45,7 @@ type Dictionary struct {
|
||||
UpdatedAt time.Time
|
||||
Name string
|
||||
Type string
|
||||
Status bool
|
||||
Status *bool
|
||||
Desc string
|
||||
ParentID *uint
|
||||
Children []*Dictionary
|
||||
|
||||
+11
-3
@@ -32,9 +32,17 @@ func (uc *EmailUsecase) Alert(ctx context.Context, subject, body string) error {
|
||||
if !uc.repo.Enabled() {
|
||||
return nil
|
||||
}
|
||||
recipients := uc.repo.DefaultRecipients()
|
||||
if len(recipients) == 0 {
|
||||
return nil
|
||||
// ErrorToEmail splits the configured comma-separated recipient
|
||||
// string, while its explicit test endpoint deliberately sends the whole
|
||||
// value as one recipient. Keep those two contracts separate.
|
||||
configured := uc.repo.DefaultRecipients()
|
||||
recipients := make([]string, 0, len(configured))
|
||||
for _, value := range configured {
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) > 1 && parts[len(parts)-1] == "" {
|
||||
parts = parts[:len(parts)-1]
|
||||
}
|
||||
recipients = append(recipients, parts...)
|
||||
}
|
||||
return uc.repo.Send(ctx, recipients, subject, body)
|
||||
}
|
||||
|
||||
+13
-2
@@ -5,8 +5,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ExportCondition struct{ From, Column, Operator string }
|
||||
type ExportJoin struct{ Join, Table, On string }
|
||||
type ExportCondition struct {
|
||||
ID uint
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
TemplateID string
|
||||
From, Column string
|
||||
Operator string
|
||||
}
|
||||
type ExportJoin struct {
|
||||
ID uint
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
TemplateID string
|
||||
Join, Table, On string
|
||||
}
|
||||
type ExportTemplate struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
|
||||
+73
-5
@@ -48,14 +48,25 @@ func validateMediaName(name string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime string, categoryID int, reader io.Reader, save bool) (*MediaFile, error) {
|
||||
func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedMIME string, categoryID int, reader io.Reader, save bool) (*MediaFile, error) {
|
||||
if err := validateMediaName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
buffered := bufio.NewReader(reader)
|
||||
if header, _ := buffered.Peek(512); len(header) > 0 {
|
||||
mime = http.DetectContentType(header)
|
||||
// Keep the multipart Content-Type when the client supplied one.
|
||||
// Only infer it when the header is empty: first by extension, then by
|
||||
// sniffing the first 512 bytes. Overwriting a valid client MIME with
|
||||
// http.DetectContentType changes the media record for common uploads
|
||||
// (for example SVG/JSON), and diverges from the compatible DetectMIME helper.
|
||||
if strings.TrimSpace(suppliedMIME) == "" {
|
||||
if detected := mediaMIMEByExtension(ext); detected != "" {
|
||||
suppliedMIME = detected
|
||||
} else if header, _ := buffered.Peek(512); len(header) > 0 {
|
||||
suppliedMIME = http.DetectContentType(header)
|
||||
} else {
|
||||
suppliedMIME = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext
|
||||
hash := md5.New()
|
||||
@@ -63,7 +74,7 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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}
|
||||
media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(name), "."), Key: key, Size: stored.Size, Mime: suppliedMIME, MD5: hex.EncodeToString(hash.Sum(nil)), UserID: userID}
|
||||
if save {
|
||||
if err = uc.CreateMedia(ctx, media); err != nil {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
@@ -72,6 +83,30 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, mime stri
|
||||
}
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// mediaMIMEByExtension mirrors the small extension fallback table. It is
|
||||
// intentionally narrower than mime.TypeByExtension: all other types are sniffed.
|
||||
// types from the first 512 bytes.
|
||||
func mediaMIMEByExtension(ext string) string {
|
||||
switch ext {
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".xml":
|
||||
return "application/xml"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".csv":
|
||||
return "text/csv"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error {
|
||||
media, err := uc.FindMedia(ctx, id)
|
||||
if err != nil {
|
||||
@@ -89,5 +124,38 @@ func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error {
|
||||
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)
|
||||
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"
|
||||
if uc.settings != nil {
|
||||
if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" {
|
||||
chunkRoot = configured
|
||||
}
|
||||
}
|
||||
chunkRoot = strings.Trim(chunkRoot, "/") + "/"
|
||||
result := make([]*StoredFile, 0, limit)
|
||||
nextCursor := cursor
|
||||
for {
|
||||
items, next, more, err := uc.files.List(ctx, prefix, nextCursor, limit)
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item == nil || strings.HasPrefix(strings.TrimPrefix(item.Path, "/"), chunkRoot) {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
if len(result) == limit {
|
||||
return result, next, more, nil
|
||||
}
|
||||
}
|
||||
if !more || next == "" || next == nextCursor {
|
||||
return result, next, more, nil
|
||||
}
|
||||
nextCursor = next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func (uc *MediaUsecase) chunkPrefix(uploadID uint) string {
|
||||
directory := ".chunks"
|
||||
directory := "uploads/chunks"
|
||||
if uc.settings != nil {
|
||||
if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" {
|
||||
directory = configured
|
||||
@@ -34,9 +35,6 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash
|
||||
if err := validateMediaName(name); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if size <= 0 || chunkSize <= 0 || total <= 0 {
|
||||
return nil, nil, nil, errors.New("上传参数不合法")
|
||||
}
|
||||
if completed, err := uc.FindCompletedSession(ctx, userID, hash); err == nil && completed.MediaID != 0 {
|
||||
if media, findErr := uc.FindMedia(ctx, completed.MediaID); findErr == nil {
|
||||
copy := &MediaFile{Name: name, URL: media.URL, Tag: media.Tag, Key: media.Key}
|
||||
@@ -65,29 +63,50 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash
|
||||
}
|
||||
func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, index int, expected string, reader io.Reader) error {
|
||||
session, err := uc.FindUploadSession(ctx, uploadID)
|
||||
if err != nil || session.UserID != userID {
|
||||
return errors.New("上传会话不存在或无权操作")
|
||||
if err != nil {
|
||||
return errors.New("上传会话不存在")
|
||||
}
|
||||
if session.UserID != userID {
|
||||
return errors.New("无权操作该上传")
|
||||
}
|
||||
if session.Status != "uploading" {
|
||||
return errors.New("上传会话状态不允许收片")
|
||||
}
|
||||
hash := md5.New()
|
||||
key := uc.chunkKey(uploadID, index)
|
||||
stored, err := uc.files.Put(ctx, key, io.TeeReader(reader, hash))
|
||||
temporary, err := os.CreateTemp("", "kra-upload-chunk-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryName := temporary.Name()
|
||||
defer os.Remove(temporaryName)
|
||||
defer temporary.Close()
|
||||
if _, err = io.Copy(io.MultiWriter(temporary, hash), reader); err != nil {
|
||||
return err
|
||||
}
|
||||
actual := hex.EncodeToString(hash.Sum(nil))
|
||||
if actual != expected {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
return fmt.Errorf("分片 %d 校验失败", index)
|
||||
}
|
||||
if _, err = temporary.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
stored, err := uc.files.Put(ctx, key, temporary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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.FindUploadSession(ctx, uploadID)
|
||||
if err != nil || session.UserID != userID {
|
||||
return nil, errors.New("上传会话不存在或无权操作")
|
||||
if err != nil {
|
||||
return nil, errors.New("上传会话不存在")
|
||||
}
|
||||
if session.UserID != userID {
|
||||
return nil, errors.New("无权操作该上传")
|
||||
}
|
||||
if err = validateMediaName(session.FileName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claimed, err := uc.ClaimUploadSession(ctx, uploadID)
|
||||
if err != nil {
|
||||
@@ -122,7 +141,10 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
return fail(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}
|
||||
// Keep the size declared when the upload session was created. The full-file
|
||||
// MD5 above remains the integrity check even if storage reports another
|
||||
// byte count.
|
||||
media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(session.FileName), "."), Key: key, Size: session.FileSize, Mime: mime, MD5: hash, UserID: userID}
|
||||
if err = uc.CreateMedia(ctx, media); err != nil {
|
||||
_ = uc.files.Delete(ctx, key)
|
||||
return fail(err)
|
||||
@@ -134,8 +156,11 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin
|
||||
}
|
||||
func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) error {
|
||||
session, err := uc.FindUploadSession(ctx, uploadID)
|
||||
if err != nil || session.UserID != userID {
|
||||
return errors.New("上传会话不存在或无权操作")
|
||||
if err != nil {
|
||||
return errors.New("上传会话不存在")
|
||||
}
|
||||
if session.UserID != userID {
|
||||
return errors.New("无权操作该上传")
|
||||
}
|
||||
_ = uc.DeleteChunks(ctx, uploadID)
|
||||
_ = uc.files.DeletePrefix(ctx, uc.chunkPrefix(uploadID))
|
||||
|
||||
+23
-8
@@ -1,8 +1,14 @@
|
||||
package biz
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MenuButton struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
@@ -11,15 +17,22 @@ type MenuButton struct {
|
||||
}
|
||||
|
||||
type MenuParameter struct {
|
||||
ID uint
|
||||
MenuID uint
|
||||
Type string
|
||||
Key string
|
||||
Value string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
ID uint
|
||||
MenuID uint
|
||||
Type string
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
type Menu struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
ID uint
|
||||
MenuID uint
|
||||
ParentID uint
|
||||
Path string
|
||||
Name string
|
||||
@@ -73,7 +86,7 @@ func (uc *MenuUsecase) Tree(ctx context.Context) ([]*Menu, error) {
|
||||
for _, item := range items {
|
||||
if parent := byID[item.ParentID]; parent != nil {
|
||||
parent.Children = append(parent.Children, item)
|
||||
} else {
|
||||
} else if item.ParentID == 0 {
|
||||
roots = append(roots, item)
|
||||
}
|
||||
}
|
||||
@@ -118,8 +131,10 @@ func (uc *MenuUsecase) AuthorityMenus(ctx context.Context, authorityID uint) ([]
|
||||
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.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
type SecurityConfig struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CaptchaOpen int
|
||||
CaptchaTimeout int
|
||||
KeyLong int
|
||||
@@ -128,21 +130,21 @@ func (uc *SecurityUsecase) ValidateCurrentPassword(ctx context.Context, password
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) LoginLocked(ctx context.Context, username string) (bool, error) {
|
||||
_, locked, err := uc.cache.Get(ctx, "login:lock:"+username)
|
||||
_, locked, err := uc.cache.Get(ctx, loginLockKey(username))
|
||||
return locked, err
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) IncrementLoginFailure(ctx context.Context, username string, expiration time.Duration) (int64, error) {
|
||||
return uc.cache.Increment(ctx, "login:fail:"+username, expiration)
|
||||
return uc.cache.Increment(ctx, loginFailureKey(username), expiration)
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) LockLogin(ctx context.Context, username string, expiration time.Duration) error {
|
||||
return uc.cache.Set(ctx, "login:lock:"+username, "1", expiration)
|
||||
return uc.cache.Set(ctx, loginLockKey(username), "1", expiration)
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) ClearLoginState(ctx context.Context, username string) {
|
||||
_ = uc.cache.Delete(ctx, "login:fail:"+username)
|
||||
_ = uc.cache.Delete(ctx, "login:lock:"+username)
|
||||
_ = uc.cache.Delete(ctx, loginFailureKey(username))
|
||||
_ = uc.cache.Delete(ctx, loginLockKey(username))
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) EnsureLoginIPCounter(ctx context.Context, ip string, expiration time.Duration) (int, error) {
|
||||
@@ -171,15 +173,15 @@ func (uc *SecurityUsecase) IncrementRateLimit(ctx context.Context, key string, e
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) SetCaptcha(ctx context.Context, id, value string, expiration time.Duration) error {
|
||||
return uc.cache.Set(ctx, "captcha:"+id, value, expiration)
|
||||
return uc.cache.Set(ctx, "CAPTCHA_"+id, value, expiration)
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) GetCaptcha(ctx context.Context, id string) (string, bool, error) {
|
||||
return uc.cache.Get(ctx, "captcha:"+id)
|
||||
return uc.cache.Get(ctx, "CAPTCHA_"+id)
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) DeleteCaptcha(ctx context.Context, id string) error {
|
||||
return uc.cache.Delete(ctx, "captcha:"+id)
|
||||
return uc.cache.Delete(ctx, "CAPTCHA_"+id)
|
||||
}
|
||||
|
||||
func (uc *SecurityUsecase) VerifyCaptcha(ctx context.Context, id, answer string, clear bool) bool {
|
||||
@@ -196,7 +198,13 @@ func (uc *SecurityUsecase) VerifyCaptcha(ctx context.Context, id, answer string,
|
||||
return strings.EqualFold(value, answer)
|
||||
}
|
||||
|
||||
func activeTokenKey(username string) string { return "jwt:active:" + username }
|
||||
// The currently active multipoint token is stored under the username itself.
|
||||
// Keep the same key so a rolling deployment can revoke sessions issued by the
|
||||
// other implementation instead of silently creating a second active session.
|
||||
func activeTokenKey(username string) string { return username }
|
||||
|
||||
func loginFailureKey(username string) string { return "login_fail:" + username }
|
||||
func loginLockKey(username string) string { return "login_lock:" + username }
|
||||
|
||||
func (uc *SecurityUsecase) ActiveToken(ctx context.Context, username string) (string, error) {
|
||||
value, _, err := uc.cache.Get(ctx, activeTokenKey(username))
|
||||
|
||||
@@ -43,8 +43,8 @@ func (uc *SystemConfigUsecase) IsInitialized(ctx context.Context) (bool, error)
|
||||
return uc.repo.IsInitialized(ctx)
|
||||
}
|
||||
func (uc *SystemConfigUsecase) Initialize(ctx context.Context, config *DatabaseConfig) error {
|
||||
if config == nil || len(config.AdminPassword) < 6 {
|
||||
return ErrInvalidCredentials
|
||||
if config == nil {
|
||||
return errors.New("数据库初始化参数无效")
|
||||
}
|
||||
if err := uc.repo.Initialize(ctx, config); err != nil {
|
||||
return err
|
||||
|
||||
+2
-25
@@ -27,7 +27,7 @@ type TimedTask struct {
|
||||
}
|
||||
type TimedTaskLog struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
TaskID uint
|
||||
TaskName, TriggerType string
|
||||
StartedAt, FinishedAt time.Time
|
||||
@@ -49,29 +49,6 @@ type TaskScheduleError struct{ Err error }
|
||||
func (e *TaskScheduleError) Error() string { return e.Err.Error() }
|
||||
func (e *TaskScheduleError) Is(target error) bool { return target == ErrTaskSchedule }
|
||||
|
||||
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
|
||||
@@ -122,7 +99,7 @@ func (uc *TaskUsecase) Validate(value *TimedTask) error {
|
||||
}
|
||||
case TaskExecutorHTTP:
|
||||
parsed, parseErr := url.Parse(value.HTTPURL)
|
||||
if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
|
||||
if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return errors.New("httpUrl 必须是合法的 http/https 地址")
|
||||
}
|
||||
if len(value.HTTPHeader) > 0 {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package biz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// TaskMethodFunc is the execution contract for a registered in-process task.
|
||||
// Implementations are responsible for decoding params and honoring ctx cancellation.
|
||||
type TaskMethodFunc func(ctx context.Context, params json.RawMessage) error
|
||||
|
||||
type TaskMethod struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
type taskMethodEntry struct {
|
||||
meta TaskMethod
|
||||
fn TaskMethodFunc
|
||||
}
|
||||
|
||||
var taskMethodRegistry = struct {
|
||||
sync.RWMutex
|
||||
methods map[string]taskMethodEntry
|
||||
}{methods: make(map[string]taskMethodEntry)}
|
||||
|
||||
// RegisterTaskMethod registers an in-process task. Re-registering the same name
|
||||
// replaces the previous implementation so startup registration remains idempotent.
|
||||
func RegisterTaskMethod(name, description string, fn TaskMethodFunc) {
|
||||
taskMethodRegistry.Lock()
|
||||
defer taskMethodRegistry.Unlock()
|
||||
taskMethodRegistry.methods[name] = taskMethodEntry{
|
||||
meta: TaskMethod{Name: name, Description: description},
|
||||
fn: fn,
|
||||
}
|
||||
}
|
||||
|
||||
// TaskMethodByName returns the registered function for name.
|
||||
func TaskMethodByName(name string) (TaskMethodFunc, bool) {
|
||||
taskMethodRegistry.RLock()
|
||||
defer taskMethodRegistry.RUnlock()
|
||||
method, ok := taskMethodRegistry.methods[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return method.fn, true
|
||||
}
|
||||
|
||||
// RegisteredTaskMethods returns task metadata in a stable name order.
|
||||
func RegisteredTaskMethods() []TaskMethod {
|
||||
taskMethodRegistry.RLock()
|
||||
defer taskMethodRegistry.RUnlock()
|
||||
methods := make([]TaskMethod, 0, len(taskMethodRegistry.methods))
|
||||
for _, method := range taskMethodRegistry.methods {
|
||||
methods = append(methods, method.meta)
|
||||
}
|
||||
sort.Slice(methods, func(i, j int) bool { return methods[i].Name < methods[j].Name })
|
||||
return methods
|
||||
}
|
||||
|
||||
func registeredTaskMethod(name string) bool {
|
||||
_, ok := TaskMethodByName(name)
|
||||
return ok
|
||||
}
|
||||
+10
-11
@@ -35,15 +35,6 @@ type UserListFilter struct {
|
||||
Desc bool
|
||||
}
|
||||
|
||||
type Authority struct {
|
||||
AuthorityID uint
|
||||
AuthorityName string
|
||||
ParentID *uint
|
||||
DataScope int
|
||||
DefaultRouter string
|
||||
Children []*Authority
|
||||
}
|
||||
|
||||
type UserRepo interface {
|
||||
FindUserByUsername(context.Context, string) (*User, error)
|
||||
FindUserByID(context.Context, uint) (*User, error)
|
||||
@@ -125,8 +116,16 @@ func (uc *UserUsecase) CreateUser(ctx context.Context, user *User, authorityIDs
|
||||
return uc.repo.CreateUserWithAuthorities(ctx, user, authorityIDs)
|
||||
}
|
||||
func (uc *UserUsecase) UpdateUser(ctx context.Context, user *User, authorityIDs []uint) error {
|
||||
authorityIDs = includeAuthority(authorityIDs, user.AuthorityID)
|
||||
return uc.repo.UpdateUserWithAuthorities(ctx, user, authorityIDs)
|
||||
// Role associations are updated first only when authorityIds was supplied,
|
||||
// and then updates the basic user fields as a separate operation. Preserve
|
||||
// that ordering and partial-failure behavior instead of coupling both writes
|
||||
// into the data-layer transaction used by lower-level callers.
|
||||
if len(authorityIDs) > 0 {
|
||||
if err := uc.repo.SetUserAuthorities(ctx, user.ID, authorityIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return uc.repo.UpdateUser(ctx, user)
|
||||
}
|
||||
|
||||
func (uc *UserUsecase) UpdateSelfUser(ctx context.Context, user *User) error {
|
||||
|
||||
@@ -8,6 +8,24 @@ import (
|
||||
|
||||
var ErrInvalidVersion = errors.New("版本信息格式错误")
|
||||
|
||||
type VersionStage string
|
||||
|
||||
const (
|
||||
VersionStageMenus VersionStage = "menus"
|
||||
VersionStageAPIs VersionStage = "apis"
|
||||
VersionStageDictionaries VersionStage = "dictionaries"
|
||||
VersionStageJSON VersionStage = "json"
|
||||
VersionStageSave VersionStage = "save"
|
||||
)
|
||||
|
||||
type VersionStageError struct {
|
||||
Stage VersionStage
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *VersionStageError) Error() string { return e.Err.Error() }
|
||||
func (e *VersionStageError) Unwrap() error { return e.Err }
|
||||
|
||||
type Version struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
|
||||
Reference in New Issue
Block a user