Files
MeBox/internal/repository/bot_repo.go
T
2026-06-26 08:42:16 +08:00

220 lines
8.8 KiB
Go

package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// ─── RegistrationCode ─────────────────────────────────────────────────────────
// RegistrationCodeRepository persists model.RegistrationCode records.
type RegistrationCodeRepository struct{ db *gorm.DB }
// Create inserts a new code.
func (r *RegistrationCodeRepository) Create(ctx context.Context, c *model.RegistrationCode) error {
return r.db.WithContext(ctx).Create(c).Error
}
// FindByCode returns the code row, or (nil, nil) when absent.
func (r *RegistrationCodeRepository) FindByCode(ctx context.Context, code string) (*model.RegistrationCode, error) {
var c model.RegistrationCode
err := r.db.WithContext(ctx).Where("code = ?", code).First(&c).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// MarkUsed atomically consumes one use of a redeemable code. It returns
// gorm.ErrRecordNotFound when the code is exhausted so callers can avoid
// double-spend races.
func (r *RegistrationCodeRepository) MarkUsed(ctx context.Context, id, userID string) error {
now := time.Now()
res := r.db.WithContext(ctx).Model(&model.RegistrationCode{}).
Where("id = ? AND used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END", id).
Updates(map[string]any{
"used_by_user_id": userID,
"used_count": gorm.Expr("used_count + 1"),
"used_at": gorm.Expr("CASE WHEN used_count + 1 >= CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END THEN ? ELSE used_at END", now),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// List returns the most recent codes (newest first).
func (r *RegistrationCodeRepository) List(ctx context.Context, limit int) ([]model.RegistrationCode, error) {
if limit <= 0 {
limit = 50
}
var rows []model.RegistrationCode
err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
// CountUnused returns the number of codes that are still redeemable.
func (r *RegistrationCodeRepository) CountUnused(ctx context.Context) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.RegistrationCode{}).
Where("used_at IS NULL AND used_count < CASE WHEN max_uses > 0 THEN max_uses ELSE 1 END").Count(&n).Error
return n, err
}
// ─── SignIn ───────────────────────────────────────────────────────────────────
// SignInRepository persists model.SignIn records.
type SignInRepository struct{ db *gorm.DB }
// Get returns the sign-in row for a user, or (nil, nil) when absent.
func (r *SignInRepository) Get(ctx context.Context, userID string) (*model.SignIn, error) {
var s model.SignIn
err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&s).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
// Save inserts or updates a sign-in row.
func (r *SignInRepository) Save(ctx context.Context, s *model.SignIn) error {
return r.db.WithContext(ctx).Save(s).Error
}
// ─── UserDevice ───────────────────────────────────────────────────────────────
// UserDeviceRepository persists model.UserDevice records.
type UserDeviceRepository struct{ db *gorm.DB }
// Find returns the device row for (user, device), or (nil, nil) when absent.
func (r *UserDeviceRepository) Find(ctx context.Context, userID, deviceID string) (*model.UserDevice, error) {
var d model.UserDevice
err := r.db.WithContext(ctx).
Where("user_id = ? AND device_id = ?", userID, deviceID).First(&d).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &d, nil
}
// FindByFingerprint returns the newest row for a terminal fingerprint.
func (r *UserDeviceRepository) FindByFingerprint(ctx context.Context, userID, fingerprint string) (*model.UserDevice, error) {
var d model.UserDevice
err := r.db.WithContext(ctx).
Where("user_id = ? AND fingerprint = ?", userID, fingerprint).
Order("last_seen_at desc").
First(&d).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &d, nil
}
// Create inserts a new device row.
func (r *UserDeviceRepository) Create(ctx context.Context, d *model.UserDevice) error {
return r.db.WithContext(ctx).Create(d).Error
}
// Save persists changes to an existing device row.
func (r *UserDeviceRepository) Save(ctx context.Context, d *model.UserDevice) error {
return r.db.WithContext(ctx).Save(d).Error
}
// ListByUser returns all device rows for a user, newest activity first.
func (r *UserDeviceRepository) ListByUser(ctx context.Context, userID string) ([]model.UserDevice, error) {
var rows []model.UserDevice
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order("last_seen_at desc").Find(&rows).Error
return rows, err
}
// CountActiveClients counts distinct terminal devices for a user that were
// seen on or after `since`. Multiple apps on the same terminal share the same
// fingerprint and count as one terminal; rows remain as login channels.
func (r *UserDeviceRepository) CountActiveClients(ctx context.Context, userID string, since time.Time) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.UserDevice{}).
Where("user_id = ? AND last_seen_at >= ? AND kicked = ?", userID, since, false).
Select("COUNT(DISTINCT COALESCE(NULLIF(fingerprint, ''), device_id))").Scan(&n).Error
return n, err
}
// CountConcurrentPlaying counts terminal devices for a user whose last playback
// ping was on or after `since` (used for the max concurrent playback rule).
func (r *UserDeviceRepository) CountConcurrentPlaying(ctx context.Context, userID string, since time.Time) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.UserDevice{}).
Where("user_id = ? AND last_play_at IS NOT NULL AND last_play_at >= ?", userID, since).
Select("COUNT(DISTINCT COALESCE(NULLIF(fingerprint, ''), device_id))").Scan(&n).Error
return n, err
}
// Delete removes a single device row by primary key.
func (r *UserDeviceRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Unscoped().Delete(&model.UserDevice{}, "id = ?", id).Error
}
// DeleteByUser removes every device row for a user (used on account deletion).
func (r *UserDeviceRepository) DeleteByUser(ctx context.Context, userID string) error {
return r.db.WithContext(ctx).Unscoped().Where("user_id = ?", userID).Delete(&model.UserDevice{}).Error
}
// DeleteByFingerprintExcept removes stale login-channel rows for the same
// terminal, leaving the selected canonical row.
func (r *UserDeviceRepository) DeleteByFingerprintExcept(ctx context.Context, userID, fingerprint, keepID string) error {
return r.db.WithContext(ctx).Unscoped().
Where("user_id = ? AND fingerprint = ? AND id <> ?", userID, fingerprint, keepID).
Delete(&model.UserDevice{}).Error
}
// SetKicked marks a device as kicked (forces re-login on next request).
func (r *UserDeviceRepository) SetKicked(ctx context.Context, id string, kicked bool) error {
return r.db.WithContext(ctx).Model(&model.UserDevice{}).Where("id = ?", id).
Update("kicked", kicked).Error
}
// SetKickedByFingerprint marks every row for a terminal fingerprint.
func (r *UserDeviceRepository) SetKickedByFingerprint(ctx context.Context, userID, fingerprint string, kicked bool) error {
return r.db.WithContext(ctx).Model(&model.UserDevice{}).
Where("user_id = ? AND fingerprint = ?", userID, fingerprint).
Update("kicked", kicked).Error
}
// SetKickedByUser marks every device for a user as kicked/un-kicked.
func (r *UserDeviceRepository) SetKickedByUser(ctx context.Context, userID string, kicked bool) error {
return r.db.WithContext(ctx).Model(&model.UserDevice{}).Where("user_id = ?", userID).
Update("kicked", kicked).Error
}
// WatchedMillisSince approximates the total watched milliseconds for a user
// since `since`, using the last known playback position per media. Playback
// history keeps one row per (user, media), so this is an activity proxy rather
// than an exact watch-time integral — sufficient for the inactivity rule.
func (r *UserDeviceRepository) WatchedMillisSince(ctx context.Context, userID string, since time.Time) (int64, error) {
var total int64
err := r.db.WithContext(ctx).Model(&model.PlaybackHistory{}).
Where("user_id = ? AND watched_at >= ?", userID, since).
Select("COALESCE(SUM(position_ms), 0)").Scan(&total).Error
return total, err
}