diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 5d18650..4f69805 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -30,6 +30,7 @@ import ( "github.com/qianfree/team-api/internal/response" "github.com/qianfree/team-api/internal/utility/crypto" + adminHandler "github.com/qianfree/team-api/internal/handler/admin" "github.com/qianfree/team-api/internal/handler/public" "github.com/qianfree/team-api/internal/handler/relay" setupHandler "github.com/qianfree/team-api/internal/handler/setup" @@ -299,6 +300,14 @@ var ( }) }) + // File streaming serve — admin auth required, bypasses JSON response wrapper. + // 流式返回二进制(local 存储模式 + 未来应用层代理下载),不能走统一 JSON 响应格式, + // 故独立注册路由组(不套 MiddlewareHandlerResponse),只套鉴权与审计中间件。 + s.Group("/api/admin/files", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.AdminAuth, middleware.AdminPermissionGuard, middleware.OperationLog) + group.GET("/{id}/serve", adminHandler.HandleFileServe) + }) + // Open Platform API — HMAC-SHA256 authentication s.Group("/api/open", func(group *ghttp.RouterGroup) { group.Middleware(middleware.MiddlewareHandlerResponse) diff --git a/internal/controller/admin/admin_v1_data_governance.go b/internal/controller/admin/admin_v1_data_governance.go index 6a34bc2..acdc76f 100644 --- a/internal/controller/admin/admin_v1_data_governance.go +++ b/internal/controller/admin/admin_v1_data_governance.go @@ -4,6 +4,7 @@ import ( "context" "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/logic/task" "github.com/qianfree/team-api/internal/service" ) @@ -14,7 +15,13 @@ func (c *ControllerV1) DataGovernanceSettingsUpdate(ctx context.Context, req *v1 return service.Admin().DataGovernanceSettingsUpdate(ctx, req) } func (c *ControllerV1) DataGovernanceExport(ctx context.Context, req *v1.DataGovernanceExportReq) (res *v1.DataGovernanceExportRes, err error) { - return service.Admin().DataGovernanceExport(ctx, req) + res, err = service.Admin().DataGovernanceExport(ctx, req) + if err == nil && res != nil && res.TaskID > 0 { + // 创建后立即异步执行,无需等待 cron 周期(最长 60s 的轮询延迟对交互式操作不可接受)。 + // ExecuteTask 有 CAS 保护(WHERE status='pending'),即使 cron 同时捡起也不会重复执行。 + task.ExecuteTaskAfterCreate(res.TaskID) + } + return } func (c *ControllerV1) DataGovernanceDeletion(ctx context.Context, req *v1.DataGovernanceDeletionReq) (res *v1.DataGovernanceDeletionRes, err error) { return service.Admin().DataGovernanceDeletion(ctx, req) diff --git a/internal/handler/admin/file_serve.go b/internal/handler/admin/file_serve.go new file mode 100644 index 0000000..7b28b57 --- /dev/null +++ b/internal/handler/admin/file_serve.go @@ -0,0 +1,90 @@ +package admin + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + + "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/common" +) + +// HandleFileServe 流式返回文件内容(应用层鉴权后由 provider.Download 读取转发)。 +// +// 用于 local 存储模式(无预签名 URL)以及任何需要应用层代理下载的场景。走 AdminAuth + +// AdminPermissionGuard 中间件(cmd.go 注册时套上),**不**走统一 JSON 响应包装,直接写 +// http.ResponseWriter 返回二进制流。鉴权依赖 Authorization header,前端必须用 axios +// responseType:'blob' 请求后转 ObjectURL,不能直接 window.open / (不带 header 会 401)。 +func HandleFileServe(r *ghttp.Request) { + ctx := r.Context() + + fileID, err := strconv.ParseInt(r.Get("id").String(), 10, 64) + if err != nil || fileID <= 0 { + writeServeError(r, http.StatusBadRequest, "无效的文件 ID") + return + } + + var record *common.FileRecord + if err := dao.FilFiles.Ctx(ctx).Where("id", fileID).Scan(&record); err != nil { + g.Log().Warningf(ctx, "[FileServe] query file %d: %v", fileID, err) + writeServeError(r, http.StatusInternalServerError, "文件查询失败") + return + } + if record == nil { + writeServeError(r, http.StatusNotFound, "文件不存在") + return + } + + svc, err := common.NewFileServiceFromConfig(ctx) + if err != nil { + g.Log().Warningf(ctx, "[FileServe] init storage for file %d: %v", fileID, err) + writeServeError(r, http.StatusInternalServerError, "存储初始化失败") + return + } + + reader, err := svc.Provider().Download(ctx, record.StoragePath) + if err != nil { + g.Log().Warningf(ctx, "[FileServe] read file %d (path=%s): %v", fileID, record.StoragePath, err) + writeServeError(r, http.StatusInternalServerError, "文件读取失败") + return + } + defer reader.Close() + + contentType := record.MimeType + if contentType == "" { + contentType = "application/octet-stream" + } + disposition := "attachment" + if r.GetQuery("disposition").String() == "inline" { + disposition = "inline" + } + filename := record.OriginalName + if filename == "" { + filename = record.Filename + } + + r.Response.Header().Set("Content-Type", contentType) + // RFC 5987:filename* 支持 UTF-8 文件名(中文等),现代浏览器优先采用。 + r.Response.Header().Set("Content-Disposition", + fmt.Sprintf(`%s; filename*=UTF-8''%s`, disposition, url.PathEscape(filename))) + if record.Size > 0 { + r.Response.Header().Set("Content-Length", strconv.FormatInt(record.Size, 10)) + } + + if _, err := io.Copy(r.Response.Writer, reader); err != nil { + // 流式传输中断(客户端取消等)只能记日志,响应头已发出无法回滚。 + g.Log().Warningf(ctx, "[FileServe] stream file %d interrupted: %v", fileID, err) + } +} + +// writeServeError 在流式写入开始前返回 JSON 错误(与统一响应格式一致)。 +// 一旦开始 io.Copy,响应头已发出,错误只能记日志。 +func writeServeError(r *ghttp.Request, status int, msg string) { + r.Response.WriteStatus(status) + r.Response.WriteJson(g.Map{"code": status, "message": msg}) +} diff --git a/internal/logic/admin/file.go b/internal/logic/admin/file.go index 57a2426..9ce857b 100644 --- a/internal/logic/admin/file.go +++ b/internal/logic/admin/file.go @@ -20,11 +20,16 @@ func deriveFileCategory(storagePath, mimeType string) string { return "other" } -// adminFileService 从数据库配置构造 FileService;对象存储未配置时返回业务错误。 +// adminFileService 从数据库配置构造 FileService。 +// +// 对象存储未配置时,NewFileServiceFromConfig 自动降级到本地磁盘 provider, +// 因此本函数几乎不返回 error;仅当存储 provider 构造本身失败(如本地目录不可写、 +// OSS 配置畸形)时返回错误。local 模式下 GetDownloadURL 返回应用层 serve URL, +// FileDownload/FileDelete 自动适配,无需调用方分支。 func adminFileService(ctx context.Context) (*common.FileService, error) { svc, err := common.NewFileServiceFromConfig(ctx) if err != nil { - return nil, common.NewBadRequestError("对象存储未配置,请先在系统设置中配置存储") + return nil, common.NewBadRequestError("存储初始化失败,请检查系统设置中的存储配置") } return svc, nil } diff --git a/internal/logic/billing/transaction.go b/internal/logic/billing/transaction.go new file mode 100644 index 0000000..73f2b38 --- /dev/null +++ b/internal/logic/billing/transaction.go @@ -0,0 +1,46 @@ +package billing + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + + "github.com/qianfree/team-api/internal/dao" +) + +// TransactionQueryParams holds common filter parameters for transaction queries. +type TransactionQueryParams struct { + TenantID int64 // 租户 ID,0 表示不过滤 + Type string // 交易类型 + Username string // 用户名(模糊匹配) + ModelName string // 模型名称(模糊匹配) + StartDate string // 开始日期(YYYY-MM-DD) + EndDate string // 结束日期(YYYY-MM-DD) +} + +// BuildTransactionQuery 构建 bil_transactions 基础查询,应用通用过滤条件。 +// 调用方可继续追加特定过滤、字段选择、JOIN 和排序。 +func BuildTransactionQuery(ctx context.Context, params TransactionQueryParams) *gdb.Model { + query := dao.BilTransactions.Ctx(ctx) + + if params.TenantID > 0 { + query = query.Where("bil_transactions.tenant_id", params.TenantID) + } + if params.Type != "" { + query = query.Where("bil_transactions.type", params.Type) + } + if params.StartDate != "" { + query = query.Where("bil_transactions.created_at >= ?", params.StartDate+" 00:00:00") + } + if params.EndDate != "" { + query = query.Where("bil_transactions.created_at <= ?", params.EndDate+" 23:59:59") + } + if params.Username != "" { + query = query.Where("tu.username LIKE ?", "%"+params.Username+"%") + } + if params.ModelName != "" { + query = query.Where("bil_transactions.model_name LIKE ?", "%"+params.ModelName+"%") + } + + return query +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 8c6d11a..5709c39 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -226,8 +226,8 @@ var Registry = []SettingDef{ // ── Storage ── {Key: "storage_provider", Type: SettingTypeString, Default: "minio", Category: "storage", - Label: "存储供应商", Validation: "enum:s3,minio,r2,oss,cos", - Description: "对象存储供应商类型"}, + Label: "存储供应商", Validation: "enum:s3,minio,r2,oss,cos,local", + Description: "对象存储供应商类型;local 表示本地磁盘存储(仅适合单实例部署)"}, {Key: "storage_endpoint", Type: SettingTypeString, Default: "", Category: "storage", Label: "存储端点", Description: "S3/MinIO: https://s3.amazonaws.com, OSS: https://oss-cn-hangzhou.aliyuncs.com, COS: https://cos.ap-guangzhou.myqcloud.com, R2: https://.r2.cloudflarestorage.com"}, @@ -243,6 +243,8 @@ var Registry = []SettingDef{ Label: "启用 SSL"}, {Key: "storage_path_prefix", Type: SettingTypeString, Default: "team-api", Category: "storage", Label: "路径前缀", Description: "存储路径前缀,用于隔离不同环境"}, + {Key: "storage_local_dir", Type: SettingTypeString, Default: "./data/files", Category: "storage", + Label: "本地存储目录", Description: "本地磁盘存储根目录,仅 provider=local(或对象存储未配置降级)时生效。相对路径基于可执行文件目录。仅适合单实例部署;多实例需改用对象存储或挂载共享卷(PVC/NFS),否则不同副本间文件不互通"}, // Data Governance {Key: "data_retention_api_logs_days", Type: SettingTypeInt, Default: "90", Category: "data_governance", diff --git a/internal/logic/common/storage.go b/internal/logic/common/storage.go index 4561c44..ea80396 100644 --- a/internal/logic/common/storage.go +++ b/internal/logic/common/storage.go @@ -6,6 +6,7 @@ import ( "github.com/qianfree/team-api/internal/dao" do "github.com/qianfree/team-api/internal/model/do" "io" + "path/filepath" "strings" "time" @@ -43,6 +44,12 @@ func NewFileService(provider StorageProvider) *FileService { } } +// Provider 返回底层存储 provider,供需要直接操作 provider(如自定义 key 上传)的场景使用。 +func (s *FileService) Provider() StorageProvider { return s.provider } + +// ProviderName 返回 provider 名称(s3/oss/cos/local 等),用于下载链路按后端类型分流。 +func (s *FileService) ProviderName() string { return s.providerName } + // FileUpload 表示一次文件上传请求。 type FileUpload struct { Reader io.Reader @@ -86,22 +93,37 @@ func applyStoragePrefix(prefix, key string) string { return prefix + "/" + key } -// Upload 上传文件、通过 provider 存储,并写入元数据记录。 +// Upload 上传文件、通过 provider 存储,并写入元数据记录。存储 key 自动生成为 +// "{日期}/{uuid}{扩展名}"——按日期分目录便于生命周期策略,UUID 保证唯一且不可猜测。 func (s *FileService) Upload(ctx context.Context, upload *FileUpload) (*FileRecord, error) { - // 生成存储 key(相对于 provider 配置的路径前缀):{日期}/{uuid}{扩展名}—— - // 按日期分目录便于生命周期策略,UUID 保证唯一且不可猜测,保留原始扩展名。 ext := extFromFilename(upload.Filename) uuidStr := uuid.New().String() key := fmt.Sprintf("%s/%s%s", time.Now().Format("2006-01-02"), uuidStr, ext) - fileName := uuidStr + ext + return s.persist(ctx, upload, key) +} - // 上传到存储 provider。provider 内部会应用配置的路径前缀,因此这里持久化 raw key - // (而非返回的 full key):Download/Delete/PresignedURL 会把该 key 再次传回 provider, - // 由其恰好再加一次前缀。若在此存储已带前缀的 key,读取时会被重复加前缀两次。 +// UploadWithKey 用指定的存储 key 上传文件并写入元数据记录。 +// +// 与 Upload(自动生成 "{date}/{uuid}" key)不同,本方法允许调用方指定 key,用于需要特定 +// 路径约定的场景——例如数据导出必须保留 "exports/" 前缀,否则文件列表的 export 分类 +// 与保留期清理(storage_path LIKE 'exports/%')将无法识别。其余行为与 Upload 一致。 +func (s *FileService) UploadWithKey(ctx context.Context, upload *FileUpload, key string) (*FileRecord, error) { + return s.persist(ctx, upload, key) +} + +// persist 执行实际上传 + 元数据写入(Upload 与 UploadWithKey 共用)。 +// +// provider 内部会应用配置的路径前缀,因此这里持久化 raw key(而非 provider 返回的 full key): +// Download/Delete/PresignedURL 会把该 key 再次传回 provider,由其恰好再加一次前缀。 +// 若在此存储已带前缀的 key,读取时会被重复加前缀两次。元数据写入失败时回滚已上传对象, +// 避免存储泄漏。 +func (s *FileService) persist(ctx context.Context, upload *FileUpload, key string) (*FileRecord, error) { if _, err := s.provider.Upload(ctx, upload.Reader, key, upload.ContentType); err != nil { return nil, gerror.Wrapf(err, "upload to storage") } + fileName := filepath.Base(key) + // 写入元数据记录 result, err := dao.FilFiles.Ctx(ctx).Data(do.FilFiles{ TenantId: upload.TenantID, @@ -136,7 +158,24 @@ func (s *FileService) Upload(ctx context.Context, upload *FileUpload) (*FileReco }, nil } -// GetDownloadURL 生成用于下载文件的预签名 URL。 +// serveURL 返回应用层 serve 端点的相对 URL,仅用于 local provider 的下载/预览。 +// +// 路径不含 "/api" 前缀——与前端 request 实例的 baseURL(/api)约定一致,前端 request.get(url) +// 会自动补全为 /api/admin/files/{id}/serve(与现有 /admin/files/{id}/download 调用同风格)。 +// 因 serve 端点走 AdminAuth(Authorization header),前端必须用 axios responseType:'blob' 请求 +// 再转 ObjectURL,不能直接 window.open / (不带 header 会 401)。 +func (s *FileService) serveURL(fileID int64, thumb bool, width int) string { + q := "" + if thumb { + q = fmt.Sprintf("?variant=thumb&width=%d", width) + } + return fmt.Sprintf("/admin/files/%d/serve%s", fileID, q) +} + +// GetDownloadURL 生成用于下载文件的 URL。 +// +// 对象存储走预签名 URL(OSS/S3/COS 直接访问桶);local provider 不支持预签名, +// 返回应用层 serve 端点的相对 URL(由 handler 鉴权后流式返回内容)。 func (s *FileService) GetDownloadURL(ctx context.Context, fileID int64) (string, error) { var record *FileRecord err := dao.FilFiles.Ctx(ctx). @@ -149,11 +188,16 @@ func (s *FileService) GetDownloadURL(ctx context.Context, fileID int64) (string, return "", gerror.Newf("file not found: %d", fileID) } + if s.providerName == "local" { + return s.serveURL(fileID, false, 0), nil + } return s.provider.PresignedURL(ctx, record.StoragePath, 24*time.Hour) } -// GetThumbnailURL 生成用于预览缩略图的预签名 URL。只有图片会做服务端缩放; +// GetThumbnailURL 生成用于预览缩略图的 URL。只有图片会做服务端缩放; // 非图片文件(以及不支持原生图片处理的 provider)回退到原图对象。 +// +// local provider 无服务端图片处理能力,返回原图的 serve URL(前端 CSS 缩放)。 func (s *FileService) GetThumbnailURL(ctx context.Context, fileID int64, width int) (string, error) { var record *FileRecord err := dao.FilFiles.Ctx(ctx). @@ -169,6 +213,10 @@ func (s *FileService) GetThumbnailURL(ctx context.Context, fileID int64, width i if width <= 0 { width = 400 } + // local:无服务端图片处理,返回原图 serve URL(与 S3/MinIO/R2 的回退策略一致)。 + if s.providerName == "local" { + return s.serveURL(fileID, true, width), nil + } // 非图片对象无法缩放;返回原图的预签名 URL。 if !strings.HasPrefix(record.MimeType, "image/") { return s.provider.PresignedURL(ctx, record.StoragePath, 24*time.Hour) diff --git a/internal/logic/common/storage_factory.go b/internal/logic/common/storage_factory.go index 9f89b16..01f6491 100644 --- a/internal/logic/common/storage_factory.go +++ b/internal/logic/common/storage_factory.go @@ -23,6 +23,7 @@ type StorageConfig struct { SecretKey string UseSSL bool PathPrefix string + LocalDir string // 本地存储根目录,仅 provider=local 时使用 } // GetStorageConfig 从配置服务读取存储配置。 @@ -36,6 +37,7 @@ func GetStorageConfig(ctx context.Context) *StorageConfig { SecretKey: Config().GetString(ctx, "storage_access_key_secret"), UseSSL: Config().GetBool(ctx, "storage_use_ssl"), PathPrefix: Config().GetString(ctx, "storage_path_prefix"), + LocalDir: Config().GetString(ctx, "storage_local_dir"), } if cfg.PathPrefix == "" { cfg.PathPrefix = "team-api" @@ -52,6 +54,8 @@ func NewStorageProvider(cfg *StorageConfig) (StorageProvider, error) { return NewOSSProvider(cfg) case "cos": return NewCOSProvider(cfg) + case "local": + return NewLocalProvider(cfg) default: return nil, fmt.Errorf("unsupported storage provider: %s", cfg.Provider) } @@ -65,11 +69,23 @@ func IsStorageConfigured(ctx context.Context) bool { } // NewFileServiceFromConfig 使用配置服务(数据库设置)中的存储配置创建一个 FileService。 -// 未配置(缺 provider 或 bucket)时返回 ErrStorageNotConfigured 哨兵错误,供上层友好提示。 +// +// 对象存储未配置(provider 或 bucket 为空)时,**降级到本地磁盘存储**(provider=local), +// 保证数据导出、文件下载/删除等基本管理功能在未配对象存储时仍可用(仅适合单实例部署)。 +// 因此本函数几乎不返回错误,调用方拿到的是「当前可用的存储后端」而非「一定是对象存储」。 +// +// 需要强制要求对象存储的链路(如 AI 图片 re-host)应改用 IsStorageConfigured 判断, +// 并自行返回 ErrStorageNotConfigured 哨兵,以保留「未配置」这一可操作语义。 func NewFileServiceFromConfig(ctx context.Context) (*FileService, error) { cfg := GetStorageConfig(ctx) if cfg.Provider == "" || cfg.Bucket == "" { - return nil, ErrStorageNotConfigured + localCfg := *cfg + localCfg.Provider = "local" + provider, err := NewStorageProvider(&localCfg) + if err != nil { + return nil, err + } + return &FileService{provider: provider, providerName: "local"}, nil } provider, err := NewStorageProvider(cfg) diff --git a/internal/logic/common/storage_local.go b/internal/logic/common/storage_local.go new file mode 100644 index 0000000..84a83a9 --- /dev/null +++ b/internal/logic/common/storage_local.go @@ -0,0 +1,115 @@ +package common + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// LocalStorageProvider 基于本地磁盘实现 StorageProvider,作为对象存储未配置时的降级后端。 +// +// 仅适合单实例部署——多实例间本地磁盘不共享,文件在生成实例之外的副本上会 404。 +// 生产环境应配置 OSS/S3/COS;本 provider 主要服务于开发环境与未配对象存储时的基本可用性 +// (数据导出、文件下载/删除等),不应用于 AI 图片 re-host 等高频读路径(后者由调用方用 +// IsStorageConfigured 守卫,保持「要求对象存储」语义)。 +type LocalStorageProvider struct { + rootDir string // 本地存储根目录(绝对路径) + prefix string // 路径前缀,与 OSS provider 对称(默认 team-api) +} + +// NewLocalProvider 创建一个本地磁盘存储 provider。rootDir 不存在时会递归创建。 +func NewLocalProvider(cfg *StorageConfig) (*LocalStorageProvider, error) { + dir := cfg.LocalDir + if dir == "" { + dir = "./data/files" + } + abs, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("resolve local storage dir: %w", err) + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return nil, fmt.Errorf("create local storage dir: %w", err) + } + return &LocalStorageProvider{rootDir: abs, prefix: cfg.PathPrefix}, nil +} + +// resolve 将存储 key 解析为本地绝对路径,带路径遍历防护。 +// +// 前导 "/" 强制绝对化使 filepath.Clean 吃掉 "..";随后再校验结果必须仍在 rootDir 之下, +// 双重防御 "../"、绝对路径、编码变体等绕过手段。raw key 以数字/字母开头,不会与 prefix 冲突。 +func (s *LocalStorageProvider) resolve(key string) (string, error) { + fullKey := applyStoragePrefix(s.prefix, key) + cleaned := filepath.Clean("/" + fullKey) + cleaned = strings.TrimPrefix(cleaned, "/") + abs := filepath.Join(s.rootDir, cleaned) + if !strings.HasPrefix(abs+string(os.PathSeparator), s.rootDir+string(os.PathSeparator)) { + return "", fmt.Errorf("path traversal detected: %s", key) + } + return abs, nil +} + +// Upload 将文件写入本地磁盘。 +func (s *LocalStorageProvider) Upload(ctx context.Context, reader io.Reader, key string, contentType string) (string, error) { + abs, err := s.resolve(key) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return "", fmt.Errorf("create dir: %w", err) + } + f, err := os.Create(abs) + if err != nil { + return "", fmt.Errorf("create file: %w", err) + } + defer f.Close() + if _, err := io.Copy(f, reader); err != nil { + return "", fmt.Errorf("write file: %w", err) + } + return applyStoragePrefix(s.prefix, key), nil +} + +// Download 打开本地文件供读取。调用方负责 Close 返回的 ReadCloser。 +func (s *LocalStorageProvider) Download(ctx context.Context, key string) (io.ReadCloser, error) { + abs, err := s.resolve(key) + if err != nil { + return nil, err + } + f, err := os.Open(abs) + if err != nil { + return nil, fmt.Errorf("open file: %w", err) + } + return f, nil +} + +// Delete 删除本地文件。文件不存在视为成功(与 S3/OSS 语义对齐——FileService.Delete 依赖 +// 此行为避免误报错而保留库行,使对象成为无法定位的孤儿)。 +func (s *LocalStorageProvider) Delete(ctx context.Context, key string) error { + abs, err := s.resolve(key) + if err != nil { + return err + } + if err := os.Remove(abs); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete file: %w", err) + } + return nil +} + +// PresignedURL 本地存储不支持预签名语义。 +// +// 本 provider 的下载统一走应用层 serve 端点:FileService.GetDownloadURL 在 local 时直接 +// 返回 serve URL(不调本方法)。返回 error 以防误用——若调用方未走 serve 链路却拿到伪造 +// URL,反而制造难以排查的 404。AI 图片 re-host 等仍需真实预签名的链路,依赖 +// acquireSyncImageFileSvc 的 IsStorageConfigured 守卫保持「要求对象存储」语义。 +func (s *LocalStorageProvider) PresignedURL(ctx context.Context, key string, expires time.Duration) (string, error) { + return "", fmt.Errorf("local provider does not support presigned URLs") +} + +// PresignedThumbnailURL 本地存储无服务端图片处理能力,回退到 PresignedURL(同 S3/MinIO/R2 +// 的「返回原图」策略,不引入图片处理库)。local 模式下缩略图预览由前端 CSS 缩放。 +func (s *LocalStorageProvider) PresignedThumbnailURL(ctx context.Context, key string, width int, expires time.Duration) (string, error) { + return s.PresignedURL(ctx, key, expires) +} diff --git a/internal/logic/common/storage_task_result.go b/internal/logic/common/storage_task_result.go index 07ba136..e587286 100644 --- a/internal/logic/common/storage_task_result.go +++ b/internal/logic/common/storage_task_result.go @@ -37,9 +37,15 @@ func TaskResultImageURLs(ctx context.Context, tenantID int64, publicTaskID strin return "", "" } + // AI re-host 结果图强制要求对象存储:local 降级返回的 admin serve 相对 URL 无法给租户 + // 控制台 用(租户侧无对应端点、无 admin 鉴权)。未配对象存储时返回空串,调用方 + // 回退到任务表存的 result_url。 + if !IsStorageConfigured(ctx) { + return "", "" + } svc, err := NewFileServiceFromConfig(ctx) if err != nil { - return "", "" // 对象存储未配置 → 无法生成,回退原 result_url + return "", "" } if thumbWidth <= 0 { diff --git a/internal/logic/task/data_tasks.go b/internal/logic/task/data_tasks.go index ac609ac..c9b1926 100644 --- a/internal/logic/task/data_tasks.go +++ b/internal/logic/task/data_tasks.go @@ -1,11 +1,10 @@ package task import ( + "bytes" "context" - "crypto/rand" "encoding/json" "fmt" - "math/big" "time" "github.com/gogf/gf/v2/frame/g" @@ -44,36 +43,59 @@ func handleDataExport(ctx context.Context, payload json.RawMessage) (any, error) switch scope { case "members": var members []map[string]any - if err := dao.TntUsers.Ctx(ctx).Where("tenant_id", p.TenantID). - Fields("id, username, display_name, role, status, created_at").Scan(&members); err != nil { + result, err := dao.TntUsers.Ctx(ctx).Where("tenant_id", p.TenantID). + Fields("id, username, display_name, role, status, created_at"). + All() + if err != nil { g.Log().Warningf(ctx, "export members for tenant %d: %v", p.TenantID, err) - } - for _, m := range members { - delete(m, "email") + } else { + for _, rec := range result { + m := rec.Map() + delete(m, "email") + members = append(members, m) + } } exportData["members"] = members case "usage": var logs []map[string]any - if err := dao.BilUsageLogs.Ctx(ctx).Where("tenant_id", p.TenantID). + result, err := dao.BilUsageLogs.Ctx(ctx).Where("tenant_id", p.TenantID). OrderDesc("created_at").Limit(10000). - Fields("id, model_name, input_tokens, output_tokens, actual_cost, created_at").Scan(&logs); err != nil { + Fields("id, model_name, input_tokens, output_tokens, actual_cost, created_at"). + All() + if err != nil { g.Log().Warningf(ctx, "export usage for tenant %d: %v", p.TenantID, err) + } else { + for _, rec := range result { + logs = append(logs, rec.Map()) + } } exportData["usage"] = logs case "billing": var records []map[string]any - if err := dao.BilRecords.Ctx(ctx).Where("tenant_id", p.TenantID). + result, err := dao.BilRecords.Ctx(ctx).Where("tenant_id", p.TenantID). OrderDesc("created_at").Limit(10000). - Fields("id, type, amount, balance_before, balance_after, description, created_at").Scan(&records); err != nil { + Fields("id, relay_mode, model_name, input_tokens, output_tokens, total_cost, currency, status, created_at"). + All() + if err != nil { g.Log().Warningf(ctx, "export billing for tenant %d: %v", p.TenantID, err) + } else { + for _, rec := range result { + records = append(records, rec.Map()) + } } exportData["billing_records"] = records case "logs": var logs []map[string]any - if err := common.AuditModelCtx(ctx, "aud_operation_logs").Where("tenant_id", p.TenantID). + result, err := common.AuditModelCtx(ctx, "aud_operation_logs").Where("tenant_id", p.TenantID). OrderDesc("created_at").Limit(10000). - Fields("id, action, resource_type, resource_id, ip_address, user_agent, created_at").Scan(&logs); err != nil { + Fields("id, action, resource_type, resource_id, ip_address, detail, created_at"). + All() + if err != nil { g.Log().Warningf(ctx, "export logs for tenant %d: %v", p.TenantID, err) + } else { + for _, rec := range result { + logs = append(logs, rec.Map()) + } } exportData["operation_logs"] = logs } @@ -84,27 +106,42 @@ func handleDataExport(ctx context.Context, payload json.RawMessage) (any, error) return nil, fmt.Errorf("marshal export data: %w", err) } - storagePath := fmt.Sprintf("exports/tenant_%d/%s.json", p.TenantID, time.Now().Format("20060102_150405")) - password := generateRandomPassword(16) - - _, err = dao.FilFiles.Ctx(ctx).Data(do.FilFiles{ - TenantId: p.TenantID, - UserId: p.RequestedBy, - OriginalName: fmt.Sprintf("export_tenant_%d_%s.json", p.TenantID, time.Now().Format("20060102")), - StoragePath: storagePath, - MimeType: "application/json", - Size: int64(len(jsonData)), - }).Insert() + // 通过 FileService 上传(未配对象存储时自动降级本地磁盘,保证导出功能基本可用)。 + // storage_path 必须保留 "exports/" 前缀——文件列表的 export 分类与保留期清理 + // (export_file_cleanup cron)均据此识别导出文件,故用 UploadWithKey 而非 Upload + // (后者生成 "{date}/{uuid}" 路径会破坏该判据)。 + svc, err := common.NewFileServiceFromConfig(ctx) if err != nil { - return nil, fmt.Errorf("save file metadata: %w", err) + return nil, fmt.Errorf("初始化存储失败: %w", err) + } + storagePath := fmt.Sprintf("exports/tenant_%d/%s.json", p.TenantID, time.Now().Format("20060102_150405")) + rec, err := svc.UploadWithKey(ctx, &common.FileUpload{ + Reader: bytes.NewReader(jsonData), + Filename: fmt.Sprintf("export_tenant_%d_%s.json", p.TenantID, time.Now().Format("20060102")), + ContentType: "application/json", + Size: int64(len(jsonData)), + TenantID: p.TenantID, + UserID: p.RequestedBy, + }, storagePath) + if err != nil { + return nil, fmt.Errorf("upload export: %w", err) } - g.Log().Infof(ctx, "data export completed for tenant %d, scopes=%v, size=%d bytes", p.TenantID, p.Scopes, len(jsonData)) + // 下载链接:local 返回应用层 serve 相对 URL,OSS 返回预签名 URL(前端按是否 http 分支)。 + downloadURL, err := svc.GetDownloadURL(ctx, rec.ID) + if err != nil { + g.Log().Warningf(ctx, "data export: get download url for tenant %d: %v", p.TenantID, err) + } + + g.Log().Infof(ctx, "data export completed for tenant %d, scopes=%v, size=%d bytes, provider=%s", + p.TenantID, p.Scopes, len(jsonData), svc.ProviderName()) return map[string]any{ - "storage_path": storagePath, - "scopes": p.Scopes, - "size": len(jsonData), - "password": password, + "file_id": rec.ID, + "storage_path": storagePath, + "storage_provider": svc.ProviderName(), + "download_url": downloadURL, + "scopes": p.Scopes, + "size": len(jsonData), }, nil } @@ -168,18 +205,3 @@ func handleExportCleanup(ctx context.Context, payload json.RawMessage) (any, err g.Log().Infof(ctx, "cleaned up export file %d for tenant %d", p.FileID, p.TenantID) return map[string]any{"file_id": p.FileID, "deleted": true}, nil } - -func generateRandomPassword(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, length) - max := big.NewInt(int64(len(charset))) - for i := range b { - n, err := rand.Int(rand.Reader, max) - if err != nil { - b[i] = charset[i%len(charset)] - continue - } - b[i] = charset[n.Int64()] - } - return string(b) -} diff --git a/internal/logic/task/sync_image_worker.go b/internal/logic/task/sync_image_worker.go index 4a46d47..220d00a 100644 --- a/internal/logic/task/sync_image_worker.go +++ b/internal/logic/task/sync_image_worker.go @@ -91,6 +91,14 @@ var acquireSyncImageFileSvc = func(ctx context.Context) (*lcommon.FileService, e if syncImageFileSvc != nil { return syncImageFileSvc, nil } + // AI 图片 re-host 强制要求对象存储:re-host 产出的 URL 直接喂给租户控制台 , + // 而 NewFileServiceFromConfig 在未配对象存储时会降级到 local,返回的 admin 侧 serve 相对 + // URL(/api/admin/files/{id}/serve)租户侧既无对应端点、也无 admin 鉴权,图片无法展示。 + // 且 local 仅单实例适用,不适合 AI 图片这类高频读路径。故在此显式拒绝,imageFailReason + // 会据此给出「请配置对象存储」的友好提示。 + if !lcommon.IsStorageConfigured(ctx) { + return nil, lcommon.ErrStorageNotConfigured + } fs, err := lcommon.NewFileServiceFromConfig(ctx) if err != nil { return nil, err diff --git a/internal/logic/task/task.go b/internal/logic/task/task.go index 110e6b1..3a5d354 100644 --- a/internal/logic/task/task.go +++ b/internal/logic/task/task.go @@ -301,6 +301,26 @@ func RunTaskAsync(task *Task) { }() } +// ExecuteTaskAfterCreate 在后台立即执行刚创建的任务(跳过 cron 周期)。 +// +// 适用于用户主动创建的交互式任务(如数据导出),用户期望提交后立即开始执行, +// 而非等待最长 60 秒的 cron 轮询。goroutine 内部使用 gctx.New() 避免被请求上下文 +// 取消。ExecuteTask 有 CAS 保护(WHERE status='pending'),即使 cron 也同时竞争 +// 也不会重复执行——只有一个会成功 claim,另一个的 RowsAffected == 0 静默退出。 +func ExecuteTaskAfterCreate(taskID int64) { + go func() { + defer func() { + if r := recover(); r != nil { + glogError("panic in ExecuteTaskAfterCreate task %d: %v", taskID, r) + } + }() + bgCtx := gctx.New() + if err := ExecuteTask(bgCtx, taskID); err != nil { + glogError("ExecuteTaskAfterCreate task %d: %v", taskID, err) + } + }() +} + func glogError(format string, args ...any) { g.Log().Errorf(gctx.New(), format, args...) } diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 4a51582..86ef52d 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -203,6 +203,7 @@ var adminPermissionRules = []permissionRule{ {method: "GET", path: "/api/admin/files/stats", perm: "file:view"}, {method: "POST", path: "/api/admin/files/cleanup", perm: "file:cleanup"}, {method: "GET", prefix: "/api/admin/files/", suffix: "/download", perm: "file:view"}, + {method: "GET", prefix: "/api/admin/files/", suffix: "/serve", perm: "file:view"}, {method: "DELETE", prefix: "/api/admin/files/", perm: "file:delete"}, // ── monitor 监控告警 ── diff --git a/web/admin/src/views/system/FilesPage.vue b/web/admin/src/views/system/FilesPage.vue index 6159985..26fad05 100644 --- a/web/admin/src/views/system/FilesPage.vue +++ b/web/admin/src/views/system/FilesPage.vue @@ -1,9 +1,9 @@