优化结构

This commit is contained in:
Yvan
2026-08-20 23:02:06 +08:00
parent 63dbb4cfbc
commit e65cf0b1d0
338 changed files with 622 additions and 481 deletions
-3
View File
@@ -1,3 +0,0 @@
# Biz
本目录只包含领域对象、用例和仓储接口,不依赖 HTTP Handler、数据库驱动或对象存储 SDK。跨多表、必须原子完成的业务动作通过具有业务含义的仓储方法表达。
-20
View File
@@ -1,20 +0,0 @@
package biz
import "context"
type AccessControlUsecase struct {
authorities AuthorityAccessRepo
apis APIRepo
}
func NewAccessControlUsecase(authorities AuthorityAccessRepo, apis APIRepo) *AccessControlUsecase {
return &AccessControlUsecase{authorities: authorities, apis: apis}
}
func (uc *AccessControlUsecase) Authorize(ctx context.Context, authorityID uint, path, method string) (bool, error) {
return uc.apis.Authorize(ctx, authorityID, path, method)
}
func (uc *AccessControlUsecase) ResolveDataScope(ctx context.Context, authorityID, userID uint) (DataScope, error) {
return uc.authorities.ResolveDataScope(ctx, authorityID, userID)
}
-19
View File
@@ -1,19 +0,0 @@
package biz
import "context"
type actorContextKey struct{}
type Actor struct {
UserID uint
AuthorityID uint
}
func NewActorContext(ctx context.Context, actor Actor) context.Context {
return context.WithValue(ctx, actorContextKey{}, actor)
}
func ActorFromContext(ctx context.Context) (Actor, bool) {
actor, ok := ctx.Value(actorContextKey{}).(Actor)
return actor, ok
}
-71
View File
@@ -1,71 +0,0 @@
package biz
import (
"context"
"encoding/json"
"time"
)
type Announcement struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Title string
Content string
UserID *int
Attachments json.RawMessage
}
type AnnouncementFilter struct {
Page, PageSize int
StartCreatedAt, EndCreatedAt *time.Time
}
type AnnouncementRepo interface {
Create(context.Context, *Announcement) error
Delete(context.Context, string) error
DeleteByIDs(context.Context, []string) error
Update(context.Context, *Announcement) error
Find(context.Context, string) (*Announcement, error)
List(context.Context, AnnouncementFilter) ([]*Announcement, int64, error)
UserOptions(context.Context) ([]UserOption, error)
}
type UserOption struct {
Label string
Value uint
}
type AnnouncementUsecase struct{ repo AnnouncementRepo }
func NewAnnouncementUsecase(repo AnnouncementRepo) *AnnouncementUsecase {
return &AnnouncementUsecase{repo: repo}
}
func (uc *AnnouncementUsecase) Create(ctx context.Context, item *Announcement) error {
return uc.repo.Create(ctx, item)
}
func (uc *AnnouncementUsecase) Update(ctx context.Context, item *Announcement) error {
return uc.repo.Update(ctx, item)
}
func (uc *AnnouncementUsecase) Delete(ctx context.Context, id string) error {
return uc.repo.Delete(ctx, id)
}
func (uc *AnnouncementUsecase) DeleteByIDs(ctx context.Context, ids []string) error {
return uc.repo.DeleteByIDs(ctx, ids)
}
func (uc *AnnouncementUsecase) Find(ctx context.Context, id string) (*Announcement, error) {
return uc.repo.Find(ctx, id)
}
func (uc *AnnouncementUsecase) List(ctx context.Context, filter AnnouncementFilter) ([]*Announcement, int64, error) {
return uc.repo.List(ctx, filter)
}
func (uc *AnnouncementUsecase) UserOptions(ctx context.Context) ([]UserOption, error) {
return uc.repo.UserOptions(ctx)
}
-115
View File
@@ -1,115 +0,0 @@
package biz
import (
"context"
"time"
)
type API struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Path string
Description string
APIGroup string
Method string
OrderKey string
Desc bool
StrictAll bool
}
type APIRepo interface {
CreateAPI(context.Context, *API) error
UpdateAPI(context.Context, *API) error
DeleteAPIs(context.Context, []uint) error
FindAPI(context.Context, uint) (*API, error)
ListAPIs(context.Context, int, int, *API) ([]*API, int64, error)
APIRoleIDs(context.Context, string, string) ([]uint, error)
SetAPIRoles(context.Context, string, string, []uint) error
CheckPolicyStore(context.Context) error
Authorize(context.Context, uint, string, string) (bool, error)
PolicyPaths(context.Context, uint) ([]*API, error)
SetPolicyPaths(context.Context, uint, []*API) error
IgnoredAPIs(context.Context) ([]*API, error)
SetAPIIgnored(context.Context, string, string, bool) error
ApplyAPISync(context.Context, []*API, []*API) error
}
type APIUsecase struct{ APIRepo }
func NewAPIUsecase(repo APIRepo) *APIUsecase { return &APIUsecase{APIRepo: repo} }
func (uc *APIUsecase) FreshCasbin(ctx context.Context) error {
return uc.CheckPolicyStore(ctx)
}
// 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
Ignored []*API
}
func (uc *APIUsecase) SyncAPIs(ctx context.Context, routes []*API) (*APISyncDiff, error) {
stored, _, err := uc.ListAPIs(ctx, 0, 0, nil)
if err != nil {
return nil, err
}
ignored, err := uc.IgnoredAPIs(ctx)
if err != nil {
return nil, err
}
key := func(value *API) string { return value.Method + " " + value.Path }
ignoreSet := make(map[string]bool, len(ignored))
for _, item := range ignored {
ignoreSet[key(item)] = true
}
routeSet := make(map[string]*API, len(routes))
for _, item := range routes {
if !ignoreSet[key(item)] {
routeSet[key(item)] = item
}
}
storedSet := make(map[string]*API, len(stored))
for _, item := range stored {
storedSet[key(item)] = item
}
// The sync API always returns JSON arrays for all three collections,
// including when no routes were added or removed.
diff := &APISyncDiff{
Added: make([]*API, 0),
Deleted: make([]*API, 0),
Ignored: ignored,
}
added := make(map[string]bool)
for _, item := range routes {
routeKey := key(item)
if ignoreSet[routeKey] || added[routeKey] {
continue
}
if storedSet[routeKey] == nil {
diff.Added = append(diff.Added, item)
added[routeKey] = true
}
}
for _, item := range stored {
storedKey := key(item)
// Ignoring an API removes it from the in-memory route comparison. If it
// already exists in sys_apis, the reference implementation therefore
// returns it in deleteApis while also returning it in ignoreApis.
if routeSet[storedKey] == nil {
diff.Deleted = append(diff.Deleted, item)
}
}
return diff, nil
}
-55
View File
@@ -1,55 +0,0 @@
package biz
import (
"context"
"errors"
"time"
)
type APIToken struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
UserID uint
AuthorityID uint
Token string
Status bool
ExpiresAt time.Time
Remark string
User *User
}
type APITokenRepo interface {
UserHasAuthority(context.Context, uint, uint) (*User, bool, error)
CreateAPIToken(context.Context, *APIToken) error
ListAPITokens(context.Context, int, int, uint, *bool) ([]*APIToken, int64, error)
DisableAPIToken(context.Context, uint) (string, error)
DisableAndBlacklistAPIToken(context.Context, uint) error
BlacklistToken(context.Context, string) error
IsTokenDisabled(context.Context, string) (bool, error)
}
type TokenUsecase struct{ APITokenRepo }
func NewTokenUsecase(repo APITokenRepo) *TokenUsecase {
return &TokenUsecase{APITokenRepo: repo}
}
func (uc *TokenUsecase) PrepareAPIToken(ctx context.Context, userID, authorityID uint, days int) (*User, time.Duration, error) {
user, allowed, err := uc.UserHasAuthority(ctx, userID, authorityID)
if err != nil {
return nil, 0, err
}
if !allowed {
return nil, 0, errors.New("用户不具备该角色权限")
}
duration := time.Duration(days) * 24 * time.Hour
if days == -1 {
duration = 100 * 365 * 24 * time.Hour
}
return user, duration, nil
}
func (uc *TokenUsecase) DisableToken(ctx context.Context, id uint) error {
return uc.APITokenRepo.DisableAndBlacklistAPIToken(ctx, id)
}
-125
View File
@@ -1,125 +0,0 @@
package biz
import (
"context"
"errors"
"time"
)
var (
ErrInvalidLogMonth = errors.New("日志月份格式不正确")
ErrInvalidLogDate = errors.New("日志日期格式不正确")
ErrInvalidLogPath = errors.New("日志文件路径不合法")
ErrLogFileNotFound = errors.New("日志文件不存在")
ErrLogFileUnreadable = errors.New("日志文件不可读取")
ErrLogRootUnavailable = errors.New("日志目录不可读取")
)
type OperationRecord struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
IP, Method, Path string
Status int
LatencyMS int64
Agent, ErrorMessage, Body, Response string
UserID uint
RequestID, TraceID, DeviceID string
User *User
}
type LoginLog struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Username, IP string
Status bool
ErrorMessage, Agent string
UserID uint
User *User
FilterByStatus bool
}
type DataAccessLog struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
EventType, TargetTable, Operation string
UserID, AuthorityID uint
Scope int
RequestID, Method, Path, Detail string
}
type LogDate struct {
Date string
FileCount int
}
type LogFile struct {
Path, Name string
Size int64
ModifiedAt time.Time
}
type LogContent struct {
Date, Path, Content string
LineCount int
NextCursor int64
HasMore, LimitedByBytes bool
Size int64
ModifiedAt time.Time
}
type LogFileRepo interface {
LogDates(context.Context, string) ([]LogDate, error)
LogFiles(context.Context, string) ([]LogFile, error)
LogContent(context.Context, string, string, *int64) (*LogContent, error)
}
type LogViewerUsecase struct{ LogFileRepo }
func NewLogViewerUsecase(repo LogFileRepo) *LogViewerUsecase {
return &LogViewerUsecase{LogFileRepo: repo}
}
type ErrorRecord struct {
ID uint
CreatedAt, UpdatedAt time.Time
Form, Info, Solution *string
Level string
RequestID, TraceID string
Status string
CreatedAtRange []time.Time
}
type AuditRecordRepo interface {
RecordOperation(context.Context, *OperationRecord) error
RecordLogin(context.Context, *LoginLog) error
RecordDataAccess(context.Context, *DataAccessLog) error
CreateError(context.Context, *ErrorRecord) error
}
type AuditQueryRepo interface {
ListOperations(context.Context, int, int, *OperationRecord) ([]*OperationRecord, int64, error)
FindOperation(context.Context, uint) (*OperationRecord, error)
DeleteOperations(context.Context, []int) error
ListLogins(context.Context, int, int, *LoginLog) ([]*LoginLog, int64, error)
FindLogin(context.Context, uint) (*LoginLog, error)
DeleteLogins(context.Context, []int) error
ListDataAccess(context.Context, int, int, *DataAccessLog) ([]*DataAccessLog, int64, error)
DeleteDataAccess(context.Context, []uint) error
UpdateError(context.Context, *ErrorRecord) error
DeleteErrors(context.Context, []uint) error
FindError(context.Context, uint) (*ErrorRecord, error)
ListErrors(context.Context, int, int, *ErrorRecord) ([]*ErrorRecord, int64, error)
}
type AuditUsecase struct{ AuditQueryRepo }
func NewAuditUsecase(repo AuditQueryRepo) *AuditUsecase {
return &AuditUsecase{AuditQueryRepo: repo}
}
type AuditRecorderUsecase struct{ AuditRecordRepo }
func NewAuditRecorderUsecase(repo AuditRecordRepo) *AuditRecorderUsecase {
return &AuditRecorderUsecase{AuditRecordRepo: repo}
}
-252
View File
@@ -1,252 +0,0 @@
package biz
import (
"context"
"errors"
"fmt"
"time"
)
var (
ErrAccountLocked = errors.New("account locked")
ErrCaptchaInvalid = errors.New("captcha invalid")
ErrTokenIssue = errors.New("token issue failed")
ErrLoginState = errors.New("login state failed")
)
type UserDisabledError struct{ UserID uint }
func (e *UserDisabledError) Error() string { return ErrUserDisabled.Error() }
func (e *UserDisabledError) Unwrap() error { return ErrUserDisabled }
type AccountLockedError struct{ Minutes int }
func (e *AccountLockedError) Error() string { return ErrAccountLocked.Error() }
func (e *AccountLockedError) Unwrap() error { return ErrAccountLocked }
type LoginAttempt struct {
Username string
Password string
CaptchaID string
Captcha string
IP string
Agent string
}
type AuthenticationResult struct {
User *User
Token string
ExpiresAt time.Time
NeedChangePassword bool
}
type TokenAuthentication struct {
Claims *AuthClaims
Refreshed *IssuedToken
}
type AuthenticationUsecase struct {
users *UserUsecase
security *SecurityUsecase
issuer TokenIssuer
audit AuditRecordRepo
}
func NewAuthenticationUsecase(users *UserUsecase, security *SecurityUsecase, issuer TokenIssuer, audit AuditRecordRepo) *AuthenticationUsecase {
return &AuthenticationUsecase{users: users, security: security, issuer: issuer, audit: audit}
}
func (uc *AuthenticationUsecase) recordLogin(ctx context.Context, attempt *LoginAttempt, status bool, message string, userID uint) {
_ = uc.audit.RecordLogin(ctx, &LoginLog{Username: attempt.Username, IP: attempt.IP, Status: status, ErrorMessage: message, Agent: attempt.Agent, UserID: userID})
}
func (uc *AuthenticationUsecase) Login(ctx context.Context, attempt *LoginAttempt) (*AuthenticationResult, error) {
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 {
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}
}
}
ipTTL := time.Hour
requireCaptcha := config == nil || config.CaptchaOpen == 0
if config != nil {
if config.CaptchaTimeout > 0 {
ipTTL = time.Duration(config.CaptchaTimeout) * time.Second
}
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) {
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
uc.recordLogin(ctx, attempt, false, "验证码错误", 0)
return nil, ErrCaptchaInvalid
}
user, err := uc.users.Login(ctx, attempt.Username, attempt.Password)
if err != nil {
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
if config != nil && config.LockEnable {
lockTTL := time.Duration(config.LockDuration) * time.Minute
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 {
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)
return nil, ErrInvalidCredentials
}
if user.Enable != 1 {
_, _ = uc.security.IncrementLoginIP(ctx, attempt.IP, ipTTL)
uc.recordLogin(ctx, attempt, false, "用户被禁止登录", user.ID)
return nil, &UserDisabledError{UserID: user.ID}
}
uc.security.ClearLoginState(ctx, attempt.Username)
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)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTokenIssue, err)
}
// Record a successful credential login immediately after issuing the JWT.
// Multipoint-session persistence happens afterwards, so a Redis/cache
// failure must not erase the successful-login audit event.
uc.recordLogin(ctx, attempt, true, "登录成功", user.ID)
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
}
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
// 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
}
// Role switching signs the current claims again after replacing only the
// authority ID. It must not reload mutable profile fields or reset JWT
// timing/buffer claims as if this were a fresh login.
issued, err := uc.issuer.ReissueToken(claims, authorityID)
if err != nil {
return nil, err
}
user := &User{ID: claims.ID, UUID: claims.UUID, Username: claims.Username, NickName: claims.NickName, AuthorityID: authorityID, MustChangePassword: claims.MustChangePwd}
return &AuthenticationResult{User: user, Token: issued.Value, ExpiresAt: issued.ExpiresAt, NeedChangePassword: claims.MustChangePwd}, nil
}
func (uc *AuthenticationUsecase) AuthenticateToken(ctx context.Context, token string) (*TokenAuthentication, error) {
// 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
}
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
}
issued, err := uc.issuer.IssueToken(user, claims.AuthorityID, user.MustChangePassword, 0)
if err != nil {
return result, 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
return result, nil
}
-250
View File
@@ -1,250 +0,0 @@
package biz
import (
"context"
"errors"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
type authenticationUserRepo struct {
UserRepo
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
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 }
func (*authenticationCache) Delete(context.Context, string) error { return nil }
func (*authenticationCache) Increment(context.Context, string, time.Duration) (int64, error) {
return 1, nil
}
type authenticationSettings struct{ RuntimeSettings }
func (*authenticationSettings) UseMultipoint() bool { return true }
type authenticationIssuer struct {
TokenIssuer
claims *AuthClaims
issuedUser *User
issuedMust bool
}
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
}
type authenticationAudit struct {
AuditRecordRepo
logins []*LoginLog
}
type switchAuthorityUserRepo struct {
UserRepo
setUserID, setAuthorityID uint
findCalls int
}
func (r *switchAuthorityUserRepo) SetUserAuthority(_ context.Context, userID, authorityID uint) error {
r.setUserID, r.setAuthorityID = userID, authorityID
return nil
}
func (r *switchAuthorityUserRepo) FindUserByID(context.Context, uint) (*User, error) {
r.findCalls++
return &User{NickName: "changed-in-database"}, nil
}
type switchAuthorityIssuer struct {
TokenIssuer
claims *AuthClaims
authorityID uint
}
func (i *switchAuthorityIssuer) ReissueToken(claims *AuthClaims, authorityID uint) (*IssuedToken, error) {
i.claims, i.authorityID = claims, authorityID
return &IssuedToken{Value: "switched", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
}
func (a *authenticationAudit) RecordLogin(_ context.Context, value *LoginLog) error {
a.logins = append(a.logins, value)
return nil
}
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 {
t.Fatal(err)
}
users := NewUserUsecase(&authenticationUserRepo{user: &User{ID: 1, Username: "admin", Password: string(hash), AuthorityID: 888, Enable: 1}})
cacheErr := errors.New("cache unavailable")
security := NewSecurityUsecase(&authenticationSecurityRepo{}, &authenticationCache{activeErr: cacheErr}, &authenticationSettings{}, nil)
audit := &authenticationAudit{}
uc := NewAuthenticationUsecase(users, security, &authenticationIssuer{}, audit)
_, err = uc.Login(context.Background(), &LoginAttempt{Username: "admin", Password: "secret", IP: "127.0.0.1", Agent: "test"})
if !errors.Is(err, ErrLoginState) {
t.Fatalf("expected login-state failure, got %v", err)
}
if len(audit.logins) != 1 || !audit.logins[0].Status || audit.logins[0].ErrorMessage != "登录成功" || audit.logins[0].UserID != 1 {
t.Fatalf("expected successful login audit before cache failure, got %+v", audit.logins)
}
}
func TestSwitchAuthorityReissuesCurrentClaimsWithoutReloadingUser(t *testing.T) {
repo := &switchAuthorityUserRepo{}
issuer := &switchAuthorityIssuer{}
uc := NewAuthenticationUsecase(NewUserUsecase(repo), nil, issuer, nil)
expiresAt := time.Now().Add(time.Hour).Truncate(time.Second)
claims := &AuthClaims{ID: 7, UUID: "uuid", Username: "admin", NickName: "token-nickname", AuthorityID: 888, MustChangePwd: true, ExpiresAt: expiresAt}
result, err := uc.SwitchAuthority(context.Background(), claims, 999)
if err != nil {
t.Fatal(err)
}
if repo.setUserID != 7 || repo.setAuthorityID != 999 {
t.Fatalf("stored authority switch = user:%d authority:%d", repo.setUserID, repo.setAuthorityID)
}
if repo.findCalls != 0 {
t.Fatalf("switch reloaded user %d times", repo.findCalls)
}
if issuer.claims != claims || issuer.authorityID != 999 {
t.Fatalf("reissue input = claims:%p authority:%d", issuer.claims, issuer.authorityID)
}
if result.User.NickName != "token-nickname" || result.User.AuthorityID != 999 || !result.NeedChangePassword || !result.ExpiresAt.Equal(expiresAt) {
t.Fatalf("switch result = %+v", result)
}
}
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)
}
}
-74
View File
@@ -1,74 +0,0 @@
package biz
import (
"context"
"time"
)
type AuthorityAccessRepo interface {
CreateAuthority(context.Context, *Authority) error
CopyAuthority(context.Context, uint, *Authority) error
UpdateAuthority(context.Context, *Authority) error
DeleteAuthority(context.Context, uint) error
ListAuthorities(context.Context) ([]*Authority, error)
SetAuthorityUsers(context.Context, uint, []uint) error
AuthorityUserIDs(context.Context, uint) ([]uint, error)
SetDataScope(context.Context, uint, int, []uint) error
DataScopeDepartmentIDs(context.Context, uint) ([]uint, error)
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
Menus []*Menu
}
type AuthorityUsecase struct{ AuthorityAccessRepo }
func NewAuthorityUsecase(repo AuthorityAccessRepo) *AuthorityUsecase {
return &AuthorityUsecase{AuthorityAccessRepo: repo}
}
func (uc *AuthorityUsecase) AuthorityTree(ctx context.Context) ([]*Authority, error) {
items, err := uc.ListAuthorities(ctx)
if err != nil {
return nil, err
}
byID := make(map[uint]*Authority, len(items))
for _, item := range items {
item.Children = nil
byID[item.AuthorityID] = item
}
roots := make([]*Authority, 0)
for _, item := range items {
if item.ParentID != nil && *item.ParentID != 0 && byID[*item.ParentID] != nil {
parent := byID[*item.ParentID]
parent.Children = append(parent.Children, item)
} else if item.ParentID != nil && *item.ParentID == 0 {
roots = append(roots, item)
}
}
// 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 {
for _, item := range items {
if item.ParentID != nil && *item.ParentID == actor.AuthorityID {
roots = append(roots, item)
}
}
}
}
return roots, nil
}
-6
View File
@@ -1,6 +0,0 @@
package biz
import "github.com/google/wire"
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(NewUserUsecase, NewAuthenticationUsecase, NewSystemConfigUsecase, NewAuthorityUsecase, NewAPIUsecase, NewPermissionUsecase, NewAccessControlUsecase, NewMenuUsecase, NewDepartmentUsecase, NewPositionUsecase, NewDictionaryUsecase, NewParameterUsecase, NewTokenUsecase, NewSecurityUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewAuditRecorderUsecase, NewLogViewerUsecase, NewTaskUsecase, NewTaskApplicationUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase, NewPaymentUsecase)
-27
View File
@@ -1,27 +0,0 @@
package biz
import "context"
type DataScope struct {
All bool
Scope int
UserID uint
AuthorityID uint
PrimaryDeptID uint
OwnerUserID uint
DepartmentIDs []uint
RequestID string
Method string
Path string
}
type dataScopeKey struct{}
func NewDataScopeContext(ctx context.Context, scope DataScope) context.Context {
return context.WithValue(ctx, dataScopeKey{}, scope)
}
func DataScopeFromContext(ctx context.Context) (DataScope, bool) {
scope, ok := ctx.Value(dataScopeKey{}).(DataScope)
return scope, ok
}
-46
View File
@@ -1,46 +0,0 @@
package biz
import (
"context"
"time"
)
type Department struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
ParentID uint
Ancestors string
Sort int
LeaderID uint
Leader *User
Status *bool
Children []*Department
NamePath string
}
type DepartmentRepo interface {
CreateDepartment(context.Context, *Department) error
UpdateDepartment(context.Context, *Department) error
DeleteDepartment(context.Context, uint) error
FindDepartment(context.Context, uint) (*Department, error)
ListDepartments(context.Context, string) ([]*Department, error)
DepartmentUserIDs(context.Context, uint) ([]uint, error)
SetDepartmentUsers(context.Context, uint, []uint) error
SetUserDepartments(context.Context, uint, []uint, uint) error
}
type DepartmentUsecase struct{ DepartmentRepo }
func NewDepartmentUsecase(repo DepartmentRepo) *DepartmentUsecase {
return &DepartmentUsecase{DepartmentRepo: repo}
}
func (uc *DepartmentUsecase) Departments(ctx context.Context, name string) ([]*Department, error) {
return uc.ListDepartments(ctx, name)
}
func (uc *DepartmentUsecase) Department(ctx context.Context, id uint) (*Department, error) {
return uc.FindDepartment(ctx, id)
}
-83
View File
@@ -1,83 +0,0 @@
package biz
import (
"context"
"time"
)
type DictionaryDetail struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Label string
Value string
Extend string
Status *bool
Sort int
DictionaryID uint
ParentID *uint
Level int
Path string
Children []*DictionaryDetail
}
type DictionaryDetailFilter struct {
DictionaryID uint
Label string
Value string
Status *bool
ParentID *uint
Level *int
}
type DictionaryDetailRepo interface {
CreateDictionaryDetail(context.Context, *DictionaryDetail) error
UpdateDictionaryDetail(context.Context, *DictionaryDetail) error
DeleteDictionaryDetail(context.Context, uint) error
FindDictionaryDetail(context.Context, uint) (*DictionaryDetail, error)
ListDictionaryDetails(context.Context, int, int, DictionaryDetailFilter) ([]*DictionaryDetail, int64, error)
DictionaryDetailTree(context.Context, uint, string) ([]*DictionaryDetail, error)
DictionaryDetailsByParent(context.Context, uint, *uint, bool) ([]*DictionaryDetail, error)
}
type Dictionary struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
Type string
Status *bool
Desc string
ParentID *uint
Children []*Dictionary
Details []*DictionaryDetail
}
type DictionaryRepo interface {
DictionaryMetadataRepo
DictionaryDetailRepo
}
type DictionaryUsecase struct{ DictionaryRepo }
func NewDictionaryUsecase(repo DictionaryRepo) *DictionaryUsecase {
return &DictionaryUsecase{DictionaryRepo: repo}
}
type DictionaryMetadataRepo interface {
CreateDictionary(context.Context, *Dictionary) error
ImportDictionary(context.Context, *Dictionary, []*DictionaryDetail) error
UpdateDictionary(context.Context, *Dictionary) error
DeleteDictionary(context.Context, uint) error
FindDictionary(context.Context, uint, string, *bool, bool) (*Dictionary, error)
ExportDictionary(context.Context, uint) (*Dictionary, error)
ListDictionaries(context.Context, int, int, string, string, bool) ([]*Dictionary, int64, error)
}
func (uc *DictionaryUsecase) ImportDictionary(ctx context.Context, dictionary *Dictionary, details []*DictionaryDetail) error {
return uc.DictionaryRepo.ImportDictionary(ctx, dictionary, details)
}
func (uc *DictionaryUsecase) DictionaryDetailsByParent(ctx context.Context, dictionaryID uint, parentID *uint, includeChildren bool) ([]*DictionaryDetail, error) {
return uc.DictionaryRepo.DictionaryDetailsByParent(ctx, dictionaryID, parentID, includeChildren)
}
-48
View File
@@ -1,48 +0,0 @@
package biz
import (
"context"
"errors"
"strings"
)
type EmailRepo interface {
Send(context.Context, []string, string, string) error
DefaultRecipients() []string
Enabled() bool
}
type EmailUsecase struct{ repo EmailRepo }
func NewEmailUsecase(repo EmailRepo) *EmailUsecase { return &EmailUsecase{repo: repo} }
func (uc *EmailUsecase) Send(ctx context.Context, to, subject, body string) error {
return uc.repo.Send(ctx, strings.Split(to, ","), subject, body)
}
func (uc *EmailUsecase) Test(ctx context.Context) error {
recipients := uc.repo.DefaultRecipients()
if len(recipients) == 0 {
return errors.New("未配置测试邮件收件人")
}
return uc.repo.Send(ctx, recipients, "test", "test")
}
func (uc *EmailUsecase) Alert(ctx context.Context, subject, body string) error {
if !uc.repo.Enabled() {
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
View File
@@ -1,13 +0,0 @@
package biz
import (
"github.com/go-kratos/kratos/v3/errors"
)
var (
ErrUserNotFound = errors.NotFound("USER_NOT_FOUND", "user not found")
// ErrInvalidCredentials is returned for any failed login, regardless of
// whether the user exists, to avoid leaking which accounts are registered.
ErrInvalidCredentials = errors.Unauthorized("INVALID_CREDENTIALS", "invalid credentials")
ErrUserDisabled = errors.Forbidden("USER_DISABLED", "user disabled")
)
-45
View File
@@ -1,45 +0,0 @@
package biz
import (
"context"
"time"
)
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
UpdatedAt time.Time
DBName, Name, TableName, TemplateID, TemplateInfo, SQL, ImportSQL string
Limit *int
Order string
Conditions []ExportCondition
Joins []ExportJoin
StartCreatedAt *time.Time
EndCreatedAt *time.Time
}
type ExportRepo interface {
CreateExportTemplate(context.Context, *ExportTemplate) error
UpdateExportTemplate(context.Context, *ExportTemplate) error
DeleteExportTemplates(context.Context, []uint) error
FindExportTemplate(context.Context, uint, string) (*ExportTemplate, error)
ListExportTemplates(context.Context, int, int, *ExportTemplate) ([]*ExportTemplate, int64, error)
QueryExport(context.Context, *ExportTemplate, map[string]string) ([]map[string]any, string, error)
PreviewExport(context.Context, *ExportTemplate, map[string]string) (string, error)
ImportExportRows(context.Context, *ExportTemplate, []map[string]any) error
}
type ExportUsecase struct{ ExportRepo }
func NewExportUsecase(repo ExportRepo) *ExportUsecase { return &ExportUsecase{ExportRepo: repo} }
-114
View File
@@ -1,114 +0,0 @@
package biz
import (
"context"
"errors"
"io"
"time"
)
// Cache is the shared cache seam. The data implementation uses Redis when it
// is reachable and falls back to process memory for local development.
type Cache interface {
Get(context.Context, string) (string, bool, error)
Set(context.Context, string, string, time.Duration) error
Delete(context.Context, string) error
Increment(context.Context, string, time.Duration) (int64, error)
}
type StoredFile struct {
Name string
Path string
URL string
Size int64
LastModified time.Time
ContentType string
}
// FileStorage owns the persistence boundary for uploaded files.
type FileStorage interface {
Put(context.Context, string, io.Reader) (*StoredFile, error)
Open(context.Context, string) (io.ReadCloser, error)
Delete(context.Context, string) error
Compose(context.Context, []string, string) (*StoredFile, string, error)
DeletePrefix(context.Context, string) error
List(context.Context, string, string, int) ([]*StoredFile, string, bool, error)
}
type JWTSettings struct {
SigningKey string
Issuer string
Expires time.Duration
Buffer time.Duration
}
type CaptchaSettings struct {
KeyLong int
ImageWidth int
ImageHeight int
StoreExpiration time.Duration
}
type MediaSettings struct {
SessionTTL int
MaxFileSize int64
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.
type RuntimeSettings interface {
RouterPrefix() string
JWTSettings() JWTSettings
CaptchaSettings() CaptchaSettings
MediaSettings() MediaSettings
UseMultipoint() bool
}
type IssuedToken struct {
Value string
ExpiresAt time.Time
TTL time.Duration
}
type AuthClaims struct {
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 (
ErrTokenExpired = errors.New("token expired")
ErrTokenMalformed = errors.New("token malformed")
ErrTokenSignatureInvalid = errors.New("token signature invalid")
ErrTokenNotValidYet = errors.New("token not valid yet")
ErrTokenInvalid = errors.New("token invalid")
ErrTokenDisabled = errors.New("token disabled")
)
type TokenIssuer interface {
IssueToken(*User, uint, bool, time.Duration) (*IssuedToken, error)
ReissueToken(*AuthClaims, uint) (*IssuedToken, error)
ParseToken(string) (*AuthClaims, error)
}
-195
View File
@@ -1,195 +0,0 @@
package biz
import (
"bufio"
"context"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
type MediaRepo interface {
MediaMetadataRepo
UploadRepo
}
type MediaUsecase struct {
MediaRepo
files FileStorage
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,
".mp4": true, ".webm": true, ".mov": true, ".avi": true, ".mkv": true,
".txt": true, ".md": true, ".csv": true, ".json": true, ".log": true, ".pdf": true,
".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true,
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true, ".tgz": true, ".bin": true,
}
func validateMediaName(name string) error {
if name == "" || strings.TrimSpace(name) != name || strings.ContainsAny(name, `/\`) {
return errors.New("file extension is not allowed")
}
if !allowedMediaExtensions[strings.ToLower(filepath.Ext(name))] {
return errors.New("file extension is not allowed")
}
return nil
}
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)
// 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()
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)
if countErr != nil {
return nil, countErr
}
if count == 0 {
if err = uc.CreateMedia(ctx, media); err != nil {
return nil, err
}
}
}
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 {
return err
}
count, err := uc.MediaKeyReferences(ctx, media.Key)
if err != nil {
return err
}
if count <= 1 {
if err = uc.files.Delete(ctx, media.Key); err != nil {
return err
}
}
return uc.DeleteMedia(ctx, id)
}
func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, limit int) ([]*StoredFile, string, bool, error) {
if limit <= 0 {
limit = 100
}
// Multipart chunks are an internal staging detail. The compatible local
// implementation keeps them outside the configured object-storage root, so
// the public listing must not expose them even when the selected backend is
// also used for staging.
chunkRoot := "uploads/chunks"
if uc.settings != nil {
if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" {
chunkRoot = configured
}
}
chunkRoot = strings.Trim(chunkRoot, "/") + "/"
result := make([]*StoredFile, 0, limit)
nextCursor := cursor
for {
// Ask only for the remaining visible capacity. Otherwise a page that is
// partially filtered could fill the response halfway through the next
// storage page while returning that page's end cursor, skipping objects.
remaining := limit - len(result)
items, next, more, err := uc.files.List(ctx, prefix, nextCursor, remaining)
if err != nil {
return nil, "", false, err
}
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
}
}
-53
View File
@@ -1,53 +0,0 @@
package biz
import (
"context"
"time"
)
type MediaFile struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
CategoryID int
URL, Tag, Key string
Size int64
Mime, MD5 string
UserID uint
}
type MediaFilter struct {
Page, PageSize int
Keyword string
CategoryID int
Tag string
UserID uint
StartCreatedAt time.Time
EndCreatedAt time.Time
OrderKey string
Desc bool
}
type AttachmentCategory struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
ParentID uint
Children []*AttachmentCategory
}
type MediaMetadataRepo interface {
CreateMedia(context.Context, *MediaFile) error
FindMedia(context.Context, uint) (*MediaFile, error)
FindMediaByHash(context.Context, uint, string) (*MediaFile, error)
ListMedia(context.Context, MediaFilter) ([]*MediaFile, int64, error)
UpdateMediaName(context.Context, uint, string) error
DeleteMedia(context.Context, uint) error
MediaKeyReferences(context.Context, string) (int64, error)
CreateMediaBatch(context.Context, []*MediaFile) error
SaveCategory(context.Context, *AttachmentCategory) error
DeleteCategory(context.Context, uint) error
ListCategories(context.Context) ([]*AttachmentCategory, error)
}
-250
View File
@@ -1,250 +0,0 @@
package biz
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"strings"
"testing"
)
type listStorage struct {
items []*StoredFile
limits []int
}
func (*listStorage) Put(context.Context, string, io.Reader) (*StoredFile, error) {
return nil, nil
}
func (*listStorage) Open(context.Context, string) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("")), nil
}
func (*listStorage) Delete(context.Context, string) error { return nil }
func (*listStorage) Compose(context.Context, []string, string) (*StoredFile, string, error) {
return nil, "", nil
}
func (*listStorage) DeletePrefix(context.Context, string) error { return nil }
func (s *listStorage) List(_ context.Context, _ string, cursor string, limit int) ([]*StoredFile, string, bool, error) {
s.limits = append(s.limits, limit)
start := 0
for start < len(s.items) && s.items[start].Path <= cursor {
start++
}
end := start + limit
if end > len(s.items) {
end = len(s.items)
}
page := s.items[start:end]
more := end < len(s.items)
next := ""
if len(page) > 0 {
next = page[len(page)-1].Path
}
return page, next, more, nil
}
func TestListStorageFiltersChunksWithoutSkippingVisibleObjects(t *testing.T) {
storage := &listStorage{items: []*StoredFile{
{Path: "uploads/chunks/1/00000000"},
{Path: "uploads/chunks/1/00000001"},
{Path: "visible/a"},
{Path: "visible/b"},
{Path: "visible/c"},
{Path: "visible/d"},
{Path: "visible/e"},
}}
uc := NewMediaUsecase(nil, storage, nil)
first, cursor, more, err := uc.ListStorage(context.Background(), "", "", 3)
if err != nil {
t.Fatal(err)
}
if got := []string{first[0].Path, first[1].Path, first[2].Path}; strings.Join(got, ",") != "visible/a,visible/b,visible/c" {
t.Fatalf("first page = %v", got)
}
if cursor != "visible/c" || !more {
t.Fatalf("first cursor/more = %q/%v", cursor, more)
}
if len(storage.limits) != 2 || storage.limits[0] != 3 || storage.limits[1] != 2 {
t.Fatalf("storage limits = %v, want [3 2]", storage.limits)
}
second, cursor, more, err := uc.ListStorage(context.Background(), "", cursor, 3)
if err != nil {
t.Fatal(err)
}
if got := []string{second[0].Path, second[1].Path}; strings.Join(got, ",") != "visible/d,visible/e" {
t.Fatalf("second page = %v", got)
}
if cursor != "visible/e" || more {
t.Fatalf("second cursor/more = %q/%v", cursor, more)
}
}
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")
}
}
-268
View File
@@ -1,268 +0,0 @@
package biz
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/google/uuid"
)
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 {
if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" {
directory = configured
}
}
return path.Join(directory, fmt.Sprintf("%d", uploadID))
}
func (uc *MediaUsecase) chunkKey(uploadID uint, index int) string {
return path.Join(uc.chunkPrefix(uploadID), fmt.Sprintf("%08d", index))
}
func (uc *MediaUsecase) InitUpload(ctx context.Context, userID uint, name, hash string, size, chunkSize int64, total int) (*UploadSession, *MediaFile, []int, error) {
if err := validateMediaName(name); err != nil {
return nil, nil, nil, err
}
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"}
if err = uc.CreateUploadSession(ctx, session); err != nil {
return nil, nil, nil, err
}
} 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 {
return nil, nil, nil, err
}
indices := make([]int, 0, len(chunks))
for _, v := range chunks {
indices = append(indices, v.Index)
}
sort.Ints(indices)
return session, nil, indices, nil
}
func (uc *MediaUsecase) SaveChunk(ctx context.Context, userID, uploadID uint, index int, expected string, reader io.Reader) error {
session, err := uc.FindUploadSession(ctx, uploadID)
if err != nil {
return errors.New("上传会话不存在")
}
if session.UserID != userID {
return errors.New("无权操作该上传")
}
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-*")
if err != nil {
return err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
defer temporary.Close()
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 !strings.EqualFold(actual, expected) {
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
}
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)
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 {
return nil, err
}
if !claimed {
return nil, errors.New("上传不在可合并状态(可能已在合并或已完成)")
}
fail := func(value error) (*MediaFile, error) {
_ = uc.FailUploadSession(ctx, uploadID)
return nil, value
}
chunks, err := uc.ListChunks(ctx, uploadID)
if err != nil || len(chunks) != session.ChunkTotal {
return fail(fmt.Errorf("分片不全: %d/%d", len(chunks), session.ChunkTotal))
}
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 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("整文件校验失败"))
}
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)
}
_ = uc.CompleteUploadSession(ctx, uploadID, key, media.ID)
_ = uc.DeleteChunks(ctx, uploadID)
_ = uc.files.DeletePrefix(ctx, uc.chunkPrefix(uploadID))
return media, nil
}
func (uc *MediaUsecase) CancelUpload(ctx context.Context, userID, uploadID uint) error {
session, err := uc.FindUploadSession(ctx, uploadID)
if err != nil {
return errors.New("上传会话不存在")
}
if session.UserID != userID {
return errors.New("无权操作该上传")
}
_ = uc.DeleteChunks(ctx, uploadID)
_ = uc.files.DeletePrefix(ctx, uc.chunkPrefix(uploadID))
return uc.DeleteUploadSession(ctx, uploadID)
}
func (uc *MediaUsecase) CleanupStale(ctx context.Context, ttlHours int) error {
if ttlHours <= 0 {
ttlHours = 24
}
// The reference cleanup job is best-effort: a stale-session query or an
// individual storage/database cleanup failure is not propagated to the
// scheduler. Keep the endpoint-independent background behavior compatible.
ids, _ := uc.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour))
for _, id := range ids {
_ = uc.DeleteUploadData(ctx, id)
_ = uc.files.DeletePrefix(ctx, uc.chunkPrefix(id))
}
return nil
}
-138
View File
@@ -1,138 +0,0 @@
package biz
import (
"context"
"time"
)
type MenuButton struct {
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
ID uint
Name string
Description string
MenuID uint
AuthorityID uint
}
type MenuParameter struct {
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
Hidden bool
Component string
Sort int
ActiveName string
KeepAlive bool
DefaultMenu bool
Title string
Icon string
CloseTab bool
TransitionType string
Children []*Menu
Buttons []*MenuButton
Parameters []*MenuParameter
}
type MenuRepo interface {
Create(context.Context, *Menu) error
Update(context.Context, *Menu) error
Delete(context.Context, uint) error
Find(context.Context, uint) (*Menu, error)
List(context.Context) ([]*Menu, error)
SetAuthorityMenus(context.Context, uint, []uint) error
AuthorityMenuIDs(context.Context, uint) ([]uint, error)
// ListAuthorityMenus returns the base menus assigned to the target
// authority. Unlike List, it must not apply the current actor's strict
// menu filter: the administration endpoint is explicitly querying another
// role's assignments.
ListAuthorityMenus(context.Context, uint) ([]*Menu, error)
MenuRoleIDs(context.Context, uint) ([]uint, error)
DefaultRouterRoleIDs(context.Context, uint) ([]uint, error)
SetMenuRoles(context.Context, uint, []uint) error
}
type MenuUsecase struct{ repo MenuRepo }
func NewMenuUsecase(repo MenuRepo) *MenuUsecase { return &MenuUsecase{repo: repo} }
func (uc *MenuUsecase) List(ctx context.Context) ([]*Menu, error) {
return uc.repo.List(ctx)
}
func (uc *MenuUsecase) Tree(ctx context.Context) ([]*Menu, error) {
items, err := uc.repo.List(ctx)
if err != nil {
return nil, err
}
byID := make(map[uint]*Menu, len(items))
for _, item := range items {
item.Children = nil
byID[item.ID] = item
}
var roots []*Menu
for _, item := range items {
if parent := byID[item.ParentID]; parent != nil {
parent.Children = append(parent.Children, item)
} else if item.ParentID == 0 {
roots = append(roots, item)
}
}
return roots, nil
}
func (uc *MenuUsecase) Create(ctx context.Context, menu *Menu) error {
return uc.repo.Create(ctx, menu)
}
func (uc *MenuUsecase) Update(ctx context.Context, menu *Menu) error {
return uc.repo.Update(ctx, menu)
}
func (uc *MenuUsecase) Delete(ctx context.Context, id uint) error {
return uc.repo.Delete(ctx, id)
}
func (uc *MenuUsecase) Find(ctx context.Context, id uint) (*Menu, error) {
return uc.repo.Find(ctx, id)
}
func (uc *MenuUsecase) SetAuthorityMenus(ctx context.Context, authorityID uint, ids []uint) error {
return uc.repo.SetAuthorityMenus(ctx, authorityID, ids)
}
func (uc *MenuUsecase) AuthorityMenus(ctx context.Context, authorityID uint) ([]*Menu, error) {
items, err := uc.repo.ListAuthorityMenus(ctx, authorityID)
if err != nil {
return nil, err
}
return items, nil
}
func (uc *MenuUsecase) RoleIDs(ctx context.Context, menuID uint) ([]uint, error) {
return uc.repo.MenuRoleIDs(ctx, menuID)
}
func (uc *MenuUsecase) DefaultRouterRoleIDs(ctx context.Context, menuID uint) ([]uint, error) {
return uc.repo.DefaultRouterRoleIDs(ctx, menuID)
}
func (uc *MenuUsecase) SetRoles(ctx context.Context, menuID uint, ids []uint) error {
return uc.repo.SetMenuRoles(ctx, menuID, ids)
}
-39
View File
@@ -1,39 +0,0 @@
package biz
import (
"go.einride.tech/aip/filtering"
"go.einride.tech/aip/ordering"
)
type ListOption func(*ListOptions)
type ListOptions struct {
Filter filtering.Filter
OrderBy ordering.OrderBy
Offset int
Limit int
}
func ListFilter(filter filtering.Filter) ListOption {
return func(o *ListOptions) {
o.Filter = filter
}
}
func ListOrderBy(orderBy ordering.OrderBy) ListOption {
return func(o *ListOptions) {
o.OrderBy = orderBy
}
}
func ListOffset(offset int) ListOption {
return func(o *ListOptions) {
o.Offset = offset
}
}
func ListLimit(limit int) ListOption {
return func(o *ListOptions) {
o.Limit = limit
}
}
-33
View File
@@ -1,33 +0,0 @@
package biz
import (
"context"
"time"
)
type SystemParameter struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
Key string
Value string
Desc string
StartCreatedAt *time.Time
EndCreatedAt *time.Time
}
type ParameterRepo interface {
CreateParameter(context.Context, *SystemParameter) error
UpdateParameter(context.Context, *SystemParameter) error
DeleteParameters(context.Context, []string) error
FindParameterByID(context.Context, string) (*SystemParameter, error)
FindParameterByKey(context.Context, string) (*SystemParameter, error)
ListParameters(context.Context, int, int, *SystemParameter) ([]*SystemParameter, int64, error)
}
type ParameterUsecase struct{ ParameterRepo }
func NewParameterUsecase(repo ParameterRepo) *ParameterUsecase {
return &ParameterUsecase{ParameterRepo: repo}
}
File diff suppressed because it is too large Load Diff
-32
View File
@@ -1,32 +0,0 @@
package biz
import (
"context"
"log/slog"
)
// PaymentLogger isolates payment audit logging from the payment workflow and
// lets an application replace slog with its own structured logger.
type PaymentLogger interface {
Info(context.Context, string, ...any)
Error(context.Context, string, ...any)
}
type slogPaymentLogger struct {
logger *slog.Logger
}
func NewPaymentLogger(logger *slog.Logger) PaymentLogger {
if logger == nil {
logger = slog.Default()
}
return &slogPaymentLogger{logger: logger.With("mod", "payment")}
}
func (l *slogPaymentLogger) Info(ctx context.Context, message string, args ...any) {
l.logger.InfoContext(ctx, message, args...)
}
func (l *slogPaymentLogger) Error(ctx context.Context, message string, args ...any) {
l.logger.ErrorContext(ctx, message, args...)
}
-220
View File
@@ -1,220 +0,0 @@
package biz
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
)
const (
PaymentStatusInitialized = "initialized"
PaymentStatusPending = "pending"
PaymentStatusPaid = "paid"
PaymentStatusFailed = "failed"
PaymentStatusClosed = "closed"
PaymentStatusPartiallyRefunded = "partially_refunded"
PaymentStatusRefunded = "refunded"
FulfillmentStatusPending = "pending"
FulfillmentStatusProcessing = "processing"
FulfillmentStatusSucceeded = "succeeded"
FulfillmentStatusFailed = "failed"
RefundStatusNone = "none"
RefundStatusProcessing = "processing"
RefundStatusPending = "pending"
RefundStatusPartial = "partial"
RefundStatusSucceeded = "succeeded"
RefundStatusFailed = "failed"
)
var (
ErrPaymentOrderNotFound = errors.New("支付订单不存在")
ErrPaymentOrderConflict = errors.New("支付订单参数冲突")
ErrPaymentOrderBusy = errors.New("支付订单正在处理中")
ErrPaymentOrderState = errors.New("支付订单状态不允许当前操作")
ErrPaymentProviderConflict = errors.New("支付平台交易号冲突")
)
// PaymentOrder is one payment attempt. Business modules own their domain
// orders and link to this record through BusinessType and BusinessID.
type PaymentOrder struct {
ID uint64
TradeNo string
Provider string
ProviderTradeNo string
QueryID string
BusinessType string
BusinessID string
Subject string
PaymentMode string
OriginalAmount int64
Amount int64
PaidAmount int64
PayerPaidAmount int64
CashPaidAmount int64
PointPaidAmount int64
DiscountAmount int64
ProviderDiscountAmount int64
MerchantDiscountAmount int64
SettlementAmount int64
Currency string
PayerCurrency string
AmountBreakdownKnown bool
PaymentStatus string
ProviderStatus string
FulfillmentStatus string
RefundStatus string
RefundedAmount int64
RefundRequestedAmount int64
RefundNo string
ConfirmationID string
RequestFingerprint string
CreatePayload json.RawMessage
Extra json.RawMessage
LastEventID string
LastPayloadHash string
LastError string
FulfillmentToken string
RefundToken string
Version uint64
CreatedAt time.Time
UpdatedAt time.Time
PaidAt *time.Time
FulfilledAt *time.Time
RefundedAt *time.Time
FulfillmentLeaseUntil *time.Time
RefundLeaseUntil *time.Time
}
type PaymentProviderUpdate struct {
Status string
ProviderStatus string
ProviderTradeNo string
QueryID string
Amount int64
PayerPaidAmount int64
CashPaidAmount int64
PointPaidAmount int64
DiscountAmount int64
ProviderDiscountAmount int64
MerchantDiscountAmount int64
SettlementAmount int64
Currency string
PayerCurrency string
AmountBreakdownKnown bool
EventID string
PayloadHash string
CreatePayload json.RawMessage
}
type PaymentOrderFilter struct {
Provider string
TradeNo string
BusinessType string
BusinessID string
PaymentStatus string
RefundStatus string
}
type PaymentOrderRepo interface {
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error)
ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error)
RecordPaymentCreate(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
ApplyPaymentResult(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
BeginPaymentFulfillment(context.Context, string, string, time.Duration) (order *PaymentOrder, token string, duplicate bool, err error)
CompletePaymentFulfillment(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (order *PaymentOrder, token string, err error)
CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error)
}
// PaymentOrderSource is implemented by each business module. It returns the
// canonical order facts; client-provided amount, currency and subject are not
// trusted once the persistent payment-order flow is enabled.
type PaymentOrderSource interface {
Type() string
PreparePayment(context.Context, string, string, string) (*PaymentIntent, error)
}
type PaymentBusinessModule interface {
PaymentOrderSource
PaymentFulfillmentHandler
PaymentRefundAuthorizer
}
type PaymentRefundAuthorizer interface {
AuthorizeRefund(context.Context, *PaymentOrder, int64) error
}
type PaymentOrderSourceRegistry struct {
mu sync.RWMutex
sources map[string]PaymentOrderSource
}
func NewPaymentOrderSourceRegistry() *PaymentOrderSourceRegistry {
return &PaymentOrderSourceRegistry{sources: map[string]PaymentOrderSource{}}
}
func (r *PaymentOrderSourceRegistry) Register(source PaymentOrderSource) error {
if source == nil || strings.TrimSpace(source.Type()) == "" {
return errors.New("支付业务订单来源无效")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.sources[source.Type()]; exists {
return errors.New("支付业务订单来源已注册: " + source.Type())
}
r.sources[source.Type()] = source
return nil
}
func (r *PaymentOrderSourceRegistry) Source(kind string) PaymentOrderSource {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.sources[kind]
}
func (r *PaymentOrderSourceRegistry) Unregister(kind string) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
delete(r.sources, kind)
}
func (o *PaymentOrder) Intent() *PaymentIntent {
if o == nil {
return nil
}
return &PaymentIntent{
Provider: o.Provider,
TradeNo: o.TradeNo,
BusinessType: o.BusinessType,
BusinessID: o.BusinessID,
Subject: o.Subject,
Amount: o.Amount,
Currency: o.Currency,
Extra: append(json.RawMessage(nil), o.Extra...),
PaymentMode: defaultPaymentMode(o.Provider, o.PaymentMode),
OriginalAmount: o.OriginalAmount,
}
}
func defaultPaymentMode(provider, mode string) string {
if strings.TrimSpace(mode) != "" {
return mode
}
if provider == PaymentInternal {
return PaymentModeInternal
}
return PaymentModeExternal
}
-375
View File
@@ -1,375 +0,0 @@
package biz
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"testing"
"time"
)
type paymentRepoStub struct {
create *PaymentResult
createdReq *PaymentRequest
refund *PaymentResult
refundErr error
refundedReq *PaymentRefundRequest
callback *PaymentResult
query *PaymentResult
queryCalls int
callbackErr error
}
func (r *paymentRepoStub) ListConfigs(context.Context) ([]*PaymentConfig, error) { return nil, nil }
func (r *paymentRepoStub) SaveConfig(context.Context, *PaymentConfig) error { return nil }
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
copyReq := *req
r.createdReq = &copyReq
return r.create, nil
}
type paymentOrderRepoStub struct {
order *PaymentOrder
}
func (r *paymentOrderRepoStub) CreatePaymentOrder(_ context.Context, order *PaymentOrder) (*PaymentOrder, bool, error) {
if r.order != nil {
return r.order, false, nil
}
copyOrder := *order
r.order = &copyOrder
return r.order, true, nil
}
func (r *paymentOrderRepoStub) FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error) {
if r.order == nil {
return nil, ErrPaymentOrderNotFound
}
return r.order, nil
}
func (r *paymentOrderRepoStub) ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error) {
if r.order == nil {
return []*PaymentOrder{}, 0, nil
}
return []*PaymentOrder{r.order}, 1, nil
}
func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
r.order.PaymentStatus = PaymentStatusPending
r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...)
return r.order, nil
}
func (r *paymentOrderRepoStub) ApplyPaymentResult(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
if update.Status == "success" {
r.order.PaymentStatus = PaymentStatusPaid
r.order.PaidAmount = r.order.Amount
r.order.ProviderTradeNo = update.ProviderTradeNo
}
return r.order, nil
}
func (r *paymentOrderRepoStub) BeginPaymentFulfillment(context.Context, string, string, time.Duration) (*PaymentOrder, string, bool, error) {
if r.order.FulfillmentStatus == FulfillmentStatusSucceeded {
return r.order, "", true, nil
}
r.order.FulfillmentStatus = FulfillmentStatusProcessing
return r.order, "token", false, nil
}
func (r *paymentOrderRepoStub) CompletePaymentFulfillment(_ context.Context, _, _, _ string, success bool, _ string) (*PaymentOrder, error) {
if success {
r.order.FulfillmentStatus = FulfillmentStatusSucceeded
} else {
r.order.FulfillmentStatus = FulfillmentStatusFailed
}
return r.order, nil
}
func (r *paymentOrderRepoStub) BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (*PaymentOrder, string, error) {
r.order.RefundNo = "refund-1"
r.order.RefundStatus = RefundStatusProcessing
return r.order, "refund-token", nil
}
func (r *paymentOrderRepoStub) CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error) {
r.order.RefundStatus = RefundStatusPending
return r.order, nil
}
func (r *paymentOrderRepoStub) ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error) {
return r.order, nil
}
type paymentSourceStub struct{ intent *PaymentIntent }
func (s *paymentSourceStub) Type() string { return s.intent.BusinessType }
func (s *paymentSourceStub) PreparePayment(context.Context, string, string, string) (*PaymentIntent, error) {
return s.intent, nil
}
func (r *paymentRepoStub) Query(context.Context, string, string) (*PaymentResult, error) {
r.queryCalls++
return r.query, nil
}
func (r *paymentRepoStub) Refund(_ context.Context, req *PaymentRefundRequest) (*PaymentResult, error) {
if req != nil {
copyReq := *req
r.refundedReq = &copyReq
}
return r.refund, r.refundErr
}
func (r *paymentRepoStub) HandleCallback(context.Context, *PaymentCallback) (*PaymentResult, error) {
return r.callback, r.callbackErr
}
type paymentFulfillmentStub struct {
calls int
failFirst bool
confirmIDs []string
}
func (h *paymentFulfillmentStub) Type() string { return "game_item" }
func (h *paymentFulfillmentStub) AuthorizeRefund(context.Context, *PaymentOrder, int64) error {
return nil
}
func (h *paymentFulfillmentStub) Fulfill(_ context.Context, confirmation *PaymentConfirmation) error {
h.calls++
h.confirmIDs = append(h.confirmIDs, confirmation.ID)
if h.failFirst && h.calls == 1 {
return errors.New("temporary fulfillment failure")
}
return nil
}
func newPaymentUsecaseForTest(repo PaymentRepo) *PaymentUsecase {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewPaymentUsecase(repo, &paymentOrderRepoStub{order: testBizPaymentOrder()}, logger)
}
func testBizPaymentOrder() *PaymentOrder {
return &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}
}
func successPaymentResult() *PaymentResult {
return &PaymentResult{
Provider: PaymentAlipay,
Status: "success",
TradeNo: "order-1",
ProviderTradeNo: "ali-1",
Amount: 100,
Currency: "CNY",
}
}
func successIntent() *PaymentIntent {
return &PaymentIntent{
Provider: PaymentAlipay,
TradeNo: "order-1",
BusinessType: "game_item",
BusinessID: "item-1",
Amount: 100,
Currency: "CNY",
}
}
func TestPaymentCallbackQueriesBeforeFulfillment(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
uc := newPaymentUsecaseForTest(repo)
_, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err == nil {
t.Fatal("callback succeeded without fulfillment handler")
}
if repo.queryCalls != 1 {
t.Fatalf("query calls = %d, want 1", repo.queryCalls)
}
}
func TestPaymentCallbackPendingDoesNotFulfill(t *testing.T) {
pending := successPaymentResult()
pending.Status = "pending"
repo := &paymentRepoStub{callback: successPaymentResult(), query: pending}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
if err := registry.Register(handler); err != nil {
t.Fatal(err)
}
uc.SetFulfillmentRegistry(registry)
result, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
if result.Status != "pending" || handler.calls != 0 {
t.Fatalf("result=%+v handler=%d", result, handler.calls)
}
}
func TestPaymentCallbackRejectsAmountMismatch(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
uc.orders.(*paymentOrderRepoStub).order.Amount = 101
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
t.Fatal("amount mismatch was accepted")
}
if handler.calls != 0 {
t.Fatal("handler ran after amount mismatch")
}
}
func TestPaymentCallbackRequiresFulfillmentHandler(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
uc := newPaymentUsecaseForTest(repo)
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
t.Fatal("callback succeeded without fulfillment handler")
}
}
func TestPaymentCallbackDuplicateDoesNotFulfillTwice(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
if _, err := uc.Callback(context.Background(), callback); err != nil {
t.Fatal(err)
}
second, err := uc.Callback(context.Background(), callback)
if err != nil {
t.Fatal(err)
}
if !second.Duplicate || handler.calls != 1 || repo.queryCalls != 2 {
t.Fatalf("duplicate=%v handler=%d query=%d", second.Duplicate, handler.calls, repo.queryCalls)
}
}
func TestPaymentCallbackFulfillmentFailureCanRetry(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{failFirst: true}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
if _, err := uc.Callback(context.Background(), callback); err == nil {
t.Fatal("first fulfillment failure was accepted")
}
result, err := uc.Callback(context.Background(), callback)
if err != nil {
t.Fatal(err)
}
if result.Status != "fulfilled" || handler.calls != 2 || handler.confirmIDs[0] != handler.confirmIDs[1] {
t.Fatalf("result=%+v handler=%d ids=%v", result, handler.calls, handler.confirmIDs)
}
}
func TestPaymentQueryRejectsIncompleteSuccess(t *testing.T) {
result := successPaymentResult()
result.ProviderTradeNo = ""
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
t.Fatal("incomplete successful query was accepted")
}
}
func TestPaymentQueryRejectsUnknownStatus(t *testing.T) {
result := successPaymentResult()
result.Status = "maybe"
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
t.Fatal("unknown payment status was accepted")
}
}
func TestPaymentCallbackErrorCarriesSafeAck(t *testing.T) {
ack := DefaultPaymentCallbackAck(PaymentAlipay, false)
err := callbackResultError(&PaymentResult{FailureAck: ack}, errors.New("internal order resolver details"))
callbackErr, ok := err.(*PaymentCallbackError)
if !ok || string(callbackErr.Ack.Body) != "failure" {
t.Fatalf("callback error = %#v", err)
}
}
func TestPersistentPaymentCreateUsesBusinessCanonicalAmount(t *testing.T) {
repo := &paymentRepoStub{create: &PaymentResult{Provider: PaymentAlipay, Status: "created", TradeNo: "order-1", Payload: []byte(`{"prepay":"x"}`)}}
orders := &paymentOrderRepoStub{}
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
sources := NewPaymentOrderSourceRegistry()
if err := sources.Register(&paymentSourceStub{intent: &PaymentIntent{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "canonical item", Amount: 100, Currency: "CNY", Extra: []byte(`{"product_id":"canonical"}`)}}); err != nil {
t.Fatal(err)
}
uc.SetOrderSourceRegistry(sources)
result, err := uc.Create(context.Background(), &PaymentRequest{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "forged", Amount: 1, Currency: "USD", Extra: map[string]any{"product_id": "forged"}})
if err != nil {
t.Fatal(err)
}
if repo.createdReq.Amount != 100 || repo.createdReq.Currency != "CNY" || repo.createdReq.Subject != "canonical item" || repo.createdReq.Extra["product_id"] != "canonical" || orders.order.Amount != 100 || result.OrderStatus != PaymentStatusPending {
t.Fatalf("canonical order was not used: request=%+v order=%+v result=%+v", repo.createdReq, orders.order, result)
}
}
func TestPersistentPaymentCallbackFulfillsAndPersistsDuplicate(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
orders := &paymentOrderRepoStub{order: &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}}
handler := &paymentFulfillmentStub{}
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
registry := NewPaymentFulfillmentRegistry()
if err := registry.Register(handler); err != nil {
t.Fatal(err)
}
uc.SetFulfillmentRegistry(registry)
first, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
second, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
if first.Status != "fulfilled" || second.Status != "fulfilled" || !second.Duplicate || handler.calls != 1 || orders.order.FulfillmentStatus != FulfillmentStatusSucceeded {
t.Fatalf("persistent callback flow failed: first=%+v second=%+v handler=%d order=%+v", first, second, handler.calls, orders.order)
}
}
func TestPaymentConfirmationAcceptsProviderDiscount(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentAlipay, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentAlipay, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 200, ProviderDiscountAmount: 150, MerchantDiscountAmount: 50, SettlementAmount: 950, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentAlipay, "T1", result); err != nil {
t.Fatal(err)
}
}
func TestPaymentConfirmationRejectsOverpayment(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentWechatV3, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentWechatV3, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 1001, CashPaidAmount: 1001, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentWechatV3, "T1", result); err == nil {
t.Fatal("overpayment was accepted")
}
}
func TestPaymentConfirmationRejectsBrokenDiscountConservation(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentWechatV2, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentWechatV2, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 100, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentWechatV2, "T1", result); err == nil {
t.Fatal("broken discount conservation was accepted")
}
}
func TestPaymentConfirmationBindsAppleProductID(t *testing.T) {
intent := &PaymentIntent{
Provider: PaymentApple, TradeNo: "123e4567-e89b-12d3-a456-426614174000",
Amount: 100, Currency: "USD", BusinessType: "item", BusinessID: "I1",
Extra: json.RawMessage(`{"product_id":"com.example.coin100"}`),
}
result := &PaymentResult{
Provider: PaymentApple, TradeNo: intent.TradeNo, ProviderTradeNo: "APPLE-TX-1",
Status: "success", Amount: 100, Currency: "USD",
Payload: json.RawMessage(`{"productId":"com.example.coin100"}`),
}
if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err != nil {
t.Fatalf("matching productId rejected: %v", err)
}
result.Payload = json.RawMessage(`{"productId":"com.example.coin200"}`)
if err := validatePaymentConfirmation(intent, PaymentApple, intent.TradeNo, result); err == nil {
t.Fatal("mismatched Apple productId was accepted")
}
}
-18
View File
@@ -1,18 +0,0 @@
package biz
import "context"
type PermissionRepo interface {
Buttons(context.Context, uint) ([]*MenuButton, error)
SetAuthorityButtons(context.Context, uint, map[uint][]uint) error
AuthorityButtonIDs(context.Context, uint) ([]uint, error)
SelectedButtons(context.Context, uint, uint) ([]uint, error)
SetSelectedButtons(context.Context, uint, uint, []uint) error
CanRemoveButton(context.Context, string) (bool, error)
}
type PermissionUsecase struct{ PermissionRepo }
func NewPermissionUsecase(repo PermissionRepo) *PermissionUsecase {
return &PermissionUsecase{PermissionRepo: repo}
}
-48
View File
@@ -1,48 +0,0 @@
package biz
import (
"context"
"time"
)
type Position struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name string
Code string
Sort int
Status *bool
Remark string
}
type PositionListFilter struct {
Name string
Code string
Status *bool
}
type PositionRepo interface {
CreatePosition(context.Context, *Position) error
UpdatePosition(context.Context, *Position) error
DeletePosition(context.Context, uint) error
FindPosition(context.Context, uint) (*Position, error)
ListPositions(context.Context, int, int, *PositionListFilter) ([]*Position, int64, error)
PositionUserIDs(context.Context, uint) ([]uint, error)
SetPositionUsers(context.Context, uint, []uint) error
SetUserPositions(context.Context, uint, []uint) error
}
type PositionUsecase struct{ PositionRepo }
func NewPositionUsecase(repo PositionRepo) *PositionUsecase {
return &PositionUsecase{PositionRepo: repo}
}
func (uc *PositionUsecase) Positions(ctx context.Context, page, size int, filter *PositionListFilter) ([]*Position, int64, error) {
return uc.ListPositions(ctx, page, size, filter)
}
func (uc *PositionUsecase) Position(ctx context.Context, id uint) (*Position, error) {
return uc.FindPosition(ctx, id)
}
-275
View File
@@ -1,275 +0,0 @@
package biz
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
)
type SecurityConfig struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
CaptchaOpen int
CaptchaTimeout int
KeyLong int
ImgWidth int
ImgHeight int
PwdMinLength int
PwdRequireUpper bool
PwdRequireLower bool
PwdRequireDigit bool
PwdRequireSpecial bool
LimitEnable bool
LimitWindow int
LimitCount int
LockEnable bool
LockThreshold int
LockDuration int
PwdExpireEnable bool
PwdExpireDays int
ForceNewUserChangePassword bool
}
type SecurityRepo interface {
SecurityConfig(context.Context) (*SecurityConfig, error)
SaveSecurityConfig(context.Context, *SecurityConfig) error
BackfillPasswordUpdatedAt(context.Context, time.Time) error
}
type SecurityUsecase struct {
repo SecurityRepo
cache Cache
settings RuntimeSettings
tokens *TokenUsecase
mu sync.RWMutex
cachedConfig *SecurityConfig
}
var ErrTokenRevoke = errors.New("token revoke failed")
type TokenRevokeError struct{ Err error }
func (e *TokenRevokeError) Error() string { return e.Err.Error() }
func (e *TokenRevokeError) Is(target error) bool { return target == ErrTokenRevoke }
func NewSecurityUsecase(repo SecurityRepo, cache Cache, settings RuntimeSettings, tokens *TokenUsecase) *SecurityUsecase {
return &SecurityUsecase{repo: repo, cache: cache, settings: settings, tokens: tokens}
}
func (uc *SecurityUsecase) UpdateSecurity(ctx context.Context, value *SecurityConfig) error {
previous, err := uc.repo.SecurityConfig(ctx)
if err != nil {
return err
}
value.ID = previous.ID
value.CreatedAt = previous.CreatedAt
value.UpdatedAt = previous.UpdatedAt
if err := uc.repo.SaveSecurityConfig(ctx, value); err != nil {
return err
}
uc.mu.Lock()
copy := *value
uc.cachedConfig = &copy
uc.mu.Unlock()
// Keep the same observable ordering as the administration backend:
// persist and activate the new configuration first, then backfill legacy
// users when password expiration changes from disabled to enabled. A
// backfill failure is returned to the caller without rolling back the
// already effective configuration.
if value.PwdExpireEnable && !previous.PwdExpireEnable {
return uc.repo.BackfillPasswordUpdatedAt(ctx, time.Now())
}
return nil
}
func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error) {
uc.mu.RLock()
if uc.cachedConfig != nil {
copy := *uc.cachedConfig
uc.mu.RUnlock()
return &copy, nil
}
uc.mu.RUnlock()
value, err := uc.repo.SecurityConfig(ctx)
if err != nil {
// Return the default value together with the database-not-ready
// error. Callers such as login/runtime policy consumers intentionally
// ignore the error and continue with that default, while the HTTP
// settings endpoint still reports the failure.
if value != nil {
return value, err
}
return nil, err
}
uc.mu.Lock()
copy := *value
uc.cachedConfig = &copy
uc.mu.Unlock()
return value, nil
}
// Security returns the persisted row for the administration endpoint. Runtime
// policy consumers use Current so their hot path remains cached, while the
// settings page mirrors the reference behavior and always observes storage.
func (uc *SecurityUsecase) Security(ctx context.Context) (*SecurityConfig, error) {
return uc.repo.SecurityConfig(ctx)
}
func (uc *SecurityUsecase) ValidatePassword(value *SecurityConfig, password string) error {
if value.PwdMinLength > 0 && utf8.RuneCountInString(password) < value.PwdMinLength {
return fmt.Errorf("密码长度不能少于 %d 位", value.PwdMinLength)
}
hasUpper, hasLower, hasDigit, hasSpecial := false, false, false, false
for _, ch := range password {
switch {
case unicode.IsUpper(ch):
hasUpper = true
case unicode.IsLower(ch):
hasLower = true
case unicode.IsDigit(ch):
hasDigit = true
case unicode.IsPunct(ch) || unicode.IsSymbol(ch):
hasSpecial = true
}
}
missing := make([]string, 0, 4)
if value.PwdRequireUpper && !hasUpper {
missing = append(missing, "大写字母")
}
if value.PwdRequireLower && !hasLower {
missing = append(missing, "小写字母")
}
if value.PwdRequireDigit && !hasDigit {
missing = append(missing, "数字")
}
if value.PwdRequireSpecial && !hasSpecial {
missing = append(missing, "特殊字符")
}
if len(missing) > 0 {
return fmt.Errorf("密码必须包含%s", strings.Join(missing, "、"))
}
return nil
}
func (uc *SecurityUsecase) ValidateCurrentPassword(ctx context.Context, password string) error {
config, err := uc.Current(ctx)
if err != nil {
return err
}
return uc.ValidatePassword(config, password)
}
func (uc *SecurityUsecase) LoginLocked(ctx context.Context, username string) (bool, error) {
_, 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, loginFailureKey(username), expiration)
}
func (uc *SecurityUsecase) LockLogin(ctx context.Context, username string, expiration time.Duration) error {
return uc.cache.Set(ctx, loginLockKey(username), "1", expiration)
}
func (uc *SecurityUsecase) ClearLoginState(ctx context.Context, username string) {
_ = 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) {
value, exists, err := uc.cache.Get(ctx, ip)
if err != nil {
return 0, err
}
if exists {
return strconv.Atoi(value)
}
if expiration <= 0 {
expiration = time.Hour
}
if err = uc.cache.Set(ctx, ip, "1", expiration); err != nil {
return 0, err
}
return 1, nil
}
func (uc *SecurityUsecase) IncrementLoginIP(ctx context.Context, ip string, expiration time.Duration) (int64, error) {
return uc.cache.Increment(ctx, ip, expiration)
}
func (uc *SecurityUsecase) IncrementRateLimit(ctx context.Context, key string, expiration time.Duration) (int64, error) {
return uc.cache.Increment(ctx, key, expiration)
}
func (uc *SecurityUsecase) SetCaptcha(ctx context.Context, id, value string, expiration time.Duration) error {
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)
}
func (uc *SecurityUsecase) DeleteCaptcha(ctx context.Context, id string) error {
return uc.cache.Delete(ctx, "CAPTCHA_"+id)
}
func (uc *SecurityUsecase) VerifyCaptcha(ctx context.Context, id, answer string, clear bool) bool {
if id == "" || answer == "" {
return false
}
value, ok, err := uc.GetCaptcha(ctx, id)
if err != nil || !ok {
return false
}
if clear {
_ = uc.DeleteCaptcha(ctx, id)
}
return strings.EqualFold(value, answer)
}
// 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))
return value, err
}
func (uc *SecurityUsecase) ActiveTokenMatches(ctx context.Context, username, token string) (bool, error) {
if !uc.settings.UseMultipoint() {
return true, nil
}
active, ok, err := uc.cache.Get(ctx, activeTokenKey(username))
return ok && active == token, err
}
func (uc *SecurityUsecase) RotateActiveToken(ctx context.Context, username, oldToken, newToken string, expiration time.Duration) error {
if !uc.settings.UseMultipoint() {
return nil
}
if oldToken != "" && oldToken != newToken {
if err := uc.tokens.BlacklistToken(ctx, oldToken); err != nil {
return &TokenRevokeError{Err: err}
}
}
return uc.cache.Set(ctx, activeTokenKey(username), newToken, expiration)
}
func (uc *SecurityUsecase) UseMultipoint() bool { return uc.settings.UseMultipoint() }
func (uc *SecurityUsecase) CaptchaRuntimeSettings() CaptchaSettings {
return uc.settings.CaptchaSettings()
}
-112
View File
@@ -1,112 +0,0 @@
package biz
import (
"context"
"errors"
"testing"
"time"
)
type securityUpdateRepo struct {
current *SecurityConfig
backfillErr error
callOrder []string
backfillAt time.Time
persistedCopy *SecurityConfig
}
func (r *securityUpdateRepo) SecurityConfig(context.Context) (*SecurityConfig, error) {
r.callOrder = append(r.callOrder, "get")
copy := *r.current
return &copy, nil
}
func (r *securityUpdateRepo) SaveSecurityConfig(_ context.Context, value *SecurityConfig) error {
r.callOrder = append(r.callOrder, "save")
value.UpdatedAt = value.UpdatedAt.Add(time.Second)
copy := *value
r.persistedCopy = &copy
return nil
}
func (r *securityUpdateRepo) BackfillPasswordUpdatedAt(_ context.Context, at time.Time) error {
r.callOrder = append(r.callOrder, "backfill")
r.backfillAt = at
return r.backfillErr
}
func TestUpdateSecurityKeepsSavedConfigWhenPasswordBackfillFails(t *testing.T) {
createdAt := time.Date(2026, time.August, 16, 12, 0, 0, 0, time.Local)
backfillErr := errors.New("backfill failed")
repo := &securityUpdateRepo{
current: &SecurityConfig{ID: 1, CreatedAt: createdAt, UpdatedAt: createdAt, PwdExpireEnable: false},
backfillErr: backfillErr,
}
uc := NewSecurityUsecase(repo, nil, nil, nil)
next := &SecurityConfig{ID: 99, PwdExpireEnable: true, PwdExpireDays: 30}
err := uc.UpdateSecurity(context.Background(), next)
if !errors.Is(err, backfillErr) {
t.Fatalf("UpdateSecurity() error = %v, want %v", err, backfillErr)
}
if got := repo.callOrder; len(got) != 3 || got[0] != "get" || got[1] != "save" || got[2] != "backfill" {
t.Fatalf("call order = %v, want [get save backfill]", got)
}
if repo.persistedCopy == nil || repo.persistedCopy.ID != 1 || !repo.persistedCopy.PwdExpireEnable {
t.Fatalf("persisted config = %+v", repo.persistedCopy)
}
if repo.backfillAt.IsZero() {
t.Fatal("password timestamp backfill was not attempted")
}
cached, currentErr := uc.Current(context.Background())
if currentErr != nil {
t.Fatalf("Current() error = %v", currentErr)
}
if !cached.PwdExpireEnable || cached.ID != 1 {
t.Fatalf("effective config = %+v, want newly saved config", cached)
}
if got := repo.callOrder; len(got) != 3 {
t.Fatalf("Current() unexpectedly reloaded repository: %v", got)
}
}
func TestUpdateSecurityDoesNotBackfillWithoutDisabledToEnabledTransition(t *testing.T) {
repo := &securityUpdateRepo{current: &SecurityConfig{ID: 1, PwdExpireEnable: true}}
uc := NewSecurityUsecase(repo, nil, nil, nil)
if err := uc.UpdateSecurity(context.Background(), &SecurityConfig{PwdExpireEnable: true}); err != nil {
t.Fatalf("UpdateSecurity() error = %v", err)
}
if got := repo.callOrder; len(got) != 2 || got[0] != "get" || got[1] != "save" {
t.Fatalf("call order = %v, want [get save]", got)
}
}
func TestSecurityReadsPersistedValueWhileCurrentUsesCache(t *testing.T) {
repo := &securityUpdateRepo{current: &SecurityConfig{ID: 1, LimitCount: 10}}
uc := NewSecurityUsecase(repo, nil, nil, nil)
cached, err := uc.Current(context.Background())
if err != nil {
t.Fatal(err)
}
if cached.LimitCount != 10 {
t.Fatalf("initial cached limit = %d", cached.LimitCount)
}
repo.current.LimitCount = 20
current, err := uc.Current(context.Background())
if err != nil {
t.Fatal(err)
}
if current.LimitCount != 10 {
t.Fatalf("Current() bypassed cache: %d", current.LimitCount)
}
fresh, err := uc.Security(context.Background())
if err != nil {
t.Fatal(err)
}
if fresh.LimitCount != 20 {
t.Fatalf("Security() returned stale value: %d", fresh.LimitCount)
}
}
-84
View File
@@ -1,84 +0,0 @@
package biz
import (
"context"
"encoding/json"
"errors"
)
var ErrTaskRuntimeReload = errors.New("task runtime reload failed")
type TaskRuntimeReloadError struct{ Err error }
func (e *TaskRuntimeReloadError) Error() string { return e.Err.Error() }
func (e *TaskRuntimeReloadError) Is(target error) bool { return target == ErrTaskRuntimeReload }
type DatabaseConfig struct {
Driver, Host, Port, User, Password, Name, Path, Config, Template, AdminPassword string
APIs []*API
}
type InitializationRepo interface {
IsInitialized(context.Context) (bool, error)
Initialize(context.Context, *DatabaseConfig) error
PersistConfig(context.Context) error
PersistAdminConfig(context.Context, []byte) error
PersistRuntimeConfig(context.Context, []byte, []byte) error
ReloadConfig(context.Context) error
ConfigurationJSON() (json.RawMessage, error)
SaveConfigurationJSON(context.Context, json.RawMessage) error
DiskMountPoints() []string
}
type SystemConfigUsecase struct {
repo InitializationRepo
tasks TaskRuntime
}
func NewSystemConfigUsecase(repo InitializationRepo, tasks TaskRuntime) *SystemConfigUsecase {
return &SystemConfigUsecase{repo: repo, tasks: tasks}
}
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 {
return errors.New("数据库初始化参数无效")
}
if err := uc.repo.Initialize(ctx, config); err != nil {
return err
}
if err := uc.tasks.Reload(ctx); err != nil {
return &TaskRuntimeReloadError{Err: err}
}
return nil
}
func (uc *SystemConfigUsecase) PersistConfig(ctx context.Context) error {
return uc.repo.PersistConfig(ctx)
}
func (uc *SystemConfigUsecase) PersistAdminConfig(ctx context.Context, value []byte) error {
return uc.repo.PersistAdminConfig(ctx, value)
}
func (uc *SystemConfigUsecase) PersistRuntimeConfig(ctx context.Context, data, admin []byte) error {
return uc.repo.PersistRuntimeConfig(ctx, data, admin)
}
func (uc *SystemConfigUsecase) ReloadConfig(ctx context.Context) error {
if err := uc.repo.ReloadConfig(ctx); err != nil {
return err
}
if err := uc.tasks.Reload(ctx); err != nil {
return &TaskRuntimeReloadError{Err: err}
}
return nil
}
func (uc *SystemConfigUsecase) ConfigurationJSON() (json.RawMessage, error) {
return uc.repo.ConfigurationJSON()
}
func (uc *SystemConfigUsecase) SaveConfigurationJSON(ctx context.Context, value json.RawMessage) error {
return uc.repo.SaveConfigurationJSON(ctx, value)
}
func (uc *SystemConfigUsecase) DiskMountPoints() []string { return uc.repo.DiskMountPoints() }
-222
View File
@@ -1,222 +0,0 @@
package biz
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"time"
"github.com/robfig/cron/v3"
)
type TimedTask struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
Name, Description, Spec string
WithSeconds bool
ExecutorType, MethodName string
Params []byte
HTTPURL, HTTPMethod string
HTTPHeader []byte
HTTPBody string
HTTPAllowPrivate, Enabled bool
EnabledFilter *bool
}
type TimedTaskLog struct {
ID uint
CreatedAt, UpdatedAt time.Time
TaskID uint
TaskName, TriggerType string
StartedAt, FinishedAt time.Time
DurationMS int64
Status, ErrorMsg, Output string
}
const (
TaskExecutorMethod = "method"
TaskExecutorHTTP = "http"
TaskMethodClearDB = "ClearDB"
TaskMethodUploads = "CleanStaleUploads"
)
var ErrTaskSchedule = errors.New("task schedule failed")
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 TaskRepo interface {
CreateTask(context.Context, *TimedTask) error
UpdateTask(context.Context, *TimedTask) error
DeleteTask(context.Context, uint) error
FindTask(context.Context, uint) (*TimedTask, error)
ListTasks(context.Context, int, int, *TimedTask) ([]*TimedTask, int64, error)
ToggleTask(context.Context, uint, bool) error
RecordTaskLog(context.Context, *TimedTaskLog) error
ListTaskLogs(context.Context, int, int, uint, string) ([]*TimedTaskLog, int64, error)
CleanupLogs(context.Context) error
TaskNameExists(context.Context, string, uint) (bool, error)
}
type TaskRuntime interface {
ScheduleID(context.Context, uint) error
Remove(uint)
TriggerID(context.Context, uint) error
NextRuns() map[uint]time.Time
Reload(context.Context) error
Subscribe(uint) chan []byte
Unsubscribe(uint, chan []byte)
}
type TaskUsecase struct{ TaskRepo }
func NewTaskUsecase(repo TaskRepo) *TaskUsecase { return &TaskUsecase{TaskRepo: repo} }
func (uc *TaskUsecase) Validate(value *TimedTask) error {
if value.Name == "" {
return errors.New("任务名不能为空")
}
var err error
if value.WithSeconds {
_, err = cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor).Parse(value.Spec)
} else {
_, err = cron.ParseStandard(value.Spec)
}
if err != nil {
return fmt.Errorf("cron 表达式非法: %w", err)
}
switch value.ExecutorType {
case TaskExecutorMethod:
if !registeredTaskMethod(value.MethodName) {
return fmt.Errorf("方法 %s 未注册", value.MethodName)
}
if len(value.Params) > 0 && !json.Valid(value.Params) {
return errors.New("params 必须是合法 JSON")
}
case TaskExecutorHTTP:
parsed, parseErr := url.Parse(value.HTTPURL)
if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return errors.New("httpUrl 必须是合法的 http/https 地址")
}
if len(value.HTTPHeader) > 0 {
headers := map[string]string{}
if json.Unmarshal(value.HTTPHeader, &headers) != nil {
return errors.New(`httpHeader 必须是 {"Key":"Value"} 形式的 JSON 对象`)
}
}
default:
return errors.New("executorType 必须为 method 或 http")
}
return nil
}
func (uc *TaskUsecase) Create(ctx context.Context, value *TimedTask) error {
if err := uc.Validate(value); err != nil {
return err
}
exists, err := uc.TaskNameExists(ctx, value.Name, 0)
if err != nil {
return err
}
if exists {
return fmt.Errorf("任务名 %s 已存在", value.Name)
}
return uc.CreateTask(ctx, value)
}
func (uc *TaskUsecase) Update(ctx context.Context, value *TimedTask) error {
if value.ID == 0 {
return errors.New("缺少任务 ID")
}
if err := uc.Validate(value); err != nil {
return err
}
exists, err := uc.TaskNameExists(ctx, value.Name, value.ID)
if err != nil {
return err
}
if exists {
return fmt.Errorf("任务名 %s 已存在", value.Name)
}
return uc.UpdateTask(ctx, value)
}
type TaskApplicationUsecase struct {
tasks *TaskUsecase
runtime TaskRuntime
}
func NewTaskApplicationUsecase(tasks *TaskUsecase, runtime TaskRuntime) *TaskApplicationUsecase {
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
}
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
}
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 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
}
return uc.syncRuntime(ctx, id)
}
func (uc *TaskApplicationUsecase) Trigger(ctx context.Context, id uint) error {
return uc.runtime.TriggerID(ctx, id)
}
func (uc *TaskApplicationUsecase) List(ctx context.Context, page, size int, filter *TimedTask) ([]*TimedTask, int64, map[uint]time.Time, error) {
items, total, err := uc.tasks.ListTasks(ctx, page, size, filter)
return items, total, uc.runtime.NextRuns(), err
}
func (uc *TaskApplicationUsecase) Logs(ctx context.Context, page, size int, taskID uint, status string) ([]*TimedTaskLog, int64, error) {
return uc.tasks.ListTaskLogs(ctx, page, size, taskID, status)
}
func (uc *TaskApplicationUsecase) Reload(ctx context.Context) error {
return uc.runtime.Reload(ctx)
}
func (uc *TaskApplicationUsecase) Subscribe(userID uint) chan []byte {
return uc.runtime.Subscribe(userID)
}
func (uc *TaskApplicationUsecase) Unsubscribe(userID uint, events chan []byte) {
uc.runtime.Unsubscribe(userID, events)
}
-66
View File
@@ -1,66 +0,0 @@
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
}
-197
View File
@@ -1,197 +0,0 @@
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)
}
}
-38
View File
@@ -1,38 +0,0 @@
package biz
import (
"context"
"time"
)
type UploadSession struct {
ID uint
UserID uint
FileName, FileHash string
FileSize, ChunkSize int64
ChunkTotal int
Status, StorageKey string
MediaID uint
}
type UploadChunk struct {
Index int
Hash string
Size int64
}
type UploadRepo interface {
FindCompletedSession(context.Context, uint, string) (*UploadSession, error)
FindUploadingSession(context.Context, uint, string) (*UploadSession, error)
CreateUploadSession(context.Context, *UploadSession) error
FindUploadSession(context.Context, uint) (*UploadSession, error)
ClaimUploadSession(context.Context, uint) (bool, error)
FailUploadSession(context.Context, uint) error
CompleteUploadSession(context.Context, uint, string, uint) error
DeleteUploadSession(context.Context, uint) error
UpsertChunk(context.Context, uint, *UploadChunk) error
ListChunks(context.Context, uint) ([]*UploadChunk, error)
DeleteChunks(context.Context, uint) error
StaleUploadSessionIDs(context.Context, time.Time) ([]uint, error)
DeleteUploadData(context.Context, uint) error
}
-177
View File
@@ -1,177 +0,0 @@
package biz
import (
"context"
"time"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
UUID string
Username string
Password string
NickName string
HeaderImg string
AuthorityID uint
Authority Authority
Authorities []Authority
DeptID uint
Department *Department
Departments []Department
Positions []Position
Phone string
Email string
Enable int
OriginSetting map[string]any
MustChangePassword bool
PasswordUpdatedAt *time.Time
}
type UserListFilter struct {
Username, NickName, Phone, Email, OrderKey string
Desc bool
}
type UserRepo interface {
FindUserByUsername(context.Context, string) (*User, error)
FindUserByID(context.Context, uint) (*User, error)
FindUserByUUID(context.Context, string) (*User, error)
HasAuthorityMenu(context.Context, uint, string) (bool, error)
MenusByAuthority(context.Context, uint) ([]*Menu, error)
ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error)
CreateUser(context.Context, *User) (*User, error)
CreateUserWithAuthorities(context.Context, *User, []uint) (*User, error)
UpdateUser(context.Context, *User) error
UpdateSelfUser(context.Context, *User) error
UpdateUserWithAuthorities(context.Context, *User, []uint) error
DeleteUser(context.Context, uint) error
UpdatePassword(context.Context, uint, string, bool) error
SetUserAuthorities(context.Context, uint, []uint) error
SetUserAuthority(context.Context, uint, uint) error
SetUserSetting(context.Context, uint, map[string]any) error
ListAuthorities(context.Context) ([]*Authority, error)
FillDepartmentNamePaths(context.Context, *User) error
}
type UserUsecase struct{ repo UserRepo }
func NewUserUsecase(repo UserRepo) *UserUsecase { return &UserUsecase{repo: repo} }
func (uc *UserUsecase) Login(ctx context.Context, username, password string) (*User, error) {
u, err := uc.repo.FindUserByUsername(ctx, username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil {
return nil, ErrInvalidCredentials
}
uc.fallbackDefaultRouter(ctx, u)
return u, nil
}
func (uc *UserUsecase) User(ctx context.Context, id uint) (*User, error) {
user, err := uc.repo.FindUserByID(ctx, id)
if err != nil {
return nil, err
}
_ = uc.repo.FillDepartmentNamePaths(ctx, user)
uc.fallbackDefaultRouter(ctx, user)
return user, nil
}
// UserByUUID resolves the identity from the UUID embedded in the JWT rather
// than from the mutable numeric ID used by administrative records.
func (uc *UserUsecase) UserByUUID(ctx context.Context, uuid string) (*User, error) {
user, err := uc.repo.FindUserByUUID(ctx, uuid)
if err != nil {
return nil, err
}
_ = uc.repo.FillDepartmentNamePaths(ctx, user)
uc.fallbackDefaultRouter(ctx, user)
return user, nil
}
func (uc *UserUsecase) fallbackDefaultRouter(ctx context.Context, user *User) {
if user == nil {
return
}
hasMenu, err := uc.repo.HasAuthorityMenu(ctx, user.AuthorityID, user.Authority.DefaultRouter)
if err != nil {
return
}
if !hasMenu {
user.Authority.DefaultRouter = "404"
}
}
func (uc *UserUsecase) Menus(ctx context.Context, authorityID uint) ([]*Menu, error) {
return uc.repo.MenusByAuthority(ctx, authorityID)
}
func (uc *UserUsecase) ListUsers(ctx context.Context, page, pageSize int, filter *UserListFilter) ([]*User, int64, error) {
return uc.repo.ListUsers(ctx, page, pageSize, filter)
}
func (uc *UserUsecase) CreateUser(ctx context.Context, user *User, authorityIDs []uint) (*User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user.Password = string(hash)
return uc.repo.CreateUserWithAuthorities(ctx, user, authorityIDs)
}
func (uc *UserUsecase) UpdateUser(ctx context.Context, user *User, authorityIDs []uint) error {
// 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 {
return uc.repo.UpdateSelfUser(ctx, user)
}
func (uc *UserUsecase) DeleteUser(ctx context.Context, id uint) error {
return uc.repo.DeleteUser(ctx, id)
}
func (uc *UserUsecase) ResetPassword(ctx context.Context, id uint, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
return uc.repo.UpdatePassword(ctx, id, string(hash), false)
}
func (uc *UserUsecase) ChangePassword(ctx context.Context, id uint, oldPassword, newPassword string) error {
user, err := uc.repo.FindUserByID(ctx, id)
if err != nil {
return err
}
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)) != nil {
return ErrInvalidCredentials
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
return uc.repo.UpdatePassword(ctx, id, string(hash), true)
}
func (uc *UserUsecase) Authorities(ctx context.Context) ([]*Authority, error) {
return uc.repo.ListAuthorities(ctx)
}
func (uc *UserUsecase) SetUserAuthorities(ctx context.Context, id uint, authorityIDs []uint) error {
if len(authorityIDs) == 0 {
return ErrInvalidCredentials
}
return uc.repo.SetUserAuthorities(ctx, id, authorityIDs)
}
func (uc *UserUsecase) SetUserAuthority(ctx context.Context, id, authorityID uint) error {
return uc.repo.SetUserAuthority(ctx, id, authorityID)
}
func (uc *UserUsecase) SetUserSetting(ctx context.Context, id uint, setting map[string]any) error {
return uc.repo.SetUserSetting(ctx, id, setting)
}
-37
View File
@@ -1,37 +0,0 @@
package biz
import (
"context"
"errors"
"testing"
)
type defaultRouterUserRepo struct {
UserRepo
hasMenu bool
err error
}
func (r *defaultRouterUserRepo) HasAuthorityMenu(context.Context, uint, string) (bool, error) {
return r.hasMenu, r.err
}
func TestFallbackDefaultRouterMatchesMenuLookupOutcome(t *testing.T) {
user := &User{AuthorityID: 888, Authority: Authority{DefaultRouter: "dashboard"}}
NewUserUsecase(&defaultRouterUserRepo{hasMenu: false}).fallbackDefaultRouter(context.Background(), user)
if user.Authority.DefaultRouter != "404" {
t.Fatalf("missing default route = %q", user.Authority.DefaultRouter)
}
user.Authority.DefaultRouter = "dashboard"
NewUserUsecase(&defaultRouterUserRepo{err: errors.New("database unavailable")}).fallbackDefaultRouter(context.Background(), user)
if user.Authority.DefaultRouter != "dashboard" {
t.Fatalf("lookup error changed default route to %q", user.Authority.DefaultRouter)
}
user.Authority.DefaultRouter = ""
NewUserUsecase(&defaultRouterUserRepo{}).fallbackDefaultRouter(context.Background(), user)
if user.Authority.DefaultRouter != "404" {
t.Fatalf("empty missing default route = %q", user.Authority.DefaultRouter)
}
}
-57
View File
@@ -1,57 +0,0 @@
package biz
import (
"context"
"errors"
"time"
)
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
UpdatedAt time.Time
Name *string
Code *string
Description *string
Data *string
}
type VersionBundle struct {
Name string
Code string
Description string
ExportTime string
Menus []*Menu
APIs []*API
Dictionaries []*Dictionary
}
type VersionRepo interface {
CreateVersion(context.Context, *Version) error
DeleteVersions(context.Context, []uint) error
FindVersion(context.Context, uint) (*Version, error)
ListVersions(context.Context, int, int, string, string, []*time.Time) ([]*Version, int64, error)
BuildVersionBundle(context.Context, []uint, []uint, []uint) (*VersionBundle, error)
ImportVersionBundle(context.Context, *VersionBundle) error
}
type VersionUsecase struct{ VersionRepo }
func NewVersionUsecase(repo VersionRepo) *VersionUsecase { return &VersionUsecase{VersionRepo: repo} }