This commit is contained in:
2026-08-16 09:02:39 +08:00
parent c554152f4f
commit f7f6db8695
105 changed files with 2535 additions and 948 deletions
-3
View File
@@ -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
View File
@@ -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)
}
+4 -1
View File
@@ -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
}
+29 -23
View File
@@ -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
+21 -3
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+38 -13
View File
@@ -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
View File
@@ -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)
}
}
+17 -9
View File
@@ -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))
+2 -2
View File
@@ -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
View File
@@ -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 {
+66
View File
@@ -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
View File
@@ -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 {
+18
View File
@@ -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