chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:19:22 +08:00
commit 6366859bb3
222 changed files with 47313 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
package storage
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
type ObjectMeta struct {
Key string `json:"key"`
URL string `json:"url"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
type Store interface {
Put(ctx context.Context, tenantID int64, filename, contentType string, r io.Reader, size int64) (*ObjectMeta, error)
Open(ctx context.Context, key string) (io.ReadCloser, *ObjectMeta, error)
}
type LocalStore struct {
Root string
PublicBase string // e.g. http://127.0.0.1:8180/api/v1/storage
}
func NewLocalStore(root, publicBase string) (*LocalStore, error) {
if root == "" {
root = "./data/uploads"
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, err
}
return &LocalStore{Root: root, PublicBase: strings.TrimRight(publicBase, "/")}, nil
}
func (s *LocalStore) Put(_ context.Context, tenantID int64, filename, contentType string, r io.Reader, size int64) (*ObjectMeta, error) {
ext := filepath.Ext(filename)
if ext == "" {
ext = guessExt(contentType)
}
id := make([]byte, 8)
_, _ = rand.Read(id)
key := fmt.Sprintf("t%d/%s/%s%s", tenantID, time.Now().UTC().Format("20060102"), hex.EncodeToString(id), ext)
full := filepath.Join(s.Root, filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return nil, err
}
f, err := os.Create(full)
if err != nil {
return nil, err
}
defer f.Close()
n, err := io.Copy(f, r)
if err != nil {
return nil, err
}
if size <= 0 {
size = n
}
meta := &ObjectMeta{
Key: key, Filename: filename, ContentType: contentType, Size: size, CreatedAt: time.Now().UTC(),
URL: s.PublicBase + "/" + key,
}
// sidecar meta
_ = os.WriteFile(full+".meta", []byte(fmt.Sprintf("%s\n%s\n%d\n%s", filename, contentType, size, meta.CreatedAt.Format(time.RFC3339))), 0o644)
return meta, nil
}
func (s *LocalStore) Open(_ context.Context, key string) (io.ReadCloser, *ObjectMeta, error) {
key = strings.TrimPrefix(key, "/")
if strings.Contains(key, "..") {
return nil, nil, fmt.Errorf("invalid key")
}
full := filepath.Join(s.Root, filepath.FromSlash(key))
f, err := os.Open(full)
if err != nil {
return nil, nil, err
}
st, _ := f.Stat()
meta := &ObjectMeta{Key: key, Size: st.Size(), URL: s.PublicBase + "/" + key, CreatedAt: st.ModTime().UTC()}
if b, err := os.ReadFile(full + ".meta"); err == nil {
parts := strings.Split(string(b), "\n")
if len(parts) >= 2 {
meta.Filename = parts[0]
meta.ContentType = parts[1]
}
}
return f, meta, nil
}
func guessExt(ct string) string {
switch ct {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
return ".xlsx"
case "text/csv":
return ".csv"
default:
return ".bin"
}
}