fix:修复租户数据导出功能

This commit is contained in:
martin committed 2026-07-25 19:57:50 +08:00
1 parent c35136bcf1
commit e9b5592666
15 files changed
+607 -70

No files matched your search

+9
View File
@@ -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)
+90
View File
@@ -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})
}
+7 -2
View File
@@ -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
}
+46
View File
@@ -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
}
+4 -2
View File
@@ -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",
+57 -9
View File
@@ -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)
+18 -2
View File
@@ -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)
+115
View File
@@ -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)
}
+7 -1
View File
@@ -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 {
+67 -45
View File
@@ -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)
}
+8
View File
@@ -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
+20
View File
@@ -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...)
}
+1
View File
@@ -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 监控告警 ──
+150 -8
View File
@@ -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>