theorose49 f83724b995
All checks were successful
build-and-push / build (push) Successful in 39s
feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
- config/db/storage/auth/router/perms: eQMS 규약 미러링, 권한 2-tier
  (관리자 전체 / 구성원 본인·신청만), oauth2-proxy 헤더 인증 + DEV_AUTH mock
- 모델: 구성원/부서, 근무(출퇴근·휴가·공가·초과), 프로젝트(회사/제품/버전·
  작업자portion·담당자·태스크·계약·첨부·분할입금), 인센티브(설정·단계·
  유저배분·분기정산), 회계(거래·세금)
- internal/worktime: 근로기준법 월 집계 엔진
- internal/incentive: BE/non-BE × 계약금/중도금/잔금 3단계 계산 + 시뮬레이션
- 시드 데이터, Go 멀티스테이지 Dockerfile
- ADMIN_GROUPS 기본값 'admin' (전 내부 앱 공통 그룹)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:57:35 +09:00

198 lines
5.5 KiB
Go

package storage
import (
"context"
"io"
"time"
"spin/internal/config"
awscfg "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
)
// ObjectInfo describes a single S3 object as listed by the file-admin tool.
type ObjectInfo struct {
Key string `json:"key"`
Size int64 `json:"size"`
LastModified time.Time `json:"lastModified"`
}
// Storage wraps two S3 clients: one for in-cluster operations (S3_ENDPOINT) and
// one used only to mint presigned URLs that the browser can reach
// (S3_PUBLIC_ENDPOINT). Mirrors the sister eQMS service.
type Storage struct {
bucket string
prefix string // S3_PREFIX — app namespace within a shared bucket
client *s3.Client
presign *s3.PresignClient
}
// k applies the configured key prefix. DB stores logical keys; the physical
// object location is prefix + key (e.g. "spin-backend/contracts/...").
func (s *Storage) k(key string) string { return s.prefix + key }
// New constructs the storage clients. MinIO requires path-style addressing.
func New(ctx context.Context, cfg config.Config) (*Storage, error) {
creds := credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, "")
base, err := awscfg.LoadDefaultConfig(ctx,
awscfg.WithRegion(cfg.S3Region),
awscfg.WithCredentialsProvider(creds),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(base, func(o *s3.Options) {
o.BaseEndpoint = strPtr(cfg.S3Endpoint)
o.UsePathStyle = true
})
// Separate client whose endpoint is the browser-reachable host.
publicClient := s3.NewFromConfig(base, func(o *s3.Options) {
o.BaseEndpoint = strPtr(cfg.S3PublicEndpoint)
o.UsePathStyle = true
})
return &Storage{
bucket: cfg.S3Bucket,
prefix: cfg.S3Prefix,
client: client,
presign: s3.NewPresignClient(publicClient),
}, nil
}
func strPtr(s string) *string { return &s }
// EnsureBucket creates the bucket if it does not exist. Best-effort.
func (s *Storage) EnsureBucket(ctx context.Context) error {
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: &s.bucket})
if err == nil {
return nil
}
_, err = s.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &s.bucket})
return err
}
// Upload stores an object.
func (s *Storage) Upload(ctx context.Context, key, contentType string, body io.Reader, size int64) error {
full := s.k(key)
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Key: &full,
Body: body,
ContentType: &contentType,
ContentLength: &size,
})
return err
}
// Get streams an object's bytes from S3. The caller must close the returned
// reader. The second return value is the content length (-1 if unknown).
func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, int64, error) {
full := s.k(key)
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: &s.bucket,
Key: &full,
})
if err != nil {
return nil, 0, err
}
var size int64 = -1
if out.ContentLength != nil {
size = *out.ContentLength
}
return out.Body, size, nil
}
// Delete removes a single logical object (prefix applied).
func (s *Storage) Delete(ctx context.Context, key string) error {
full := s.k(key)
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &s.bucket,
Key: &full,
})
return err
}
// Prefix returns the configured S3 key prefix (app namespace). Used by the
// file-admin tool as the default listing prefix.
func (s *Storage) Prefix() string { return s.prefix }
// List enumerates RAW S3 keys (no app-prefix rewrite) under the given prefix,
// following pagination. The admin file tool operates on physical keys directly.
func (s *Storage) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
var out []ObjectInfo
var token *string
for {
page, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: &s.bucket,
Prefix: &prefix,
ContinuationToken: token,
})
if err != nil {
return nil, err
}
for _, obj := range page.Contents {
info := ObjectInfo{}
if obj.Key != nil {
info.Key = *obj.Key
}
if obj.Size != nil {
info.Size = *obj.Size
}
if obj.LastModified != nil {
info.LastModified = *obj.LastModified
}
out = append(out, info)
}
if page.IsTruncated == nil || !*page.IsTruncated {
break
}
token = page.NextContinuationToken
}
return out, nil
}
// DeleteKeys bulk-deletes RAW S3 keys (no app-prefix rewrite) and returns the
// number of objects deleted. Batches in groups of 1000 (S3 DeleteObjects limit).
func (s *Storage) DeleteKeys(ctx context.Context, keys []string) (int, error) {
deleted := 0
for i := 0; i < len(keys); i += 1000 {
end := i + 1000
if end > len(keys) {
end = len(keys)
}
ids := make([]s3types.ObjectIdentifier, 0, end-i)
for _, k := range keys[i:end] {
kk := k
ids = append(ids, s3types.ObjectIdentifier{Key: &kk})
}
out, err := s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: &s.bucket,
Delete: &s3types.Delete{Objects: ids},
})
if err != nil {
return deleted, err
}
deleted += len(out.Deleted)
}
return deleted, nil
}
// PresignGet returns a browser-usable presigned GET URL valid for 10 minutes.
func (s *Storage) PresignGet(ctx context.Context, key string) (string, error) {
full := s.k(key)
out, err := s.presign.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: &s.bucket,
Key: &full,
}, s3.WithPresignExpires(10*time.Minute))
if err != nil {
return "", err
}
return out.URL, nil
}