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 }