mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-19 07:28:46 +07:00
feat: first commit
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/port"
|
||||
)
|
||||
|
||||
type cacheEntry struct {
|
||||
res port.Resolution
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// Cache is a TTL map standing in for Redis.
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]cacheEntry
|
||||
}
|
||||
|
||||
func NewCache() *Cache {
|
||||
return &Cache{m: make(map[string]cacheEntry)}
|
||||
}
|
||||
|
||||
func (c *Cache) GetResolution(_ context.Context, code string) (port.Resolution, bool, error) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.m[code]
|
||||
c.mu.RUnlock()
|
||||
if !ok || time.Now().After(e.expires) {
|
||||
return port.Resolution{}, false, nil
|
||||
}
|
||||
return e.res, true, nil
|
||||
}
|
||||
|
||||
func (c *Cache) SetResolution(_ context.Context, code string, r port.Resolution, ttl time.Duration) error {
|
||||
c.mu.Lock()
|
||||
c.m[code] = cacheEntry{res: r, expires: time.Now().Add(ttl)}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) Invalidate(_ context.Context, code string) error {
|
||||
c.mu.Lock()
|
||||
delete(c.m, code)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Package memory provides in-memory adapters used by tests and for running the
|
||||
// stack without Postgres/Redis (STORE=memory).
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
type LinkRepo struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]*domain.Link
|
||||
seq int64
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
func NewLinkRepo() *LinkRepo {
|
||||
return &LinkRepo{byID: make(map[string]*domain.Link), clock: time.Now}
|
||||
}
|
||||
|
||||
func clone(l *domain.Link) *domain.Link {
|
||||
cp := *l
|
||||
cp.Last7Days = append([]domain.DayCount(nil), l.Last7Days...)
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (r *LinkRepo) Create(_ context.Context, l *domain.Link) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, e := range r.byID {
|
||||
if e.Code == l.Code {
|
||||
return domain.ErrCodeTaken
|
||||
}
|
||||
}
|
||||
r.byID[l.ID] = clone(l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) GetByID(_ context.Context, id string) (*domain.Link, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
l, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return clone(l), nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) GetByCode(_ context.Context, code string) (*domain.Link, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, l := range r.byID {
|
||||
if l.Code == code {
|
||||
return clone(l), nil
|
||||
}
|
||||
}
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *LinkRepo) ListByOwner(_ context.Context, ownerID, query string, limit, offset int) ([]domain.Link, int, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
q := strings.ToLower(query)
|
||||
var matched []domain.Link
|
||||
for _, l := range r.byID {
|
||||
if l.OwnerID != ownerID {
|
||||
continue
|
||||
}
|
||||
if q != "" && !strings.Contains(strings.ToLower(l.Code), q) && !strings.Contains(strings.ToLower(l.LongURL), q) {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, *clone(l))
|
||||
}
|
||||
sort.Slice(matched, func(i, j int) bool { return matched[i].CreatedAt.After(matched[j].CreatedAt) })
|
||||
|
||||
total := len(matched)
|
||||
if offset > total {
|
||||
offset = total
|
||||
}
|
||||
end := offset + limit
|
||||
if limit <= 0 || end > total {
|
||||
end = total
|
||||
}
|
||||
return matched[offset:end], total, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) StatsByOwner(_ context.Context, ownerID string) (domain.OwnerStats, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var s domain.OwnerStats
|
||||
for _, l := range r.byID {
|
||||
if l.OwnerID != ownerID {
|
||||
continue
|
||||
}
|
||||
s.TotalLinks++
|
||||
s.TotalClicks += l.TotalClicks
|
||||
for _, d := range l.Last7Days {
|
||||
s.WeekClicks += d.Count
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) Update(_ context.Context, l *domain.Link) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.byID[l.ID]; !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
r.byID[l.ID] = clone(l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) Delete(_ context.Context, id, ownerID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
l, ok := r.byID[id]
|
||||
if !ok || l.OwnerID != ownerID {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
delete(r.byID, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) ExistsCode(_ context.Context, code string) (bool, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, l := range r.byID {
|
||||
if l.Code == code {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) NextSequence(_ context.Context) (int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.seq++
|
||||
return r.seq, nil
|
||||
}
|
||||
|
||||
func (r *LinkRepo) RecordClicks(_ context.Context, code string, day time.Time, n int64) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
key := day.Format("2006-01-02")
|
||||
for _, l := range r.byID {
|
||||
if l.Code != code {
|
||||
continue
|
||||
}
|
||||
l.TotalClicks += n
|
||||
found := false
|
||||
for i := range l.Last7Days {
|
||||
if l.Last7Days[i].Date == key {
|
||||
l.Last7Days[i].Count += n
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
l.Last7Days = append(l.Last7Days, domain.DayCount{Date: key, Count: n})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/snip/backend/internal/domain"
|
||||
)
|
||||
|
||||
type UserRepo struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]*domain.User
|
||||
byKey map[string]string // provider|subject -> id
|
||||
}
|
||||
|
||||
func NewUserRepo() *UserRepo {
|
||||
return &UserRepo{byID: make(map[string]*domain.User), byKey: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (r *UserRepo) Upsert(_ context.Context, u *domain.User) (*domain.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
key := u.Provider + "|" + u.Subject
|
||||
if id, ok := r.byKey[key]; ok {
|
||||
existing := r.byID[id]
|
||||
existing.Email = u.Email
|
||||
existing.Name = u.Name
|
||||
cp := *existing
|
||||
return &cp, nil
|
||||
}
|
||||
id := randomID()
|
||||
u.ID = id
|
||||
r.byID[id] = u
|
||||
r.byKey[key] = id
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByID(_ context.Context, id string) (*domain.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
u, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
const hex = "0123456789abcdef"
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
out := make([]byte, 16)
|
||||
for i := range out {
|
||||
out[i] = hex[int(b[i])%16]
|
||||
}
|
||||
return "u_" + string(out)
|
||||
}
|
||||
Reference in New Issue
Block a user