This commit is contained in:
2026-07-18 10:52:32 +08:00
parent 2dcafae465
commit b5ef58f434
61 changed files with 1274 additions and 2142 deletions

View File

@@ -8,6 +8,7 @@ import (
"seeyon-filesystem/dto"
"seeyon-filesystem/model"
"seeyon-filesystem/repository"
"seeyon-filesystem/storage"
"seeyon-filesystem/utils"
)
@@ -55,6 +56,7 @@ func (s *AdminService) CreateUser(req *dto.UserCreateRequest) error {
}
user := &model.User{
ID: utils.GenID(),
Username: req.Username,
Password: hashed,
Email: req.Email,
@@ -77,7 +79,11 @@ func (s *AdminService) CreateUser(req *dto.UserCreateRequest) error {
// 分配角色
if len(req.RoleIDs) > 0 {
s.userRoleRepo.SetUserRoles(user.ID, req.RoleIDs)
roleIDs := make([]uint64, len(req.RoleIDs))
for i, id := range req.RoleIDs {
roleIDs[i] = uint64(id)
}
s.userRoleRepo.SetUserRoles(user.ID, roleIDs)
} else {
// 未指定角色时, 自动分配默认角色(user)
var defaultRole model.Role
@@ -99,7 +105,7 @@ func (s *AdminService) ListUsers(page, size int) ([]dto.UserVO, int64, error) {
vos := make([]dto.UserVO, 0, len(users))
for _, u := range users {
vo := dto.UserVO{
ID: u.ID,
ID: dto.StringID(u.ID),
Username: u.Username,
Email: u.Email,
Nickname: u.Nickname,
@@ -112,7 +118,7 @@ func (s *AdminService) ListUsers(page, size int) ([]dto.UserVO, int64, error) {
// 获取用户角色
roles, _ := s.userRoleRepo.GetUserRoles(u.ID)
for _, r := range roles {
vo.Roles = append(vo.Roles, dto.RoleVO{ID: r.ID, Name: r.Name, DisplayName: r.DisplayName})
vo.Roles = append(vo.Roles, dto.RoleVO{ID: dto.StringID(r.ID), Name: r.Name, DisplayName: r.DisplayName})
}
vos = append(vos, vo)
}
@@ -146,7 +152,11 @@ func (s *AdminService) UpdateUser(userID uint64, req *dto.UserUpdateRequest) err
// 更新角色
if req.RoleIDs != nil {
s.userRoleRepo.SetUserRoles(userID, req.RoleIDs)
roleIDs := make([]uint64, len(req.RoleIDs))
for i, id := range req.RoleIDs {
roleIDs[i] = uint64(id)
}
s.userRoleRepo.SetUserRoles(userID, roleIDs)
}
return nil
@@ -255,7 +265,13 @@ func (s *AdminService) UpdateStoragePolicy(id uint64, req *dto.StoragePolicyUpda
}
}
return s.policyRepo.Update(policy)
if err := s.policyRepo.Update(policy); err != nil {
return err
}
// 清除引擎缓存, 下次访问时用新配置重建
storage.InvalidateEngine(id)
return nil
}
// DeleteStoragePolicy 删除存储策略
@@ -277,6 +293,7 @@ func (s *AdminService) CreateDepartment(req *dto.DeptCreateRequest) error {
}
dept := &model.Department{
ID: utils.GenID(),
Name: req.Name,
Code: req.Code,
ParentID: req.ParentID,

View File

@@ -3,6 +3,7 @@ package service
import (
"errors"
"time"
"seeyon-filesystem/config"
"seeyon-filesystem/dto"
@@ -13,6 +14,8 @@ import (
"gorm.io/gorm"
)
const maxSessionsPerUser = 3 // 每用户最大同时登录数
// AuthService 认证服务
type AuthService struct {
userRepo *repository.UserRepository
@@ -46,7 +49,11 @@ func (s *AuthService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) {
return nil, errors.New("用户名或密码错误")
}
// 生成JWT Token
return s.generateLoginResponse(user, "", "")
}
// generateLoginResponse 生成登录响应(统一处理Token生成和会话创建)
func (s *AuthService) generateLoginResponse(user *model.User, device, ip string) (*dto.LoginResponse, error) {
token, err := utils.GenerateToken(
user.ID,
user.Username,
@@ -58,6 +65,9 @@ func (s *AuthService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) {
return nil, errors.New("生成Token失败")
}
// 创建会话(限制最大同时登录数)
s.createSession(user.ID, token, device, ip)
return &dto.LoginResponse{
Token: token,
Username: user.Username,
@@ -67,6 +77,59 @@ func (s *AuthService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) {
}, nil
}
// Logout 退出登录, 删除当前会话
func (s *AuthService) Logout(userID uint64, token string) {
config.DB.Where("user_id = ? AND token = ?", userID, token).Delete(&model.UserSession{})
}
// GetSessions 获取用户的活跃会话列表
func (s *AuthService) GetSessions(userID uint64) []map[string]interface{} {
var sessions []model.UserSession
config.DB.Where("user_id = ? AND expires_at > ?", userID, time.Now()).
Order("created_at DESC").Find(&sessions)
result := make([]map[string]interface{}, 0, len(sessions))
for _, sess := range sessions {
result = append(result, map[string]interface{}{
"id": sess.ID,
"device": sess.Device,
"ip": sess.IP,
"created_at": sess.CreatedAt.Format("2006-01-02 15:04:05"),
"expires_at": sess.ExpiresAt.Format("2006-01-02 15:04:05"),
})
}
return result
}
// createSession 创建登录会话, 超过上限时踢掉最旧的
func (s *AuthService) createSession(userID uint64, token, device, ip string) {
// 清理该用户过期的会话
config.DB.Where("user_id = ? AND expires_at < ?", userID, time.Now()).Delete(&model.UserSession{})
// 统计当前活跃会话数
var count int64
config.DB.Model(&model.UserSession{}).Where("user_id = ?", userID).Count(&count)
// 超过上限, 删除最旧的会话
if count >= maxSessionsPerUser {
var oldest model.UserSession
config.DB.Where("user_id = ?", userID).Order("created_at ASC").First(&oldest)
if oldest.ID > 0 {
config.DB.Delete(&oldest)
}
}
// 创建新会话
expiresAt := time.Now().Add(time.Duration(config.AppConfig.JWT.Expiration) * time.Hour)
config.DB.Create(&model.UserSession{
UserID: userID,
Token: token,
Device: device,
IP: ip,
ExpiresAt: expiresAt,
})
}
// validateAppLogin 校验 appId 登录
func (s *AuthService) validateAppLogin(appID string, userID uint64) error {
// 1. appId 是否存在且启用
@@ -104,25 +167,7 @@ func (s *AuthService) AppLogin(req *dto.AppLoginRequest) (*dto.LoginResponse, er
return nil, err
}
// 生成JWT Token
token, err := utils.GenerateToken(
user.ID,
user.Username,
user.Role,
config.AppConfig.JWT.Secret,
config.AppConfig.JWT.Expiration,
)
if err != nil {
return nil, errors.New("生成Token失败")
}
return &dto.LoginResponse{
Token: token,
Username: user.Username,
Nickname: user.Nickname,
Role: user.Role,
MustChangePwd: user.MustChangePwd == 1,
}, nil
return s.generateLoginResponse(user, "", "")
}
// Register 用户注册
@@ -138,6 +183,7 @@ func (s *AuthService) Register(req *dto.RegisterRequest) error {
}
user := &model.User{
ID: utils.GenID(),
Username: req.Username,
Password: hashedPassword,
Email: req.Email,

View File

@@ -14,6 +14,7 @@ import (
"seeyon-filesystem/config"
"seeyon-filesystem/dto"
"seeyon-filesystem/model"
"seeyon-filesystem/utils"
)
// BackupService 备份服务
@@ -33,6 +34,7 @@ func (s *BackupService) CreatePolicy(req *dto.BackupPolicyCreateRequest) error {
}
policy := &model.BackupPolicy{
ID: utils.GenID(),
Name: req.Name,
BackupType: req.BackupType,
TargetType: req.TargetType,
@@ -105,6 +107,7 @@ func (s *BackupService) ExecuteBackup(policyID uint64) (*model.BackupLog, error)
// 创建备份日志
log := &model.BackupLog{
ID: utils.GenID(),
PolicyID: policyID,
PolicyName: policy.Name,
BackupType: policy.BackupType,

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"os"
"path/filepath"
@@ -38,7 +39,7 @@ func NewFileService() *FileService {
// Upload 上传文件
// 流程: 校验大小 -> 计算MD5 -> 检查秒传 -> 存储文件 -> 保存元数据
func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, ownerID uint64, storagePolicyID uint64) (*dto.FileVO, error) {
func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, ownerID uint64, storagePolicyID uint64, bizID string) (*dto.FileVO, error) {
// 校验文件大小
maxSize := s.getMaxFileSize(ownerID)
if maxSize > 0 && fileHeader.Size > maxSize {
@@ -51,11 +52,19 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
if err != nil {
return nil, errors.New("目标文件夹不存在")
}
if folder.OwnerID != ownerID {
if !s.canAccessFile(folder.ID, folder.OwnerID, ownerID) {
return nil, errors.New("无权上传到此文件夹")
}
}
// 检查biz_id唯一性
if bizID != "" {
var existingBiz model.File
if config.DB.Where("biz_id = ? AND status = 1", bizID).First(&existingBiz).Error == nil {
return nil, fmt.Errorf("业务编号 %s 已存在", bizID)
}
}
// 确定存储策略: 优先使用指定策略, 否则根据用户分配自动确定
var engine storage.Engine
var policyID uint64
@@ -68,7 +77,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
engine, engineErr := storage.GetEngineByPolicyID(policyID)
if engineErr != nil {
engine = storage.GetDefaultEngine()
policyID = uint64(config.AppConfig.Storage.DefaultPolicyID)
// policyID 保留原值, 不覆盖, 确保文件记录正确的存储策略
}
if engine == nil {
return nil, errors.New("存储引擎未初始化")
@@ -106,6 +115,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
if existing != nil {
newFile := &model.File{
ID: utils.GenID(),
BizID: bizID,
Name: fileHeader.Filename,
Extension: utils.GetExtension(fileHeader.Filename),
FolderID: folderID,
@@ -140,6 +150,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
newFile := &model.File{
ID: utils.GenID(),
BizID: bizID,
Name: fileHeader.Filename,
Extension: ext,
FolderID: folderID,
@@ -158,6 +169,12 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
}
s.userRepo.UpdateStorageUsed(ownerID, size)
// 异步检测视频编码, H.265自动转码为H.264
if ext == "mp4" || ext == "mkv" || ext == "avi" || ext == "mov" {
go s.asyncTranscode(engine, newFile)
}
return s.toFileVO(newFile), nil
}
@@ -350,36 +367,48 @@ func GenerateFileURL(file *model.File, urlType string, token ...string) (string,
// 根据存储策略的实际类型决定URL生成方式
policyType := getStoragePolicyType(file.StoragePolicyID)
log.Printf("[URL生成] 文件ID=%d, storage_policy_id=%d, 策略类型=%s", file.ID, file.StoragePolicyID, policyType)
switch policyType {
case "minio":
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
if err != nil {
// MinIO不可用, 回退API代理
log.Printf("[URL生成] MinIO引擎获取失败, 回退API代理: %v", err)
return buildAPIURL(file.ID, urlType, tk), nil
}
minioEngine := engine.(*storage.MinIOEngine)
var presignedURL string
var fileURL string
var genErr error
if urlType == "preview" {
presignedURL, genErr = minioEngine.GetPresignedPreviewURL(file.StorageKey, file.MimeType, 24*time.Hour)
fileURL, genErr = minioEngine.GetPresignedPreviewURL(file.StorageKey, file.MimeType, 24*time.Hour)
} else {
presignedURL, genErr = minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour)
fileURL, genErr = minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour)
}
if genErr != nil {
return buildAPIURL(file.ID, urlType, tk), nil
// 预签名失败(如匿名凭证/公开桶), 用直接URL
log.Printf("[URL生成] 预签名失败, 使用直接URL: %v", genErr)
fileURL = minioEngine.GetURL(file.StorageKey)
// 预览模式追加inline头参数, 避免触发下载
if urlType == "preview" {
fileURL += "?response-content-disposition=inline"
}
}
policy := getStoragePolicyConfig(file.StoragePolicyID)
if policy != nil && policy.NginxEndpoint != "" {
presignedURL = replaceMinIOEndpoint(presignedURL, minioEngine.Endpoint, policy.NginxEndpoint)
fileURL = replaceMinIOEndpoint(fileURL, minioEngine.Endpoint, policy.NginxEndpoint)
}
return presignedURL, nil
return fileURL, nil
case "local":
// 本地存储: 有CDN就用CDN, 否则用API路径
cfg := config.AppConfig.Storage
if cfg.CDNHost != "" {
prefix := strings.TrimRight(cfg.CDNPathPrefix, "/")
// 策略分类子目录(hot/cold/public/slave)
category := getStoragePolicyCategory(file.StoragePolicyID)
if category != "" {
prefix = prefix + "/" + category
}
key := strings.TrimLeft(file.StorageKey, "/")
return fmt.Sprintf("%s%s/%s", cfg.CDNHost, prefix, key), nil
}
@@ -400,6 +429,15 @@ func getStoragePolicyType(policyID uint64) string {
return policy.Type
}
// getStoragePolicyCategory 获取存储策略分类(hot/cold/public/slave)
func getStoragePolicyCategory(policyID uint64) string {
var policy model.StoragePolicy
if config.DB.Select("policy_type").First(&policy, policyID).Error != nil {
return ""
}
return policy.PolicyType
}
// buildAPIURL 构建完整的API文件访问地址
func buildAPIURL(fileID uint64, urlType string, token ...string) string {
publicURL := config.AppConfig.Server.PublicURL
@@ -476,9 +514,97 @@ func (s *FileService) SearchByType(ownerID uint64, extensions []string) ([]dto.F
return vos, nil
}
// ListTrashed 查询回收站
// asyncTranscode 异步转码视频(H.265→H.264), 支持本地存储和MinIO
func (s *FileService) asyncTranscode(engine storage.Engine, file *model.File) {
if !utils.FFmpegAvailable() {
return
}
// 根据存储引擎类型处理
switch e := engine.(type) {
case *storage.LocalEngine:
s.transcodeLocal(e, file)
case *storage.MinIOEngine:
s.transcodeMinIO(e, file)
}
}
// transcodeLocal 本地存储转码
func (s *FileService) transcodeLocal(localEngine *storage.LocalEngine, file *model.File) {
filePath := localEngine.GetFullPath(file.StorageKey)
if !utils.NeedsTranscode(filePath) {
return
}
log.Printf("[转码] 检测到H.265视频, 开始转码: %s", file.Name)
utils.TranscodeToH264(filePath)
// 更新文件大小(转码后大小可能变化)
if info, err := os.Stat(filePath); err == nil {
s.fileRepo.UpdateSize(file.ID, info.Size())
}
}
// transcodeMinIO MinIO存储转码: 下载→转码→重新上传
func (s *FileService) transcodeMinIO(minioEngine *storage.MinIOEngine, file *model.File) {
// 下载到临时文件
tmpInput := os.TempDir() + "/transcode_in_" + file.MD5 + filepath.Ext(file.Name)
reader, err := minioEngine.Download(file.StorageKey)
if err != nil {
log.Printf("[转码] MinIO下载失败: %v", err)
return
}
defer reader.Close()
outFile, err := os.Create(tmpInput)
if err != nil {
log.Printf("[转码] 创建临时文件失败: %v", err)
return
}
io.Copy(outFile, reader)
outFile.Close()
defer os.Remove(tmpInput)
// 检测是否需要转码
if !utils.NeedsTranscode(tmpInput) {
return
}
log.Printf("[转码] 检测到H.265视频(MinIO), 开始转码: %s", file.Name)
// 转码
tmpOutput := os.TempDir() + "/transcode_out_" + file.MD5 + ".mp4"
utils.TranscodeToH264(tmpInput) // 会生成 tmpInput 的 _h264.mp4 版本并替换
// TranscodeToH264 会替换原文件, 所以用 tmpInput 作为转码结果
// 重新上传到 MinIO
uploadFile, err := os.Open(tmpInput)
if err != nil {
log.Printf("[转码] 打开转码文件失败: %v", err)
return
}
defer uploadFile.Close()
defer os.Remove(tmpInput)
newSize, _ := uploadFile.Seek(0, io.SeekEnd)
uploadFile.Seek(0, io.SeekStart)
if err := minioEngine.Upload(file.StorageKey, uploadFile, newSize); err != nil {
log.Printf("[转码] MinIO上传失败: %v", err)
return
}
// 更新文件大小
s.fileRepo.UpdateSize(file.ID, newSize)
log.Printf("[转码] MinIO转码完成: %s, 新大小: %d", file.Name, newSize)
os.Remove(tmpOutput)
}
// ListTrashed 查询回收站(管理员看全部, 普通用户看自己的)
func (s *FileService) ListTrashed(ownerID uint64) ([]dto.FileVO, error) {
files, err := s.fileRepo.ListTrashed(ownerID)
isAdmin := s.isAdmin(ownerID)
files, err := s.fileRepo.ListTrashed(ownerID, isAdmin)
if err != nil {
return nil, err
}
@@ -499,6 +625,7 @@ func (s *FileService) GetDownloadPath(storageKey string) string {
func (s *FileService) toFileVO(file *model.File) *dto.FileVO {
vo := &dto.FileVO{
ID: dto.StringID(file.ID),
BizID: file.BizID,
Name: file.Name,
Extension: file.Extension,
FolderID: dto.StringID(file.FolderID),
@@ -666,6 +793,7 @@ func (s *FileService) GrantPermission(fileID uint64, grantBy uint64, req *dto.Gr
}
return config.DB.Create(&model.FilePermission{
ID: utils.GenID(),
FileID: fileID,
UserID: userID,
RoleID: roleID,

View File

@@ -48,6 +48,14 @@ func (s *FolderService) Create(req *dto.CreateFolderRequest, ownerID uint64) (*d
return nil, errors.New("同名文件夹已存在")
}
// 检查biz_id唯一性
if req.BizID != "" {
existingBiz, _ := s.folderRepo.FindByBizID(req.BizID, ownerID)
if existingBiz != nil {
return nil, fmt.Errorf("业务编号 %s 已存在", req.BizID)
}
}
folderID := utils.GenID()
folder := &model.Folder{
ID: folderID,
@@ -253,6 +261,7 @@ func (s *FolderService) getUserRoleIDs(userID uint64) []uint64 {
func (s *FolderService) toFileVO(file *model.File) *dto.FileVO {
vo := &dto.FileVO{
ID: dto.StringID(file.ID),
BizID: file.BizID,
Name: file.Name,
Extension: file.Extension,
FolderID: dto.StringID(file.FolderID),

View File

@@ -4,6 +4,7 @@ import (
"seeyon-filesystem/config"
"seeyon-filesystem/dto"
"seeyon-filesystem/model"
"seeyon-filesystem/utils"
)
// LogService 日志服务
@@ -16,6 +17,7 @@ func NewLogService() *LogService {
// Record 记录操作日志
func (s *LogService) Record(userID uint64, username, action, resource, detail, ip, ua string, status int) {
log := &model.OperationLog{
ID: utils.GenID(),
UserID: userID,
Username: username,
Action: action,

View File

@@ -33,6 +33,7 @@ func (s *OpenAPIService) CreateApp(appName string) (*dto.AppVO, error) {
appSecret := generateRandomHex(32)
app := &model.App{
ID: utils.GenID(),
AppID: appID,
AppSecret: appSecret,
AppName: appName,

View File

@@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
@@ -37,7 +38,7 @@ func (s *ShareService) Create(req *dto.ShareCreateRequest, ownerID uint64) (*dto
if err != nil {
return nil, errors.New("文件不存在")
}
if file.OwnerID != ownerID {
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
return nil, errors.New("无权分享此文件")
}
@@ -48,6 +49,7 @@ func (s *ShareService) Create(req *dto.ShareCreateRequest, ownerID uint64) (*dto
}
share := &model.Share{
ID: utils.GenID(),
FileID: fileID,
OwnerID: ownerID,
ShareCode: shareCode,
@@ -176,7 +178,7 @@ func (s *ShareService) toShareVO(share *model.Share) *dto.ShareVO {
// 生成完整分享链接
publicURL := config.AppConfig.Server.PublicURL
if publicURL == "" {
publicURL = "http://localhost:8080"
publicURL = fmt.Sprintf("http://localhost:%d", config.AppConfig.Server.Port)
}
publicURL = strings.TrimRight(publicURL, "/")
@@ -194,6 +196,29 @@ func (s *ShareService) toShareVO(share *model.Share) *dto.ShareVO {
}
}
// canAccessFile 检查用户是否有权操作该文件(owner / admin / 被授权)
func (s *ShareService) canAccessFile(fileID, fileOwnerID, userID uint64) bool {
if fileOwnerID == userID {
return true
}
// 管理员
var count int64
config.DB.Model(&model.UserRole{}).
Joins("JOIN fs_role ON fs_role.id = fs_user_role.role_id").
Where("fs_user_role.user_id = ? AND fs_role.name = ?", userID, "admin").
Count(&count)
if count > 0 {
return true
}
// 文件级授权
config.DB.Model(&model.FilePermission{}).
Where("file_id = ? AND (user_id = ? OR role_id IN (?))",
fileID, userID,
config.DB.Model(&model.UserRole{}).Select("role_id").Where("user_id = ?", userID),
).Count(&count)
return count > 0
}
// generateShareCode 生成8字节随机分享码(16位十六进制字符串)
func generateShareCode() (string, error) {
bytes := make([]byte, 8)

View File

@@ -295,6 +295,7 @@ func (s *SSOService) findOrCreateUser(provider *model.SSOProvider, ssoUserData s
localUsername := fmt.Sprintf("sso_%s_%s", provider.Type, username)
newUser := &model.User{
ID: utils.GenID(),
Username: localUsername,
Password: "_SSO_NO_PASSWORD_", // SSO用户无本地密码
Email: ssoUserData["email"],
@@ -323,6 +324,7 @@ func (s *SSOService) findOrCreateUser(provider *model.SSOProvider, ssoUserData s
// saveSession 保存SSO会话
func (s *SSOService) saveSession(userID, providerID uint64, externalID, accessToken, refreshToken string, expiresAt time.Time) {
session := &model.SSOSession{
ID: utils.GenID(),
UserID: userID,
ProviderID: providerID,
ExternalID: externalID,

View File

@@ -7,6 +7,7 @@ import (
"seeyon-filesystem/config"
"seeyon-filesystem/dto"
"seeyon-filesystem/model"
"seeyon-filesystem/utils"
)
// StorageAssignService 存储策略分配服务
@@ -245,6 +246,7 @@ func (s *StorageAssignService) SetDefault(policyID uint64) error {
// 创建新记录
return config.DB.Create(&model.StorageAssignment{
ID: utils.GenID(),
PolicyID: policyID,
IsDefault: 1,
Status: 1,
@@ -265,6 +267,7 @@ func (s *StorageAssignService) AssignToRole(roleID, policyID uint64, quota int64
}
return config.DB.Create(&model.StorageAssignment{
ID: utils.GenID(),
GroupID: roleID,
PolicyID: policyID,
StorageQuota: quota,
@@ -286,6 +289,7 @@ func (s *StorageAssignService) AssignToUser(userID, policyID uint64, quota int64
}
return config.DB.Create(&model.StorageAssignment{
ID: utils.GenID(),
UserID: userID,
PolicyID: policyID,
StorageQuota: quota,