fix:修复租户数据导出功能
This commit is contained in:
1 parent
c35136bcf1
commit
e9b5592666
15 files changed
+607
-70
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 / <img src>(不带 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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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://<account_id>.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",
|
||||
|
||||
@@ -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 / <img src>(不带 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -37,9 +37,15 @@ func TaskResultImageURLs(ctx context.Context, tenantID int64, publicTaskID strin
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// AI re-host 结果图强制要求对象存储:local 降级返回的 admin serve 相对 URL 无法给租户
|
||||
// 控制台 <img> 用(租户侧无对应端点、无 admin 鉴权)。未配对象存储时返回空串,调用方
|
||||
// 回退到任务表存的 result_url。
|
||||
if !IsStorageConfigured(ctx) {
|
||||
return "", ""
|
||||
}
|
||||
svc, err := NewFileServiceFromConfig(ctx)
|
||||
if err != nil {
|
||||
return "", "" // 对象存储未配置 → 无法生成,回退原 result_url
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if thumbWidth <= 0 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 直接喂给租户控制台 <img>,
|
||||
// 而 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
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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 监控告警 ──
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { Tag, Button, Modal, Message } from '@arco-design/web-vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, h } from 'vue'
|
||||
import { Tag, Button, Modal, Message, Checkbox, Space } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import {
|
||||
IconFile, IconImage, IconArchive, IconStorage, IconSettings,
|
||||
IconFile, IconImage, IconArchive, IconStorage, IconSettings, IconExport,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
@@ -198,6 +198,25 @@ const previewLoading = ref(false)
|
||||
const previewRecord = ref<any>(null)
|
||||
const previewThumbUrl = ref('')
|
||||
const previewError = ref(false)
|
||||
// local 存储模式下预览图需转为 ObjectURL(serve 端点走 Auth header,不能直接 <img src>),
|
||||
// 该 ObjectURL 需手动释放避免内存泄漏。
|
||||
const previewObjectURL = ref('')
|
||||
function revokePreviewURL() {
|
||||
if (previewObjectURL.value) {
|
||||
URL.revokeObjectURL(previewObjectURL.value)
|
||||
previewObjectURL.value = ''
|
||||
}
|
||||
}
|
||||
onUnmounted(revokePreviewURL)
|
||||
// 判断下载 URL 是否为可直接访问的 http(s) 链接(OSS 预签名);否则是 local serve 相对路径,
|
||||
// 需用 axios blob 请求(携带 Authorization header)再转 ObjectURL。
|
||||
function isDirectURL(url: string) {
|
||||
return /^https?:\/\//i.test(url) || url.startsWith('//')
|
||||
}
|
||||
async function fetchServeBlob(url: string): Promise<Blob> {
|
||||
const res: any = await request.get(url, { responseType: 'blob', timeout: 300000 })
|
||||
return res.data instanceof Blob ? res.data : new Blob([res.data])
|
||||
}
|
||||
|
||||
function isImage(record: any) {
|
||||
return record?.category === 'image' || String(record?.mime_type || '').startsWith('image/')
|
||||
@@ -206,6 +225,7 @@ function isImage(record: any) {
|
||||
// 预览图片:弹窗内展示服务端缩略图(小图,加载快),原图仅在点「下载原图」时请求。
|
||||
async function openPreview(record: any) {
|
||||
previewRecord.value = record
|
||||
revokePreviewURL()
|
||||
previewThumbUrl.value = ''
|
||||
previewError.value = false
|
||||
previewVisible.value = true
|
||||
@@ -214,8 +234,21 @@ async function openPreview(record: any) {
|
||||
const res: any = await request.get(`/admin/files/${record.id}/download`, {
|
||||
params: { variant: 'thumb', width: 600 },
|
||||
})
|
||||
previewThumbUrl.value = res.data?.data?.url || ''
|
||||
if (!previewThumbUrl.value) Message.warning('未获取到预览链接')
|
||||
const url = res.data?.data?.url || ''
|
||||
if (!url) {
|
||||
Message.warning('未获取到预览链接')
|
||||
return
|
||||
}
|
||||
if (isDirectURL(url)) {
|
||||
// OSS 预签名 URL,可直接 <img src>
|
||||
previewThumbUrl.value = url
|
||||
} else {
|
||||
// local serve 相对 URL,取 blob 转 ObjectURL
|
||||
const blob = await fetchServeBlob(url)
|
||||
const objURL = URL.createObjectURL(blob)
|
||||
previewObjectURL.value = objURL
|
||||
previewThumbUrl.value = objURL
|
||||
}
|
||||
} catch {
|
||||
// interceptor toasts
|
||||
} finally {
|
||||
@@ -223,14 +256,30 @@ async function openPreview(record: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// 下载/打开原图:请求原图签名链接并在新标签打开。
|
||||
// 下载/打开原图:OSS 走预签名 URL 新标签打开;local 走 serve 端点 blob 下载(携带 Auth header)。
|
||||
async function downloadOriginal(record: any) {
|
||||
if (!record) return
|
||||
try {
|
||||
const res: any = await request.get(`/admin/files/${record.id}/download`)
|
||||
const url = res.data?.data?.url
|
||||
if (url) window.open(url, '_blank')
|
||||
else Message.warning('未获取到下载链接')
|
||||
if (!url) {
|
||||
Message.warning('未获取到下载链接')
|
||||
return
|
||||
}
|
||||
if (isDirectURL(url)) {
|
||||
window.open(url, '_blank')
|
||||
return
|
||||
}
|
||||
// local serve 相对 URL:blob 下载
|
||||
const blob = await fetchServeBlob(url)
|
||||
const objURL = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = objURL
|
||||
a.download = record.original_name || `file_${record.id}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(objURL)
|
||||
} catch {
|
||||
// interceptor toasts
|
||||
}
|
||||
@@ -313,6 +362,52 @@ function manualCleanup() {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 数据导出触发
|
||||
// ============================================================
|
||||
const exportVisible = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const exportTenantID = ref<number | undefined>(undefined)
|
||||
const exportScopes = ref<string[]>(['members', 'usage', 'billing', 'logs'])
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
{ label: '成员信息', value: 'members' },
|
||||
{ label: 'API 用量', value: 'usage' },
|
||||
{ label: '计费记录', value: 'billing' },
|
||||
{ label: '请求日志', value: 'logs' },
|
||||
]
|
||||
|
||||
function openExportDialog() {
|
||||
exportTenantID.value = undefined
|
||||
exportScopes.value = ['members', 'usage', 'billing', 'logs']
|
||||
exportVisible.value = true
|
||||
}
|
||||
|
||||
async function submitExport() {
|
||||
if (!exportTenantID.value || exportTenantID.value <= 0) {
|
||||
Message.warning('请输入租户 ID')
|
||||
return
|
||||
}
|
||||
if (exportScopes.value.length === 0) {
|
||||
Message.warning('请至少选择一项导出范围')
|
||||
return
|
||||
}
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const res: any = await request.post('/admin/data-governance/export', {
|
||||
tenant_id: exportTenantID.value,
|
||||
scopes: exportScopes.value,
|
||||
})
|
||||
const taskID = res.data?.data?.task_id
|
||||
Message.success(`导出任务已创建(任务 #${taskID}),文件生成后将在列表中显示(约 1 分钟内)`)
|
||||
exportVisible.value = false
|
||||
} catch {
|
||||
// interceptor toasts
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
fetchList()
|
||||
@@ -343,6 +438,10 @@ onMounted(() => {
|
||||
</template>
|
||||
</APopover>
|
||||
<AButton v-if="hasPermission('file:cleanup')" :loading="cleanupLoading" @click="manualCleanup">手动清理过期文件</AButton>
|
||||
<AButton type="primary" @click="openExportDialog">
|
||||
<template #icon><IconExport /></template>
|
||||
触发数据导出
|
||||
</AButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
@@ -419,6 +518,40 @@ onMounted(() => {
|
||||
<AButton type="primary" @click="downloadOriginal(previewRecord)">下载原图</AButton>
|
||||
</div>
|
||||
</AModal>
|
||||
|
||||
<!-- 数据导出弹窗 -->
|
||||
<AModal
|
||||
v-model:visible="exportVisible"
|
||||
title="触发数据导出"
|
||||
:ok-text="exportLoading ? '创建中...' : '开始导出'"
|
||||
:ok-loading="exportLoading"
|
||||
@ok="submitExport"
|
||||
@cancel="exportVisible = false"
|
||||
>
|
||||
<AForm layout="vertical">
|
||||
<AFormItem label="租户 ID" required>
|
||||
<AInputNumber
|
||||
v-model="exportTenantID"
|
||||
:min="1"
|
||||
placeholder="输入要导出数据的租户 ID"
|
||||
style="width:100%"
|
||||
/>
|
||||
</AFormItem>
|
||||
<AFormItem label="导出范围" required>
|
||||
<Checkbox.Group v-model="exportScopes">
|
||||
<Space direction="vertical">
|
||||
<Checkbox v-for="opt in SCOPE_OPTIONS" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
<div class="export-hint">
|
||||
导出任务创建后将在后台异步执行,生成的文件会出现在下方文件列表中(通常 1 分钟内)。
|
||||
导出的 JSON 文件包含所选范围的全部数据,每项最多 10,000 条记录。
|
||||
</div>
|
||||
</AModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -546,4 +679,13 @@ onMounted(() => {
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.export-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user