You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
149 lines
3.2 KiB
149 lines
3.2 KiB
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// URLRecord represents a stored link.
|
|
type URLRecord struct {
|
|
Code string `json:"code"`
|
|
OriginalURL string `json:"original_url"`
|
|
VisitCount int64 `json:"visit_count"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Store is a simple JSON-file-backed key-value store.
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
file string
|
|
links map[string]*URLRecord // code -> record
|
|
}
|
|
|
|
// NewStore opens (or creates) the JSON-backed store.
|
|
func NewStore(dbPath string) (*Store, error) {
|
|
s := &Store{
|
|
file: dbPath,
|
|
links: make(map[string]*URLRecord),
|
|
}
|
|
|
|
data, err := os.ReadFile(dbPath)
|
|
if err == nil {
|
|
var records []*URLRecord
|
|
if err := json.Unmarshal(data, &records); err == nil {
|
|
for _, rec := range records {
|
|
s.links[rec.Code] = rec
|
|
}
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("open store: %w", err)
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Create inserts a new URL with the given code. Returns an error if the code
|
|
// already exists.
|
|
func (s *Store) Create(code, originalURL string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, exists := s.links[code]; exists {
|
|
return fmt.Errorf("code %q already exists", code)
|
|
}
|
|
|
|
s.links[code] = &URLRecord{
|
|
Code: code,
|
|
OriginalURL: originalURL,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
return s.persist()
|
|
}
|
|
|
|
// FindByCode looks up a URL record by its short code.
|
|
func (s *Store) FindByCode(code string) (*URLRecord, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
rec, ok := s.links[code]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// IncrementVisit atomically bumps the visit counter for the given code.
|
|
func (s *Store) IncrementVisit(code string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
rec, ok := s.links[code]
|
|
if !ok {
|
|
return fmt.Errorf("code %q not found", code)
|
|
}
|
|
|
|
atomic.AddInt64(&rec.VisitCount, 1)
|
|
return s.persist()
|
|
}
|
|
|
|
// Close is a no-op for the JSON store.
|
|
func (s *Store) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// ListAll returns all records sorted by CreatedAt descending (newest first).
|
|
func (s *Store) ListAll() ([]*URLRecord, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
records := make([]*URLRecord, 0, len(s.links))
|
|
for _, rec := range s.links {
|
|
records = append(records, rec)
|
|
}
|
|
sort.Slice(records, func(i, j int) bool {
|
|
return records[i].CreatedAt.After(records[j].CreatedAt)
|
|
})
|
|
return records, nil
|
|
}
|
|
|
|
// Update changes the OriginalURL of the record identified by code.
|
|
func (s *Store) Update(code, newURL string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
rec, ok := s.links[code]
|
|
if !ok {
|
|
return fmt.Errorf("code %q not found", code)
|
|
}
|
|
rec.OriginalURL = newURL
|
|
return s.persist()
|
|
}
|
|
|
|
// Delete removes the record identified by code.
|
|
func (s *Store) Delete(code string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, ok := s.links[code]; !ok {
|
|
return fmt.Errorf("code %q not found", code)
|
|
}
|
|
delete(s.links, code)
|
|
return s.persist()
|
|
}
|
|
|
|
func (s *Store) persist() error {
|
|
records := make([]*URLRecord, 0, len(s.links))
|
|
for _, rec := range s.links {
|
|
records = append(records, rec)
|
|
}
|
|
data, err := json.MarshalIndent(records, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(s.file, data, 0644)
|
|
}
|
|
|