This commit is contained in:
Yvan
2026-08-18 15:04:07 +08:00
parent 10ccc005be
commit 1ef8ecb7b2
42 changed files with 3027 additions and 296 deletions
+73 -7
View File
@@ -61,9 +61,16 @@ func (uc *AuthenticationUsecase) recordLogin(ctx context.Context, attempt *Login
}
func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttempt) (*AuthenticationResult, error) {
config, _ := uc.security.Current(ctx)
config, err := uc.security.Current(ctx)
if err != nil || config == nil {
return nil, fmt.Errorf("%w: security config unavailable", ErrLoginState)
}
if config != nil && config.LockEnable {
if locked, _ := uc.security.LoginLocked(ctx, attempt.Username); locked {
locked, lockErr := uc.security.LoginLocked(ctx, attempt.Username)
if lockErr != nil {
return nil, fmt.Errorf("%w: %v", ErrLoginState, lockErr)
}
if locked {
uc.recordLogin(ctx, attempt, false, "账号已锁定", 0)
return nil, &AccountLockedError{Minutes: config.LockDuration}
}
@@ -75,7 +82,10 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
if config.CaptchaTimeout > 0 {
ipTTL = time.Duration(config.CaptchaTimeout) * time.Second
}
failures, _ := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL)
failures, counterErr := uc.security.EnsureLoginIPCounter(ctx, attempt.IP, ipTTL)
if counterErr != nil {
return nil, fmt.Errorf("%w: %v", ErrLoginState, counterErr)
}
requireCaptcha = config.CaptchaOpen == 0 || failures > config.CaptchaOpen
}
if requireCaptcha && !uc.security.VerifyCaptcha(ctx, attempt.CaptchaID, attempt.Captcha, true) {
@@ -89,9 +99,14 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
if config != nil && config.LockEnable {
lockTTL := time.Duration(config.LockDuration) * time.Minute
failures, _ := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL)
failures, stateErr := uc.security.IncrementLoginFailure(ctx, attempt.Username, lockTTL)
if stateErr != nil {
return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr)
}
if int(failures) >= config.LockThreshold {
_ = uc.security.LockLogin(ctx, attempt.Username, lockTTL)
if stateErr = uc.security.LockLogin(ctx, attempt.Username, lockTTL); stateErr != nil {
return nil, fmt.Errorf("%w: %v", ErrLoginState, stateErr)
}
}
}
uc.recordLogin(ctx, attempt, false, "用户名不存在或者密码错误", 0)
@@ -130,6 +145,52 @@ func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttemp
return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: user.MustChangePassword}, nil
}
func userHasAuthority(user *User, authorityID uint) bool {
if user == nil || authorityID == 0 {
return false
}
if user.AuthorityID == authorityID {
return true
}
for _, authority := range user.Authorities {
if authority.AuthorityID == authorityID {
return true
}
}
return false
}
func (uc *AuthenticationUsecase) currentTokenUser(ctx context.Context, claims *AuthClaims) (*User, error) {
if claims == nil || claims.UUID == "" || claims.IssuedAt.IsZero() {
return nil, ErrTokenDisabled
}
user, err := uc.users.UserByUUID(ctx, claims.UUID)
if err != nil || user == nil || user.ID != claims.ID || user.Username != claims.Username {
return nil, ErrTokenDisabled
}
if user.Enable != 1 {
return nil, &UserDisabledError{UserID: user.ID}
}
if !userHasAuthority(user, claims.AuthorityID) {
return nil, ErrTokenDisabled
}
passwordVersion := int64(0)
if user.PasswordUpdatedAt != nil {
passwordVersion = user.PasswordUpdatedAt.UnixNano()
}
if passwordVersion != claims.PasswordVersion {
return nil, ErrTokenDisabled
}
config, err := uc.security.Current(ctx)
if err != nil || config == nil {
return nil, ErrTokenDisabled
}
if config.PwdExpireEnable && config.PwdExpireDays > 0 && user.PasswordUpdatedAt != nil && time.Now().After(user.PasswordUpdatedAt.AddDate(0, 0, config.PwdExpireDays)) {
user.MustChangePassword = true
}
return user, 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
@@ -166,12 +227,17 @@ func (uc *AuthenticationUsecase) AuthenticateToken(ctx context.Context, token st
if err != nil {
return nil, err
}
user, err := uc.currentTokenUser(ctx, claims)
if err != nil {
return nil, err
}
claims.NickName = user.NickName
claims.MustChangePwd = user.MustChangePassword
result := &TokenAuthentication{Claims: claims}
if claims.BufferTime <= 0 || time.Until(claims.ExpiresAt) >= claims.BufferTime {
return result, nil
}
user := &User{ID: claims.ID, UUID: claims.UUID, Username: claims.Username, NickName: claims.NickName, AuthorityID: claims.AuthorityID, MustChangePassword: claims.MustChangePwd}
issued, err := uc.issuer.IssueToken(user, claims.AuthorityID, claims.MustChangePwd, 0)
issued, err := uc.issuer.IssueToken(user, claims.AuthorityID, user.MustChangePassword, 0)
if err != nil {
return result, nil
}
+115 -4
View File
@@ -11,29 +11,42 @@ import (
type authenticationUserRepo struct {
UserRepo
user *User
user *User
findByUUIDErr error
}
func (r *authenticationUserRepo) FindUserByUsername(context.Context, string) (*User, error) {
return r.user, nil
}
func (r *authenticationUserRepo) FindUserByUUID(context.Context, string) (*User, error) {
return r.user, r.findByUUIDErr
}
func (*authenticationUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) {
return true, nil
}
func (*authenticationUserRepo) FillDepartmentNamePaths(context.Context, *User) error { return nil }
type authenticationSecurityRepo struct{ SecurityRepo }
func (*authenticationSecurityRepo) SecurityConfig(context.Context) (*SecurityConfig, error) {
return &SecurityConfig{CaptchaOpen: 2, CaptchaTimeout: 60}, nil
}
type authenticationCache struct{ activeErr error }
type authenticationCache struct {
activeErr error
getErr error
}
func (c *authenticationCache) Get(_ context.Context, key string) (string, bool, error) {
if key == "admin" {
return "", false, c.activeErr
}
if c.getErr != nil {
return "", false, c.getErr
}
return "", false, nil
}
func (*authenticationCache) Set(context.Context, string, string, time.Duration) error { return nil }
@@ -46,12 +59,20 @@ type authenticationSettings struct{ RuntimeSettings }
func (*authenticationSettings) UseMultipoint() bool { return true }
type authenticationIssuer struct{ TokenIssuer }
type authenticationIssuer struct {
TokenIssuer
claims *AuthClaims
issuedUser *User
issuedMust bool
}
func (*authenticationIssuer) IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error) {
func (i *authenticationIssuer) IssueToken(user *User, _ uint, must bool, _ time.Duration) (*IssuedToken, error) {
i.issuedUser, i.issuedMust = user, must
return &IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil
}
func (i *authenticationIssuer) ParseToken(string) (*AuthClaims, error) { return i.claims, nil }
func (*authenticationIssuer) ReissueToken(claims *AuthClaims, _ uint) (*IssuedToken, error) {
return &IssuedToken{Value: "token", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
}
@@ -93,6 +114,12 @@ func (a *authenticationAudit) RecordLogin(_ context.Context, value *LoginLog) er
return nil
}
type authenticationTokenRepo struct{ APITokenRepo }
func (*authenticationTokenRepo) IsTokenDisabled(context.Context, string) (bool, error) {
return false, nil
}
func TestLoginRecordsSuccessBeforeMultipointCacheFailure(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost)
if err != nil {
@@ -137,3 +164,87 @@ func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T)
t.Fatalf("switch result = %+v", result)
}
}
func TestLoginRejectsSecurityCacheFailure(t *testing.T) {
cacheErr := errors.New("cache unavailable")
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{getErr: cacheErr}, &authenticationSettings{}, nil)
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{}), security, &authenticationIssuer{}, &authenticationAudit{})
_, err := uc.Login(context.Background(), &LoginAttempt{Username: "admin", Password: "secret", IP: "127.0.0.1"})
if !errors.Is(err, ErrLoginState) {
t.Fatalf("expected login-state failure, got %v", err)
}
}
func TestAuthenticateTokenRejectsStaleUserState(t *testing.T) {
issuedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
baseClaims := AuthClaims{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 888, IssuedAt: issuedAt, ExpiresAt: time.Now().Add(time.Hour)}
passwordChanged := issuedAt.Add(time.Minute)
tests := []struct {
name string
user *User
want error
}{
{name: "disabled", user: &User{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 888, Enable: 0}, want: ErrUserDisabled},
{name: "role removed", user: &User{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 999, Enable: 1}, want: ErrTokenDisabled},
{name: "password changed", user: &User{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 888, Enable: 1, PasswordUpdatedAt: &passwordChanged}, want: ErrTokenDisabled},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
claims := baseClaims
issuer := &authenticationIssuer{claims: &claims}
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{}, &authenticationSettings{}, NewTokenUsecase(&authenticationTokenRepo{}))
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{user: test.user}), security, issuer, &authenticationAudit{})
_, err := uc.AuthenticateToken(context.Background(), "token")
if !errors.Is(err, test.want) {
t.Fatalf("AuthenticateToken error = %v, want %v", err, test.want)
}
})
}
}
func TestAuthenticateTokenRefreshesFromCurrentUser(t *testing.T) {
issuedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
claims := &AuthClaims{ID: 7, UUID: "uuid", Username: "admin", NickName: "old", AuthorityID: 888, IssuedAt: issuedAt, BufferTime: 2 * time.Hour, ExpiresAt: time.Now().Add(time.Hour)}
user := &User{ID: 7, UUID: "uuid", Username: "admin", NickName: "current", AuthorityID: 888, Enable: 1, MustChangePassword: true}
issuer := &authenticationIssuer{claims: claims}
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{}, &authenticationSettings{}, NewTokenUsecase(&authenticationTokenRepo{}))
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{user: user}), security, issuer, &authenticationAudit{})
result, err := uc.AuthenticateToken(context.Background(), "token")
if err != nil {
t.Fatal(err)
}
if result.Refreshed == nil || result.Claims.NickName != "current" || !result.Claims.MustChangePwd {
t.Fatalf("unexpected authentication result: %+v", result)
}
if issuer.issuedUser != user || !issuer.issuedMust {
t.Fatalf("refresh used stale user state: user=%+v must=%v", issuer.issuedUser, issuer.issuedMust)
}
}
func TestAuthenticateTokenRejectsPasswordChangeWithinIssuedAtSecond(t *testing.T) {
issuedAt := time.Now().Add(-time.Minute).Truncate(time.Second)
passwordAtIssue := issuedAt.Add(100 * time.Millisecond)
passwordChanged := issuedAt.Add(900 * time.Millisecond)
if passwordAtIssue.Unix() != passwordChanged.Unix() {
t.Fatal("test setup did not keep password changes in one second")
}
claims := &AuthClaims{
ID: 7,
UUID: "uuid",
Username: "admin",
AuthorityID: 888,
IssuedAt: issuedAt,
ExpiresAt: time.Now().Add(time.Hour),
PasswordVersion: passwordAtIssue.UnixNano(),
}
user := &User{ID: 7, UUID: "uuid", Username: "admin", AuthorityID: 888, Enable: 1, PasswordUpdatedAt: &passwordChanged}
issuer := &authenticationIssuer{claims: claims}
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{}, &authenticationSettings{}, NewTokenUsecase(&authenticationTokenRepo{}))
uc := NewAuthenticationUsecase(NewUserUsecase(&authenticationUserRepo{user: user}), security, issuer, &authenticationAudit{})
if _, err := uc.AuthenticateToken(context.Background(), "token"); !errors.Is(err, ErrTokenDisabled) {
t.Fatalf("AuthenticateToken error = %v, want ErrTokenDisabled", err)
}
}
+23 -12
View File
@@ -55,6 +55,15 @@ type MediaSettings struct {
ChunkDir string
}
const DefaultMaxMediaFileSize int64 = 100 << 20
func (s MediaSettings) EffectiveMaxFileSize() int64 {
if s.MaxFileSize > 0 {
return s.MaxFileSize
}
return DefaultMaxMediaFileSize
}
// RuntimeSettings exposes only the active values needed by the application.
// The data implementation resolves every call from conf.Runtime so hot reloads
// take effect without rebuilding services.
@@ -73,18 +82,20 @@ type IssuedToken struct {
}
type AuthClaims struct {
UUID string
ID uint
Username string
NickName string
AuthorityID uint
UserType string
BufferTime time.Duration
MustChangePwd bool
Issuer string
Audience []string
NotBefore time.Time
ExpiresAt time.Time
UUID string
ID uint
Username string
NickName string
AuthorityID uint
UserType string
BufferTime time.Duration
MustChangePwd bool
PasswordVersion int64
Issuer string
Audience []string
IssuedAt time.Time
NotBefore time.Time
ExpiresAt time.Time
}
var (
+25 -1
View File
@@ -26,10 +26,19 @@ type MediaUsecase struct {
settings RuntimeSettings
}
var ErrMediaTooLarge = errors.New("文件超过大小上限")
func NewMediaUsecase(repo MediaRepo, files FileStorage, settings RuntimeSettings) *MediaUsecase {
return &MediaUsecase{MediaRepo: repo, files: files, settings: settings}
}
func (uc *MediaUsecase) maxMediaFileSize() int64 {
if uc.settings == nil {
return MediaSettings{}.EffectiveMaxFileSize()
}
return uc.settings.MediaSettings().EffectiveMaxFileSize()
}
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,
@@ -70,10 +79,25 @@ func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedM
}
key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext
hash := md5.New()
stored, err := uc.files.Put(ctx, key, io.TeeReader(buffered, hash))
maxSize := uc.maxMediaFileSize()
limited := &io.LimitedReader{R: buffered, N: maxSize + 1}
stored, err := uc.files.Put(ctx, key, io.TeeReader(limited, hash))
if err != nil {
return nil, err
}
readSize := maxSize + 1 - limited.N
if stored == nil {
_ = uc.files.Delete(ctx, key)
return nil, errors.New("文件存储未返回结果")
}
if readSize > maxSize || stored.Size > maxSize {
_ = uc.files.Delete(ctx, key)
return nil, ErrMediaTooLarge
}
if stored.Size != readSize {
_ = uc.files.Delete(ctx, key)
return nil, errors.New("文件存储大小不一致")
}
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 {
count, countErr := uc.MediaKeyReferences(ctx, key)
+169
View File
@@ -2,6 +2,9 @@ package biz
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"strings"
"testing"
@@ -79,3 +82,169 @@ func TestListStorageFiltersChunksWithoutSkippingVisibleObjects(t *testing.T) {
t.Fatalf("second cursor/more = %q/%v", cursor, more)
}
}
type mediaLimitSettings struct {
RuntimeSettings
max int64
}
func (s mediaLimitSettings) MediaSettings() MediaSettings {
return MediaSettings{MaxFileSize: s.max}
}
type mediaLimitStorage struct {
readSize int64
reportedSize *int64
deleted bool
composeCalled bool
composed *StoredFile
composedHash string
}
func (s *mediaLimitStorage) Put(_ context.Context, _ string, reader io.Reader) (*StoredFile, error) {
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
s.readSize = int64(len(data))
size := s.readSize
if s.reportedSize != nil {
size = *s.reportedSize
}
return &StoredFile{Size: size}, nil
}
func (*mediaLimitStorage) Open(context.Context, string) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("")), nil
}
func (s *mediaLimitStorage) Delete(context.Context, string) error {
s.deleted = true
return nil
}
func (s *mediaLimitStorage) Compose(context.Context, []string, string) (*StoredFile, string, error) {
s.composeCalled = true
return s.composed, s.composedHash, nil
}
func (*mediaLimitStorage) DeletePrefix(context.Context, string) error { return nil }
func (*mediaLimitStorage) List(context.Context, string, string, int) ([]*StoredFile, string, bool, error) {
return nil, "", false, nil
}
type mediaUploadRepo struct {
MediaRepo
session *UploadSession
chunks []*UploadChunk
claimed bool
failed bool
}
func (r *mediaUploadRepo) FindUploadSession(context.Context, uint) (*UploadSession, error) {
if r.session == nil {
return nil, ErrUploadSessionNotFound
}
return r.session, nil
}
func (r *mediaUploadRepo) ClaimUploadSession(context.Context, uint) (bool, error) {
return r.claimed, nil
}
func (r *mediaUploadRepo) FailUploadSession(context.Context, uint) error {
r.failed = true
return nil
}
func (r *mediaUploadRepo) ListChunks(context.Context, uint) ([]*UploadChunk, error) {
return r.chunks, nil
}
func md5Text(value string) string {
sum := md5.Sum([]byte(value))
return hex.EncodeToString(sum[:])
}
func TestUploadRejectsDataPastConfiguredLimit(t *testing.T) {
storage := &mediaLimitStorage{}
uc := NewMediaUsecase(&mediaUploadRepo{}, storage, mediaLimitSettings{max: 4})
_, err := uc.Upload(context.Background(), 1, "sample.txt", "text/plain", 0, strings.NewReader("12345"), false)
if !errors.Is(err, ErrMediaTooLarge) {
t.Fatalf("Upload() error = %v, want ErrMediaTooLarge", err)
}
if storage.readSize != 5 || !storage.deleted {
t.Fatalf("storage read/deleted = %d/%v, want 5/true", storage.readSize, storage.deleted)
}
}
func TestUploadRejectsStorageSizeMismatch(t *testing.T) {
reported := int64(3)
storage := &mediaLimitStorage{reportedSize: &reported}
uc := NewMediaUsecase(&mediaUploadRepo{}, storage, mediaLimitSettings{max: 4})
if _, err := uc.Upload(context.Background(), 1, "sample.txt", "text/plain", 0, strings.NewReader("1234"), false); err == nil {
t.Fatal("Upload() error = nil, want storage size mismatch")
}
if !storage.deleted {
t.Fatal("mismatched stored object was not deleted")
}
}
func TestInitUploadRejectsInvalidLayout(t *testing.T) {
uc := NewMediaUsecase(&mediaUploadRepo{}, &mediaLimitStorage{}, mediaLimitSettings{max: 10})
validHash := strings.Repeat("0", md5.Size*2)
tests := []struct {
name string
hash string
size int64
chunkSize int64
total int
wantLarge bool
}{
{name: "invalid hash", hash: "bad", size: 5, chunkSize: 3, total: 2},
{name: "zero size", hash: validHash, size: 0, chunkSize: 3, total: 1},
{name: "over limit", hash: validHash, size: 11, chunkSize: 3, total: 4, wantLarge: true},
{name: "wrong total", hash: validHash, size: 5, chunkSize: 3, total: 3},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, _, err := uc.InitUpload(context.Background(), 1, "sample.txt", test.hash, test.size, test.chunkSize, test.total)
if err == nil {
t.Fatal("InitUpload() error = nil")
}
if test.wantLarge && !errors.Is(err, ErrMediaTooLarge) {
t.Fatalf("InitUpload() error = %v, want ErrMediaTooLarge", err)
}
})
}
}
func TestSaveChunkRejectsIndexAndSizeMismatch(t *testing.T) {
repo := &mediaUploadRepo{session: &UploadSession{ID: 7, UserID: 1, FileSize: 5, ChunkSize: 3, ChunkTotal: 2, Status: "uploading"}}
uc := NewMediaUsecase(repo, &mediaLimitStorage{}, mediaLimitSettings{max: 10})
if err := uc.SaveChunk(context.Background(), 1, 7, 2, md5Text("abc"), strings.NewReader("abc")); err == nil {
t.Fatal("SaveChunk() accepted an out-of-range index")
}
if err := uc.SaveChunk(context.Background(), 1, 7, 0, md5Text("ab"), strings.NewReader("ab")); err == nil {
t.Fatal("SaveChunk() accepted a short chunk")
}
if err := uc.SaveChunk(context.Background(), 1, 7, 0, md5Text("abcd"), strings.NewReader("abcd")); err == nil {
t.Fatal("SaveChunk() accepted an oversized chunk")
}
}
func TestCompleteUploadRejectsMismatchedChunkSizesBeforeCompose(t *testing.T) {
repo := &mediaUploadRepo{
session: &UploadSession{ID: 7, UserID: 1, FileName: "sample.txt", FileHash: md5Text("abcde"), FileSize: 5, ChunkSize: 3, ChunkTotal: 2, Status: "uploading"},
chunks: []*UploadChunk{{Index: 0, Size: 3}, {Index: 1, Size: 1}},
claimed: true,
}
storage := &mediaLimitStorage{}
uc := NewMediaUsecase(repo, storage, mediaLimitSettings{max: 10})
if _, err := uc.CompleteUpload(context.Background(), 1, 7, "text/plain"); err == nil {
t.Fatal("CompleteUpload() accepted mismatched chunk sizes")
}
if storage.composeCalled {
t.Fatal("Compose() was called before chunk-size validation completed")
}
if !repo.failed {
t.Fatal("failed upload session was not returned to the uploading state")
}
}
+92 -9
View File
@@ -19,6 +19,45 @@ import (
var ErrUploadSessionNotFound = errors.New("upload session not found")
func validMD5(value string) bool {
if len(value) != md5.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func (uc *MediaUsecase) validateUploadLayout(size, chunkSize int64, total int) error {
if size <= 0 || chunkSize <= 0 || total <= 0 {
return errors.New("文件大小和分片参数必须大于 0")
}
if size > uc.maxMediaFileSize() {
return ErrMediaTooLarge
}
expectedTotal := (size-1)/chunkSize + 1
if int64(total) != expectedTotal {
return fmt.Errorf("分片数量不匹配: %d/%d", total, expectedTotal)
}
return nil
}
func expectedChunkSize(session *UploadSession, index int) (int64, error) {
if session == nil || session.FileSize <= 0 || session.ChunkSize <= 0 || session.ChunkTotal <= 0 {
return 0, errors.New("上传会话分片参数非法")
}
if index < 0 || index >= session.ChunkTotal {
return 0, errors.New("分片序号超出范围")
}
if index < session.ChunkTotal-1 {
return session.ChunkSize, nil
}
last := session.FileSize - session.ChunkSize*int64(session.ChunkTotal-1)
if last <= 0 || last > session.ChunkSize {
return 0, errors.New("上传会话分片参数非法")
}
return last, nil
}
func (uc *MediaUsecase) chunkPrefix(uploadID uint) string {
directory := "uploads/chunks"
if uc.settings != nil {
@@ -37,14 +76,24 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash
if err := validateMediaName(name); err != nil {
return nil, nil, nil, err
}
if completed, err := uc.FindCompletedSession(ctx, userID, hash); err == nil && completed.MediaID != 0 {
if !validMD5(hash) {
return nil, nil, nil, errors.New("文件 MD5 非法")
}
if err := uc.validateUploadLayout(size, chunkSize, total); err != nil {
return nil, nil, nil, err
}
completed, err := uc.FindCompletedSession(ctx, userID, strings.ToLower(hash))
if 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}
if createErr := uc.CreateMedia(ctx, copy); createErr == nil {
return nil, copy, nil, nil
}
}
} else if err != nil && !errors.Is(err, ErrUploadSessionNotFound) {
return nil, nil, nil, err
}
hash = strings.ToLower(hash)
session, err := uc.FindUploadingSession(ctx, userID, hash)
if errors.Is(err, ErrUploadSessionNotFound) {
session = &UploadSession{UserID: userID, FileName: name, FileHash: hash, FileSize: size, ChunkSize: chunkSize, ChunkTotal: total, Status: "uploading"}
@@ -53,6 +102,8 @@ func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash
}
} else if err != nil {
return nil, nil, nil, err
} else if session.FileSize != size || session.ChunkSize != chunkSize || session.ChunkTotal != total {
return nil, nil, nil, errors.New("上传参数与已有会话不一致")
}
chunks, err := uc.ListChunks(ctx, session.ID)
if err != nil {
@@ -76,6 +127,14 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in
if session.Status != "uploading" {
return errors.New("上传会话状态不允许收片")
}
expected = strings.ToLower(strings.TrimSpace(expected))
if !validMD5(expected) {
return errors.New("分片 MD5 非法")
}
wantSize, err := expectedChunkSize(session, index)
if err != nil {
return err
}
hash := md5.New()
key := uc.chunkKey(uploadID, index)
temporary, err := os.CreateTemp("", "kra-upload-chunk-*")
@@ -85,11 +144,15 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
defer temporary.Close()
if _, err = io.Copy(io.MultiWriter(temporary, hash), reader); err != nil {
written, err := io.Copy(io.MultiWriter(temporary, hash), io.LimitReader(reader, wantSize+1))
if err != nil {
return err
}
if written != wantSize {
return fmt.Errorf("分片 %d 大小不匹配: %d/%d", index, written, wantSize)
}
actual := hex.EncodeToString(hash.Sum(nil))
if actual != expected {
if !strings.EqualFold(actual, expected) {
return fmt.Errorf("分片 %d 校验失败", index)
}
if _, err = temporary.Seek(0, io.SeekStart); err != nil {
@@ -99,7 +162,15 @@ func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, in
if err != nil {
return err
}
return uc.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: stored.Size})
if stored == nil || stored.Size != written {
_ = uc.files.Delete(ctx, key)
return errors.New("分片存储大小不一致")
}
if err = uc.UpsertChunk(ctx, uploadID, &UploadChunk{Index: index, Hash: actual, Size: written}); err != nil {
_ = uc.files.Delete(ctx, key)
return err
}
return nil
}
func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uint, mime string) (*MediaFile, error) {
session, err := uc.FindUploadSession(ctx, uploadID)
@@ -129,26 +200,38 @@ func (uc *MediaUsecase) CompleteUpload(ctx context.Context, userID, uploadID uin
}
sort.Slice(chunks, func(i, j int) bool { return chunks[i].Index < chunks[j].Index })
names := make([]string, 0, len(chunks))
var totalSize int64
for index, chunk := range chunks {
if chunk.Index != index {
return fail(errors.New("分片序号不连续"))
}
expectedSize, sizeErr := expectedChunkSize(session, index)
if sizeErr != nil || chunk.Size != expectedSize || chunk.Size > session.FileSize-totalSize {
return fail(errors.New("分片大小不一致"))
}
totalSize += chunk.Size
names = append(names, uc.chunkKey(uploadID, index))
}
if totalSize != session.FileSize {
return fail(fmt.Errorf("文件大小不匹配: %d/%d", totalSize, session.FileSize))
}
ext := strings.ToLower(filepath.Ext(session.FileName))
key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext
stored, hash, err := uc.files.Compose(ctx, names, key)
if err != nil {
return fail(err)
}
if hash != session.FileHash {
if stored == nil || stored.Size != session.FileSize {
if stored != nil {
_ = uc.files.Delete(ctx, key)
}
return fail(errors.New("合并文件大小不匹配"))
}
if !strings.EqualFold(hash, session.FileHash) {
_ = uc.files.Delete(ctx, key)
return fail(errors.New("整文件校验失败"))
}
// 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}
media := &MediaFile{Name: session.FileName, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(session.FileName), "."), Key: key, Size: stored.Size, Mime: mime, MD5: hash, UserID: userID}
if err = uc.CreateMedia(ctx, media); err != nil {
return fail(err)
}
+21 -13
View File
@@ -154,39 +154,47 @@ func NewTaskApplicationUsecase(tasks *TaskUsecase, runtime TaskRuntime) *TaskApp
return &TaskApplicationUsecase{tasks: tasks, runtime: runtime}
}
func (uc *TaskApplicationUsecase) syncRuntime(ctx context.Context, id uint) error {
if ctx == nil {
ctx = context.Background()
}
if err := uc.runtime.ScheduleID(ctx, id); err != nil {
repairCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if reloadErr := uc.runtime.Reload(repairCtx); reloadErr != nil {
return &TaskScheduleError{Err: errors.Join(err, fmt.Errorf("重载任务运行时失败: %w", reloadErr))}
}
}
return nil
}
func (uc *TaskApplicationUsecase) Create(ctx context.Context, value *TimedTask) error {
if err := uc.tasks.Create(ctx, value); err != nil {
return err
}
if err := uc.runtime.ScheduleID(ctx, value.ID); err != nil {
return &TaskScheduleError{Err: err}
}
return nil
return uc.syncRuntime(ctx, value.ID)
}
func (uc *TaskApplicationUsecase) Update(ctx context.Context, value *TimedTask) error {
if err := uc.tasks.Update(ctx, value); err != nil {
return err
}
if err := uc.runtime.ScheduleID(ctx, value.ID); err != nil {
return &TaskScheduleError{Err: err}
}
return nil
return uc.syncRuntime(ctx, value.ID)
}
func (uc *TaskApplicationUsecase) Delete(ctx context.Context, id uint) error {
if err := uc.tasks.DeleteTask(ctx, id); err != nil {
return err
}
uc.runtime.Remove(id)
return uc.tasks.DeleteTask(ctx, id)
return nil
}
func (uc *TaskApplicationUsecase) Toggle(ctx context.Context, id uint, enabled bool) error {
if err := uc.tasks.ToggleTask(ctx, id, enabled); err != nil {
return err
}
if err := uc.runtime.ScheduleID(ctx, id); err != nil {
return &TaskScheduleError{Err: err}
}
return nil
return uc.syncRuntime(ctx, id)
}
func (uc *TaskApplicationUsecase) Trigger(ctx context.Context, id uint) error {
+197
View File
@@ -0,0 +1,197 @@
package biz
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
)
type applicationTaskRepo struct {
mu sync.Mutex
deleted bool
deleteErr error
createdID uint
toggleCalls int
updateCalls int
createdCalls int
nameExists bool
nameExistsErr error
deleteObserved chan struct{}
}
func (r *applicationTaskRepo) CreateTask(_ context.Context, value *TimedTask) error {
r.mu.Lock()
defer r.mu.Unlock()
r.createdCalls++
if value.ID == 0 {
value.ID = r.createdID
}
return nil
}
func (r *applicationTaskRepo) UpdateTask(context.Context, *TimedTask) error {
r.mu.Lock()
defer r.mu.Unlock()
r.updateCalls++
return nil
}
func (r *applicationTaskRepo) DeleteTask(context.Context, uint) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.deleteErr != nil {
return r.deleteErr
}
r.deleted = true
if r.deleteObserved != nil {
close(r.deleteObserved)
}
return nil
}
func (r *applicationTaskRepo) FindTask(context.Context, uint) (*TimedTask, error) {
return nil, errors.New("not implemented")
}
func (r *applicationTaskRepo) ListTasks(context.Context, int, int, *TimedTask) ([]*TimedTask, int64, error) {
return nil, 0, nil
}
func (r *applicationTaskRepo) ToggleTask(context.Context, uint, bool) error {
r.mu.Lock()
defer r.mu.Unlock()
r.toggleCalls++
return nil
}
func (r *applicationTaskRepo) RecordTaskLog(context.Context, *TimedTaskLog) error { return nil }
func (r *applicationTaskRepo) ListTaskLogs(context.Context, int, int, uint, string) ([]*TimedTaskLog, int64, error) {
return nil, 0, nil
}
func (r *applicationTaskRepo) CleanupLogs(context.Context) error { return nil }
func (r *applicationTaskRepo) TaskNameExists(context.Context, string, uint) (bool, error) {
return r.nameExists, r.nameExistsErr
}
type applicationTaskRuntime struct {
mu sync.Mutex
scheduleErr error
reloadErr error
scheduleCalls int
reloadCalls int
removeCalls int
removeBeforeDelete bool
repo *applicationTaskRepo
reloadContextActive bool
}
func (r *applicationTaskRuntime) ScheduleID(context.Context, uint) error {
r.mu.Lock()
defer r.mu.Unlock()
r.scheduleCalls++
return r.scheduleErr
}
func (r *applicationTaskRuntime) Remove(uint) {
r.mu.Lock()
defer r.mu.Unlock()
r.removeCalls++
if r.repo != nil {
r.repo.mu.Lock()
r.removeBeforeDelete = !r.repo.deleted
r.repo.mu.Unlock()
}
}
func (r *applicationTaskRuntime) TriggerID(context.Context, uint) error { return nil }
func (r *applicationTaskRuntime) NextRuns() map[uint]time.Time { return nil }
func (r *applicationTaskRuntime) Reload(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
r.reloadCalls++
r.reloadContextActive = ctx.Err() == nil
return r.reloadErr
}
func (r *applicationTaskRuntime) Subscribe(uint) chan []byte { return make(chan []byte) }
func (r *applicationTaskRuntime) Unsubscribe(uint, chan []byte) {}
func validApplicationTask(id uint) *TimedTask {
const methodName = "biz-test-application-task"
RegisterTaskMethod(methodName, "test", func(context.Context, json.RawMessage) error { return nil })
return &TimedTask{ID: id, Name: "test", Spec: "0 0 * * *", ExecutorType: TaskExecutorMethod, MethodName: methodName, Enabled: true}
}
func TestTaskApplicationRepairsRuntimeAfterScheduleFailure(t *testing.T) {
repo := &applicationTaskRepo{createdID: 41}
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")}
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := uc.Create(ctx, validApplicationTask(0)); err != nil {
t.Fatalf("Create() error = %v, want successful reload compensation", err)
}
if runtime.scheduleCalls != 1 || runtime.reloadCalls != 1 {
t.Fatalf("runtime calls = schedule:%d reload:%d, want 1 and 1", runtime.scheduleCalls, runtime.reloadCalls)
}
if !runtime.reloadContextActive {
t.Fatal("reload compensation inherited the canceled request context")
}
}
func TestTaskApplicationReturnsScheduleErrorWhenRepairFails(t *testing.T) {
repo := &applicationTaskRepo{createdID: 42}
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed"), reloadErr: errors.New("reload failed")}
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
err := uc.Create(context.Background(), validApplicationTask(0))
if !errors.Is(err, ErrTaskSchedule) {
t.Fatalf("Create() error = %v, want ErrTaskSchedule", err)
}
}
func TestTaskApplicationUpdateAndToggleUseRuntimeRepair(t *testing.T) {
for _, test := range []struct {
name string
run func(*TaskApplicationUsecase) error
}{
{name: "update", run: func(uc *TaskApplicationUsecase) error {
return uc.Update(context.Background(), validApplicationTask(7))
}},
{name: "toggle", run: func(uc *TaskApplicationUsecase) error { return uc.Toggle(context.Background(), 7, true) }},
} {
t.Run(test.name, func(t *testing.T) {
repo := &applicationTaskRepo{}
runtime := &applicationTaskRuntime{scheduleErr: errors.New("schedule failed")}
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
if err := test.run(uc); err != nil {
t.Fatalf("operation error = %v", err)
}
if runtime.scheduleCalls != 1 || runtime.reloadCalls != 1 {
t.Fatalf("runtime calls = schedule:%d reload:%d, want 1 and 1", runtime.scheduleCalls, runtime.reloadCalls)
}
})
}
}
func TestTaskApplicationDeleteRemovesRuntimeAfterDatabase(t *testing.T) {
repo := &applicationTaskRepo{}
runtime := &applicationTaskRuntime{repo: repo}
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
if err := uc.Delete(context.Background(), 8); err != nil {
t.Fatalf("Delete() error = %v", err)
}
if runtime.removeBeforeDelete {
t.Fatal("runtime entry was removed before the database row")
}
if runtime.removeCalls != 1 {
t.Fatalf("Remove() calls = %d, want 1", runtime.removeCalls)
}
}
func TestTaskApplicationDeleteKeepsRuntimeWhenDatabaseFails(t *testing.T) {
repo := &applicationTaskRepo{deleteErr: errors.New("delete failed")}
runtime := &applicationTaskRuntime{repo: repo}
uc := NewTaskApplicationUsecase(NewTaskUsecase(repo), runtime)
if err := uc.Delete(context.Background(), 8); err == nil {
t.Fatal("Delete() error = nil, want database error")
}
if runtime.removeCalls != 0 {
t.Fatalf("Remove() calls = %d, want 0", runtime.removeCalls)
}
}