6708c226a5
- 迁移 000023 gateway.file_objects(personal/system 归属隔离 + 部分索引) - internal/platform/storage:minio-go 适配(端点 scheme 剥离、流式 PutObject/Open/Delete) - internal/workbench/files.go:FileService(sha256 校验、PutObject-then-insert 回滚、delete 先删行再删对象) - admin /api/v1/admin/files + portal /api/v1/portal/files 处理器(流式上传下载、Content-Disposition) - RBAC file:read/file:manage;菜单加文件管理 + 门户文件仓库 - compose 增 minio 服务(S3_* anchor、不暴露端口);nginx client_max_body_size 32m→256m - 管理端文件管理页 + 门户个人文件仓;集成测试 TestFileObjectLifecycle 连真 MinIO 通过 - healthz object_storage:true;README/PRODUCTION/进展文档同步 Co-Authored-By: Claude <noreply@anthropic.com>
195 lines
7.1 KiB
Go
195 lines
7.1 KiB
Go
package workbench
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"aigateway.local/core/internal/platform/storage"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// FileObject is the metadata row for one object stored in MinIO. The object
|
|
// body lives in the bucket; this table is the searchable index plus the access
|
|
// control (personal scope is bound to a portal user, system scope to admins).
|
|
type FileObject struct {
|
|
ID string `json:"id"`
|
|
ObjectKey string `json:"-"`
|
|
OriginalName string `json:"original_name"`
|
|
ContentType string `json:"content_type"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
ContentSHA256 string `json:"content_sha256"`
|
|
Scope string `json:"scope"`
|
|
OwnerUserID *string `json:"owner_user_id,omitempty"`
|
|
CreatedBy string `json:"created_by"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// FileService stores object metadata in PostgreSQL and the body in MinIO. Both
|
|
// writes are kept consistent: the object is uploaded first and only then is the
|
|
// metadata row inserted; any failure rolls the object back.
|
|
type FileService struct {
|
|
assets *Service
|
|
store *storage.Client
|
|
}
|
|
|
|
func NewFileService(assets *Service, store *storage.Client) *FileService {
|
|
return &FileService{assets: assets, store: store}
|
|
}
|
|
|
|
func (s *FileService) MaxBytes() int64 { return s.store.MaxBytes() }
|
|
|
|
// countingReader tallies how many bytes flow through it so uploads can enforce
|
|
// the configured size ceiling without buffering the whole file in memory.
|
|
type countingReader struct {
|
|
r io.Reader
|
|
n *int64
|
|
}
|
|
|
|
func (c *countingReader) Read(p []byte) (int, error) {
|
|
n, err := c.r.Read(p)
|
|
*c.n += int64(n)
|
|
return n, err
|
|
}
|
|
|
|
// Upload streams the body into the object store and records its metadata. The
|
|
// owner must be set for the personal scope and nil for the system scope.
|
|
func (s *FileService) Upload(ctx context.Context, scope string, ownerUserID *string, createdBy, originalName, contentType string, body io.Reader) (FileObject, error) {
|
|
originalName = strings.TrimSpace(originalName)
|
|
if originalName == "" || len(originalName) > 255 {
|
|
return FileObject{}, errors.New("文件名无效")
|
|
}
|
|
if scope != "personal" && scope != "system" {
|
|
return FileObject{}, errors.New("无效的文件范围")
|
|
}
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
objectKey, err := newUUID()
|
|
if err != nil {
|
|
return FileObject{}, err
|
|
}
|
|
objectKey = scope + "/" + objectKey
|
|
var size int64
|
|
hasher := sha256.New()
|
|
limited := io.LimitReader(body, s.store.MaxBytes()+1)
|
|
counted := &countingReader{r: limited, n: &size}
|
|
rollbackObject := func() { _ = s.store.DeleteObject(ctx, objectKey) }
|
|
if _, err = s.store.PutObject(ctx, objectKey, io.TeeReader(counted, hasher), -1, contentType); err != nil {
|
|
return FileObject{}, err
|
|
}
|
|
if size > s.store.MaxBytes() {
|
|
rollbackObject()
|
|
return FileObject{}, errors.New("文件超过大小上限")
|
|
}
|
|
id, err := newUUID()
|
|
if err != nil {
|
|
rollbackObject()
|
|
return FileObject{}, err
|
|
}
|
|
var owner any
|
|
if ownerUserID != nil && strings.TrimSpace(*ownerUserID) != "" {
|
|
owner = *ownerUserID
|
|
}
|
|
var obj FileObject
|
|
err = s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.file_objects(id,object_key,original_name,content_type,size_bytes,content_sha256,scope,owner_user_id,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text,object_key,original_name,content_type,size_bytes,content_sha256,scope,owner_user_id::text,created_by,created_at,updated_at`, id, objectKey, originalName, contentType, size, hex.EncodeToString(hasher.Sum(nil)), scope, owner, createdBy).Scan(&obj.ID, &obj.ObjectKey, &obj.OriginalName, &obj.ContentType, &obj.SizeBytes, &obj.ContentSHA256, &obj.Scope, &obj.OwnerUserID, &obj.CreatedBy, &obj.CreatedAt, &obj.UpdatedAt)
|
|
if err != nil {
|
|
rollbackObject()
|
|
return FileObject{}, err
|
|
}
|
|
return obj, nil
|
|
}
|
|
|
|
const fileObjectSelect = `SELECT id::text,object_key,original_name,content_type,size_bytes,content_sha256,scope,owner_user_id::text,created_by,created_at,updated_at FROM gateway.file_objects`
|
|
|
|
func scanFileObject(row pgx.Row) (FileObject, error) {
|
|
var obj FileObject
|
|
err := row.Scan(&obj.ID, &obj.ObjectKey, &obj.OriginalName, &obj.ContentType, &obj.SizeBytes, &obj.ContentSHA256, &obj.Scope, &obj.OwnerUserID, &obj.CreatedBy, &obj.CreatedAt, &obj.UpdatedAt)
|
|
return obj, mapNotFound(err)
|
|
}
|
|
|
|
func (s *FileService) ListPersonal(ctx context.Context, ownerUserID string) ([]FileObject, error) {
|
|
rows, err := s.assets.pool.Query(ctx, fileObjectSelect+` WHERE scope='personal' AND owner_user_id=$1 ORDER BY created_at DESC`, ownerUserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []FileObject{}
|
|
for rows.Next() {
|
|
obj, err := scanFileObject(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, obj)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *FileService) ListSystem(ctx context.Context) ([]FileObject, error) {
|
|
rows, err := s.assets.pool.Query(ctx, fileObjectSelect+` WHERE scope='system' ORDER BY created_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []FileObject{}
|
|
for rows.Next() {
|
|
obj, err := scanFileObject(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, obj)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *FileService) GetPersonal(ctx context.Context, ownerUserID, id string) (FileObject, error) {
|
|
return scanFileObject(s.assets.pool.QueryRow(ctx, fileObjectSelect+` WHERE id=$1 AND scope='personal' AND owner_user_id=$2`, id, ownerUserID))
|
|
}
|
|
|
|
func (s *FileService) GetSystem(ctx context.Context, id string) (FileObject, error) {
|
|
return scanFileObject(s.assets.pool.QueryRow(ctx, fileObjectSelect+` WHERE id=$1 AND scope='system'`, id))
|
|
}
|
|
|
|
// Open streams the object body for download.
|
|
func (s *FileService) Open(ctx context.Context, obj FileObject) (io.ReadCloser, int64, error) {
|
|
return s.store.OpenObject(ctx, obj.ObjectKey)
|
|
}
|
|
|
|
func (s *FileService) DeletePersonal(ctx context.Context, ownerUserID, id string) error {
|
|
return s.deleteFile(ctx, id, `scope='personal' AND owner_user_id=$2`, ownerUserID)
|
|
}
|
|
|
|
func (s *FileService) DeleteSystem(ctx context.Context, id string) error {
|
|
return s.deleteFile(ctx, id, `scope='system'`, nil)
|
|
}
|
|
|
|
// deleteFile removes the metadata row and, best-effort, the object body. The
|
|
// object removal is best-effort because an orphan in the bucket is recoverable
|
|
// and must never block the delete that the user asked for.
|
|
func (s *FileService) deleteFile(ctx context.Context, id, where string, owner any) error {
|
|
// 只传 SQL 中真实出现的占位符参数:system 范围没有 $2,多传 nil 会让
|
|
// Postgres 报 "bind message supplies 2 parameters" 错误。
|
|
args := []any{id}
|
|
if owner != nil {
|
|
args = append(args, owner)
|
|
}
|
|
var key string
|
|
if err := s.assets.pool.QueryRow(ctx, `SELECT object_key FROM gateway.file_objects WHERE id=$1 AND `+where, args...).Scan(&key); err != nil {
|
|
return mapNotFound(err)
|
|
}
|
|
tag, err := s.assets.pool.Exec(ctx, `DELETE FROM gateway.file_objects WHERE id=$1 AND `+where, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
_ = s.store.DeleteObject(ctx, key)
|
|
return nil
|
|
}
|