refactor: modularize chat backend, update docs for .m4s segments
This commit is contained in:
1 parent
46e878bef6
commit
b3a10ba3ef
24 files changed
+3771
-2781
No files matched your search
@@ -0,0 +1,82 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DBClient wraps gorm.DB for testability.
|
||||
type DBClient struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
// InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006).
|
||||
// Returns the client and the driver name.
|
||||
func InitDB(databaseURL string) (*DBClient, string, error) {
|
||||
var (
|
||||
gormDB *gorm.DB
|
||||
err error
|
||||
driver = "sqlite"
|
||||
)
|
||||
|
||||
switch {
|
||||
case databaseURL == "":
|
||||
gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
|
||||
case strings.HasPrefix(databaseURL, "sqlite://"):
|
||||
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
|
||||
if sqlitePath == "" {
|
||||
sqlitePath = "chat.db"
|
||||
}
|
||||
gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
|
||||
driver = "postgres"
|
||||
gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
default:
|
||||
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, driver, err
|
||||
}
|
||||
|
||||
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
||||
|
||||
return &DBClient{DB: gormDB}, driver, nil
|
||||
}
|
||||
|
||||
// RedisClient wraps the Redis client for testability.
|
||||
// Interface defined for mocking in tests.
|
||||
type RedisClient interface {
|
||||
Ping(ctx context.Context) error
|
||||
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Del(ctx context.Context, keys ...string) error
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
Decr(ctx context.Context, key string) (int64, error)
|
||||
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||
Exists(ctx context.Context, keys ...string) (int64, error)
|
||||
Publish(ctx context.Context, channel string, message interface{}) error
|
||||
PSubscribe(ctx context.Context, channels ...string) PubSub
|
||||
Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error)
|
||||
Info(ctx context.Context, sections ...string) (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// PubSub wraps Redis pub/sub subscription.
|
||||
type PubSub interface {
|
||||
ReceiveMessage(ctx context.Context) (*PubSubMessage, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// PubSubMessage represents a received pub/sub message.
|
||||
type PubSubMessage struct {
|
||||
Channel string
|
||||
Payload string
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
||||
type Message struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Room string `json:"room"`
|
||||
Username string `json:"username"`
|
||||
Text string `json:"text"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Type string `json:"type"` // "public", "private", "system", "moderation"
|
||||
}
|
||||
|
||||
// ModerationLog for audit trail (immutable per ADR-005)
|
||||
type ModerationLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Action string `json:"action"` // "mute", "ban", "delete", "timeout"
|
||||
Target string `json:"target"`
|
||||
Moderator string `json:"moderator"`
|
||||
Reason string `json:"reason"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
|
||||
type Media struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Filename string `gorm:"index" json:"filename"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Status string `gorm:"index" json:"status"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||
type ScheduleOverride struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
MediaID uint `json:"mediaId"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Presence tracks online users per room (using Redis per ADR-007)
|
||||
type Presence struct {
|
||||
Username string `json:"username"`
|
||||
Room string `json:"room"`
|
||||
Online bool `json:"online"`
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisWrapper wraps go-redis client to implement RedisClient interface.
|
||||
type RedisWrapper struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisWrapper creates a RedisWrapper from a go-redis client.
|
||||
func NewRedisWrapper(client *redis.Client) *RedisWrapper {
|
||||
return &RedisWrapper{client: client}
|
||||
}
|
||||
|
||||
// InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007).
|
||||
// Returns nil if Redis is unavailable (non-fatal).
|
||||
func InitRedis(redisAddress string) *RedisWrapper {
|
||||
var options *redis.Options
|
||||
var err error
|
||||
|
||||
if strings.Contains(redisAddress, "://") {
|
||||
options, err = redis.ParseURL(redisAddress)
|
||||
if err != nil {
|
||||
log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
options = &redis.Options{Addr: redisAddress}
|
||||
}
|
||||
|
||||
client := redis.NewClient(options)
|
||||
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
||||
client.Close()
|
||||
// Start auto-reconnect goroutine
|
||||
go redisReconnectLoop(redisAddress, options)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
|
||||
return &RedisWrapper{client: client}
|
||||
}
|
||||
|
||||
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
|
||||
func redisReconnectLoop(addr string, baseOptions *redis.Options) {
|
||||
backoff := 2 * time.Second
|
||||
maxBackoff := 60 * time.Second
|
||||
for {
|
||||
time.Sleep(backoff)
|
||||
testClient := redis.NewClient(baseOptions)
|
||||
if err := testClient.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
testClient.Close()
|
||||
continue
|
||||
}
|
||||
// Reconnected — caller must replace their reference.
|
||||
// This is a best-effort background reconnect; the wrapper
|
||||
// returned by InitRedis remains nil until a new InitRedis call.
|
||||
log.Println("Redis reconnected successfully (background)")
|
||||
testClient.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ping checks Redis connectivity.
|
||||
func (r *RedisWrapper) Ping(ctx context.Context) error {
|
||||
if r == nil || r.client == nil {
|
||||
return redis.ErrClosed
|
||||
}
|
||||
return r.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// Set stores a key with TTL.
|
||||
func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
return r.client.Set(ctx, key, value, expiration).Err()
|
||||
}
|
||||
|
||||
// Get retrieves a key.
|
||||
func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) {
|
||||
return r.client.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Del removes keys.
|
||||
func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error {
|
||||
return r.client.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
// Incr increments a key.
|
||||
func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) {
|
||||
return r.client.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Decr decrements a key.
|
||||
func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) {
|
||||
return r.client.Decr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Expire sets TTL on a key.
|
||||
func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return r.client.Expire(ctx, key, expiration).Err()
|
||||
}
|
||||
|
||||
// Exists checks if keys exist.
|
||||
func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||
return r.client.Exists(ctx, keys...).Result()
|
||||
}
|
||||
|
||||
// Publish publishes a message to a channel.
|
||||
func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
return r.client.Publish(ctx, channel, message).Err()
|
||||
}
|
||||
|
||||
// PSubscribe subscribes to channels by pattern.
|
||||
func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub {
|
||||
ps := r.client.PSubscribe(ctx, channels...)
|
||||
return &redisPubSubWrapper{ps: ps}
|
||||
}
|
||||
|
||||
// Scan scans keys matching a pattern.
|
||||
func (r *RedisWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
return r.client.Scan(ctx, cursor, match, count).Result()
|
||||
}
|
||||
|
||||
// Info returns Redis server info.
|
||||
func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) {
|
||||
return r.client.Info(ctx, sections...).Result()
|
||||
}
|
||||
|
||||
// Close closes the Redis connection.
|
||||
func (r *RedisWrapper) Close() error {
|
||||
if r == nil || r.client == nil {
|
||||
return nil
|
||||
}
|
||||
return r.client.Close()
|
||||
}
|
||||
|
||||
// redisPubSubWrapper wraps go-redis PubSub.
|
||||
type redisPubSubWrapper struct {
|
||||
ps *redis.PubSub
|
||||
}
|
||||
|
||||
func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) {
|
||||
msg, err := w.ps.ReceiveMessage(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil
|
||||
}
|
||||
|
||||
func (w *redisPubSubWrapper) Close() error {
|
||||
return w.ps.Close()
|
||||
}
|
||||
Reference in new issue
Block a user