优化结构
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
# Biz
|
||||
|
||||
本目录只包含领域对象、用例和仓储接口,不依赖 HTTP Handler、数据库驱动或对象存储 SDK。跨多表、必须原子完成的业务动作通过具有业务含义的仓储方法表达。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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} }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ©Req
|
||||
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 = ©Order
|
||||
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 = ©Req
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 = ©
|
||||
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 ©, 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 = ©
|
||||
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()
|
||||
}
|
||||
@@ -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 ©, 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 = ©
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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} }
|
||||
Reference in New Issue
Block a user