feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file, render, notification, gateway (Go) services with vendored deps, plus DB migrations, event/API contracts, and an init-db script. Wire the Next.js frontend to the gateway: server-side JWT auth routes (login/register/refresh/logout/me), gateway fetch helper, and session/ cookie/jwt helpers under src/lib. Containerize the stack via docker-compose.v2.yml and per-service Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via next/font/local to avoid Google Fonts (geo-blocked). Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/flatrender/file-svc/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(connStr string) (*Store, error) {
|
||||
cfg, err := pgxpool.ParseConfig(connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db config: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 20
|
||||
cfg.MinConns = 2
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db connect: %w", err)
|
||||
}
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() { s.pool.Close() }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
// ── Folders ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetFolders(ctx context.Context, userID uuid.UUID, parentID *uuid.UUID) ([]models.UserFolder, error) {
|
||||
var rows pgx.Rows
|
||||
var err error
|
||||
if parentID == nil {
|
||||
rows, err = s.pool.Query(ctx,
|
||||
`SELECT id, tenant_id, user_id, name, folder_type, parent_folder_id,
|
||||
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at
|
||||
FROM file_mgr.user_folders
|
||||
WHERE user_id = $1 AND parent_folder_id IS NULL AND deleted_at IS NULL
|
||||
ORDER BY sort, name`, userID)
|
||||
} else {
|
||||
rows, err = s.pool.Query(ctx,
|
||||
`SELECT id, tenant_id, user_id, name, folder_type, parent_folder_id,
|
||||
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at
|
||||
FROM file_mgr.user_folders
|
||||
WHERE user_id = $1 AND parent_folder_id = $2 AND deleted_at IS NULL
|
||||
ORDER BY sort, name`, userID, parentID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var folders []models.UserFolder
|
||||
for rows.Next() {
|
||||
var f models.UserFolder
|
||||
if err := rows.Scan(&f.ID, &f.TenantID, &f.UserID, &f.Name, &f.FolderType, &f.ParentFolderID,
|
||||
&f.FileCount, &f.TotalSizeBytes, &f.Sort, &f.IsShared, &f.ShareToken, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
folders = append(folders, f)
|
||||
}
|
||||
return folders, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateFolder(ctx context.Context, tenantID, userID uuid.UUID, req models.CreateFolderRequest) (*models.UserFolder, error) {
|
||||
var f models.UserFolder
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`INSERT INTO file_mgr.user_folders (tenant_id, user_id, name, folder_type, parent_folder_id)
|
||||
VALUES ($1, $2, $3, 'User', $4)
|
||||
RETURNING id, tenant_id, user_id, name, folder_type, parent_folder_id,
|
||||
file_count, total_size_bytes, sort, is_shared, share_token, created_at, updated_at`,
|
||||
tenantID, userID, req.Name, req.ParentFolderID,
|
||||
).Scan(&f.ID, &f.TenantID, &f.UserID, &f.Name, &f.FolderType, &f.ParentFolderID,
|
||||
&f.FileCount, &f.TotalSizeBytes, &f.Sort, &f.IsShared, &f.ShareToken, &f.CreatedAt, &f.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFolder(ctx context.Context, id, userID uuid.UUID) error {
|
||||
ct, err := s.pool.Exec(ctx,
|
||||
`UPDATE file_mgr.user_folders SET deleted_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`,
|
||||
id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ListFiles(ctx context.Context, userID uuid.UUID, req models.FileListRequest) ([]models.UserFile, int64, error) {
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
|
||||
baseQ := `FROM file_mgr.user_files WHERE user_id = $1 AND deleted_at IS NULL`
|
||||
args := []any{userID}
|
||||
argN := 2
|
||||
|
||||
if req.FolderID != nil {
|
||||
baseQ += fmt.Sprintf(" AND user_folder_id = $%d", argN)
|
||||
args = append(args, req.FolderID)
|
||||
argN++
|
||||
}
|
||||
if req.FileType != nil {
|
||||
baseQ += fmt.Sprintf(" AND file_type = $%d", argN)
|
||||
args = append(args, req.FileType)
|
||||
argN++
|
||||
}
|
||||
if req.Search != nil {
|
||||
baseQ += fmt.Sprintf(" AND name ILIKE $%d", argN)
|
||||
args = append(args, "%"+*req.Search+"%")
|
||||
argN++
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) "+baseQ, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
args = append(args, req.PageSize, offset)
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
|
||||
file_type, minio_bucket, minio_key, cdn_url, file_address, size_bytes, md5_hash,
|
||||
thumbnail_url, upload_status, upload_progress, source, last_used_at, use_count,
|
||||
is_public, created_at, updated_at
|
||||
`+baseQ+fmt.Sprintf(` ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, argN, argN+1),
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []models.UserFile
|
||||
for rows.Next() {
|
||||
var f models.UserFile
|
||||
if err := rows.Scan(&f.ID, &f.TenantID, &f.UserID, &f.UserFolderID, &f.Name, &f.OriginalFilename,
|
||||
&f.FileExtension, &f.MimeType, &f.FileType, &f.MinioBucket, &f.MinioKey, &f.CdnURL,
|
||||
&f.FileAddress, &f.SizeBytes, &f.Md5Hash, &f.ThumbnailURL, &f.UploadStatus, &f.UploadProgress,
|
||||
&f.Source, &f.LastUsedAt, &f.UseCount, &f.IsPublic, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
return files, total, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetFile(ctx context.Context, id, userID uuid.UUID) (*models.UserFile, error) {
|
||||
var f models.UserFile
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
|
||||
file_type, minio_bucket, minio_key, cdn_url, file_address, size_bytes, md5_hash,
|
||||
sha256_hash, duration_sec, width, height, fps, bitrate_kbps, codec, has_audio, has_video,
|
||||
thumbnail_url, waveform_data, upload_status, upload_progress, source, export_id, parent_file_id,
|
||||
last_used_at, use_count, is_public, share_token, metadata, created_at, updated_at
|
||||
FROM file_mgr.user_files
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL`, id, userID,
|
||||
).Scan(&f.ID, &f.TenantID, &f.UserID, &f.UserFolderID, &f.Name, &f.OriginalFilename,
|
||||
&f.FileExtension, &f.MimeType, &f.FileType, &f.MinioBucket, &f.MinioKey, &f.CdnURL,
|
||||
&f.FileAddress, &f.SizeBytes, &f.Md5Hash, &f.Sha256Hash, &f.DurationSec, &f.Width, &f.Height,
|
||||
&f.Fps, &f.BitrateKbps, &f.Codec, &f.HasAudio, &f.HasVideo, &f.ThumbnailURL, &f.WaveformData,
|
||||
&f.UploadStatus, &f.UploadProgress, &f.Source, &f.ExportID, &f.ParentFileID,
|
||||
&f.LastUsedAt, &f.UseCount, &f.IsPublic, &f.ShareToken, &f.Metadata, &f.CreatedAt, &f.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateFileRecord(ctx context.Context, f *models.UserFile) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO file_mgr.user_files
|
||||
(tenant_id, user_id, user_folder_id, name, original_filename, file_extension, mime_type,
|
||||
file_type, minio_bucket, minio_key, file_address, size_bytes, upload_status, source)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'Pending',$13)
|
||||
RETURNING id, created_at, updated_at`,
|
||||
f.TenantID, f.UserID, f.UserFolderID, f.Name, f.OriginalFilename, f.FileExtension,
|
||||
f.MimeType, f.FileType, f.MinioBucket, f.MinioKey, f.FileAddress, f.SizeBytes, f.Source,
|
||||
).Scan(&f.ID, &f.CreatedAt, &f.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) MarkFileReady(ctx context.Context, id uuid.UUID, cdnURL *string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE file_mgr.user_files
|
||||
SET upload_status = 'Ready', upload_progress = 100, cdn_url = $2, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
id, cdnURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFile(ctx context.Context, id, userID uuid.UUID) (*models.UserFile, error) {
|
||||
var f models.UserFile
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`UPDATE file_mgr.user_files SET deleted_at = NOW()
|
||||
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
|
||||
RETURNING id, minio_bucket, minio_key, size_bytes, user_folder_id`,
|
||||
id, userID,
|
||||
).Scan(&f.ID, &f.MinioBucket, &f.MinioKey, &f.SizeBytes, &f.UserFolderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// ── Storage Quota ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetQuota(ctx context.Context, userID uuid.UUID) (*models.StorageQuota, error) {
|
||||
var q models.StorageQuota
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT user_id, tenant_id, plan_quota_bytes, bonus_quota_bytes, used_bytes,
|
||||
video_count, image_count, audio_count, video_bytes, image_bytes, audio_bytes,
|
||||
last_90pct_notified_at, last_100pct_notified_at, updated_at
|
||||
FROM file_mgr.storage_quotas WHERE user_id = $1`, userID,
|
||||
).Scan(&q.UserID, &q.TenantID, &q.PlanQuotaBytes, &q.BonusQuotaBytes, &q.UsedBytes,
|
||||
&q.VideoCount, &q.ImageCount, &q.AudioCount, &q.VideoBytes, &q.ImageBytes, &q.AudioBytes,
|
||||
&q.Last90PctNotifiedAt, &q.Last100PctNotifiedAt, &q.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func (s *Store) EnsureQuota(ctx context.Context, userID, tenantID uuid.UUID) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO file_mgr.storage_quotas (user_id, tenant_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO NOTHING`, userID, tenantID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AddUsedBytes(ctx context.Context, userID uuid.UUID, delta int64) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE file_mgr.storage_quotas SET used_bytes = used_bytes + $2, updated_at = NOW()
|
||||
WHERE user_id = $1`, userID, delta)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Upload Sessions ───────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) CreateUploadSession(ctx context.Context, sess *models.UploadSession) error {
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO file_mgr.upload_sessions
|
||||
(tenant_id, user_id, minio_bucket, minio_key, minio_upload_id, filename, mime_type,
|
||||
total_size_bytes, chunk_size_bytes, target_folder_id, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'Uploading')
|
||||
RETURNING id, expires_at, created_at, updated_at`,
|
||||
sess.TenantID, sess.UserID, sess.MinioBucket, sess.MinioKey, sess.MinioUploadID,
|
||||
sess.Filename, sess.MimeType, sess.TotalSizeBytes, sess.ChunkSizeBytes, sess.TargetFolderID,
|
||||
).Scan(&sess.ID, &sess.ExpiresAt, &sess.CreatedAt, &sess.UpdatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) GetUploadSession(ctx context.Context, id uuid.UUID) (*models.UploadSession, error) {
|
||||
var sess models.UploadSession
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, tenant_id, user_id, minio_bucket, minio_key, minio_upload_id, filename, mime_type,
|
||||
total_size_bytes, chunks_received, bytes_received, chunk_size_bytes,
|
||||
target_folder_id, target_file_id, status, error_message, expires_at, completed_at, created_at, updated_at
|
||||
FROM file_mgr.upload_sessions WHERE id = $1`, id,
|
||||
).Scan(&sess.ID, &sess.TenantID, &sess.UserID, &sess.MinioBucket, &sess.MinioKey, &sess.MinioUploadID,
|
||||
&sess.Filename, &sess.MimeType, &sess.TotalSizeBytes, &sess.ChunksReceived, &sess.BytesReceived,
|
||||
&sess.ChunkSizeBytes, &sess.TargetFolderID, &sess.TargetFileID, &sess.Status, &sess.ErrorMessage,
|
||||
&sess.ExpiresAt, &sess.CompletedAt, &sess.CreatedAt, &sess.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *Store) CompleteUploadSession(ctx context.Context, id, fileID uuid.UUID) error {
|
||||
now := time.Now()
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE file_mgr.upload_sessions
|
||||
SET status = 'Ready', target_file_id = $2, completed_at = $3, updated_at = $3
|
||||
WHERE id = $1`, id, fileID, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── MinIO Buckets ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) GetBucketByPurpose(ctx context.Context, purpose string) (*models.MinioBucket, error) {
|
||||
var b models.MinioBucket
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT id, name, region, endpoint, purpose, is_public, cdn_base_url, is_active, created_at
|
||||
FROM file_mgr.minio_buckets WHERE purpose = $1 AND is_active = TRUE LIMIT 1`, purpose,
|
||||
).Scan(&b.ID, &b.Name, &b.Region, &b.Endpoint, &b.Purpose, &b.IsPublic, &b.CdnBaseURL, &b.IsActive, &b.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TotalPages(total int64, pageSize int) int {
|
||||
if pageSize == 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Ceil(float64(total) / float64(pageSize)))
|
||||
}
|
||||
Reference in New Issue
Block a user