Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
2.3 KiB
Go
104 lines
2.3 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
type SessionPayload struct {
|
|
UserID string `json:"user_id"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
}
|
|
|
|
type SessionService struct {
|
|
client *redis.Client
|
|
prefix string
|
|
ttl time.Duration
|
|
slideAfter time.Duration
|
|
slideTo time.Duration
|
|
}
|
|
|
|
func NewSessionService(client *redis.Client, ttl, slideAfter time.Duration) *SessionService {
|
|
return &SessionService{
|
|
client: client,
|
|
prefix: "session:",
|
|
ttl: ttl,
|
|
slideAfter: slideAfter,
|
|
slideTo: ttl,
|
|
}
|
|
}
|
|
|
|
func (s *SessionService) Create(ctx context.Context, userID string) (string, *SessionPayload, error) {
|
|
token := randomUpper(48)
|
|
|
|
payload := &SessionPayload{
|
|
UserID: userID,
|
|
ExpiresAt: time.Now().Add(s.ttl).Unix(),
|
|
}
|
|
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
if err := s.client.Set(ctx, s.key(token), raw, s.ttl).Err(); err != nil {
|
|
return "", nil, err
|
|
}
|
|
return token, payload, nil
|
|
}
|
|
|
|
func (s *SessionService) Validate(ctx context.Context, token string) (*SessionPayload, error) {
|
|
if token == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
raw, err := s.client.Get(ctx, s.key(token)).Bytes()
|
|
if err != nil {
|
|
if errors.Is(err, redis.Nil) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var payload SessionPayload
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ttl, err := s.client.TTL(ctx, s.key(token)).Result()
|
|
if err == nil && ttl > 0 && ttl < s.slideAfter {
|
|
// Slide the expiry, but only update the in-memory payload after Redis
|
|
// has actually persisted it — otherwise a failed Set would leave the
|
|
// returned ExpiresAt out of sync with what's stored.
|
|
renewed := payload
|
|
renewed.ExpiresAt = time.Now().Add(s.slideTo).Unix()
|
|
if updated, marshalErr := json.Marshal(&renewed); marshalErr == nil {
|
|
if setErr := s.client.Set(ctx, s.key(token), updated, s.slideTo).Err(); setErr == nil {
|
|
payload.ExpiresAt = renewed.ExpiresAt
|
|
}
|
|
}
|
|
}
|
|
|
|
if payload.ExpiresAt <= time.Now().Unix() {
|
|
_ = s.Destroy(ctx, token)
|
|
return nil, nil
|
|
}
|
|
|
|
return &payload, nil
|
|
}
|
|
|
|
func (s *SessionService) Destroy(ctx context.Context, token string) error {
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
return s.client.Del(ctx, s.key(token)).Err()
|
|
}
|
|
|
|
func (s *SessionService) key(token string) string {
|
|
return s.prefix + token
|
|
}
|