Files
MeBox/internal/service/play_profile.go
T
truewhile 28485ed429 优化
2026-09-23 17:01:08 +08:00

231 lines
6.8 KiB
Go

// Package service — multi-persona play profiles.
//
// PlayProfileService persists per-user "viewing personas" so the same
// account can switch between, e.g., a child-safe profile and an adult
// one without changing credentials. Profiles drive content rating
// gates, library access, and player defaults; the upstream Vue project
// shipped the form but never wired the backend, so we implement the
// data model here.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// PlayProfileService manages PlayProfile rows.
type PlayProfileService struct {
log *zap.Logger
repo *repository.Container
}
const MaxPlayProfilesPerUser = 3
var (
ErrPlayProfileNotFound = errors.New("profile not found")
ErrPlayProfileForbidden = errors.New("profile forbidden")
ErrPlayProfilePINInvalid = errors.New("pin invalid")
ErrPlayProfileLimit = errors.New("profile limit reached")
ErrPlayProfileValidation = errors.New("validation error")
)
// NewPlayProfileService is the constructor.
func NewPlayProfileService(log *zap.Logger, repo *repository.Container) *PlayProfileService {
return &PlayProfileService{log: log, repo: repo}
}
// List returns every profile (admin view).
func (s *PlayProfileService) List(ctx context.Context) ([]ProfileView, error) {
rows, err := s.repo.PlayProfile.List(ctx)
if err != nil {
return nil, err
}
out := make([]ProfileView, 0, len(rows))
for _, r := range rows {
out = append(out, toProfileView(r))
}
return out, nil
}
// ListByUser returns the profiles owned by the user.
func (s *PlayProfileService) ListByUser(ctx context.Context, userID string) ([]ProfileView, error) {
rows, err := s.repo.PlayProfile.ListByUser(ctx, userID)
if err != nil {
return nil, err
}
out := make([]ProfileView, 0, len(rows))
for _, r := range rows {
out = append(out, toProfileView(r))
}
return out, nil
}
// Create inserts a new play profile. When IsDefault is true we clear
// every other default for the user inside the same transaction.
func (s *PlayProfileService) Create(ctx context.Context, in PlayProfileInput) (*ProfileView, error) {
if err := validateProfileInput(in, true); err != nil {
return nil, err
}
count, err := s.repo.PlayProfile.CountByUser(ctx, in.UserID)
if err != nil {
return nil, err
}
if count >= MaxPlayProfilesPerUser {
return nil, ErrPlayProfileLimit
}
libsBlob, _ := json.Marshal(in.AllowedLibraryIDs)
p := &model.PlayProfile{
UserID: in.UserID,
Name: strings.TrimSpace(in.Name),
IsDefault: in.IsDefault,
ContentRatingLimit: in.ContentRatingLimit,
AllowAdult: in.AllowAdult,
RequirePIN: in.RequirePIN,
PreferredSubtitleLang: in.PreferredSubtitleLang,
PreferredAudioLang: in.PreferredAudioLang,
AutoplayNext: in.AutoplayNext,
SkipIntro: in.SkipIntro,
AllowedLibraryIDs: string(libsBlob),
}
if in.RequirePIN && in.PIN != "" {
p.PINHash = hashPIN(in.PIN)
}
if in.IsDefault {
if err := s.repo.PlayProfile.ClearDefaultsFor(ctx, in.UserID); err != nil {
return nil, err
}
}
if err := s.repo.PlayProfile.Create(ctx, p); err != nil {
return nil, err
}
v := toProfileView(*p)
return &v, nil
}
// UpdateForUser applies a patch only when the profile belongs to userID.
func (s *PlayProfileService) UpdateForUser(ctx context.Context, id, userID string, in PlayProfileInput) (*ProfileView, error) {
row, err := s.repo.PlayProfile.FindByID(ctx, id)
if err != nil {
return nil, err
}
if row == nil {
return nil, ErrPlayProfileNotFound
}
if row.UserID != userID {
return nil, ErrPlayProfileForbidden
}
return s.updateExisting(ctx, row, in)
}
// Update applies a patch to an existing profile.
func (s *PlayProfileService) Update(ctx context.Context, id string, in PlayProfileInput) (*ProfileView, error) {
row, err := s.repo.PlayProfile.FindByID(ctx, id)
if err != nil {
return nil, err
}
if row == nil {
return nil, ErrPlayProfileNotFound
}
return s.updateExisting(ctx, row, in)
}
func (s *PlayProfileService) updateExisting(ctx context.Context, row *model.PlayProfile, in PlayProfileInput) (*ProfileView, error) {
if err := validateProfileInput(in, false); err != nil {
return nil, err
}
libsBlob, _ := json.Marshal(in.AllowedLibraryIDs)
patch := map[string]any{
"name": strings.TrimSpace(in.Name),
"is_default": in.IsDefault,
"content_rating_limit": in.ContentRatingLimit,
"allow_adult": in.AllowAdult,
"require_pin": in.RequirePIN,
"preferred_subtitle_lang": in.PreferredSubtitleLang,
"preferred_audio_lang": in.PreferredAudioLang,
"autoplay_next": in.AutoplayNext,
"skip_intro": in.SkipIntro,
"allowed_library_ids": string(libsBlob),
}
if in.RequirePIN && in.PIN != "" {
patch["pin_hash"] = hashPIN(in.PIN)
} else if in.RequirePIN && row.PINHash == "" {
return nil, fmt.Errorf("%w: pin required", ErrPlayProfileValidation)
}
if !in.RequirePIN {
patch["pin_hash"] = ""
}
if in.IsDefault {
if err := s.repo.PlayProfile.ClearDefaultsFor(ctx, row.UserID); err != nil {
return nil, err
}
}
if err := s.repo.PlayProfile.Update(ctx, row.ID, patch); err != nil {
return nil, err
}
row, err := s.repo.PlayProfile.FindByID(ctx, row.ID)
if err != nil || row == nil {
return nil, err
}
v := toProfileView(*row)
return &v, nil
}
// Delete removes a profile.
func (s *PlayProfileService) Delete(ctx context.Context, id string) error {
return s.repo.PlayProfile.Delete(ctx, id)
}
// DeleteForUser removes a profile only when it belongs to userID.
func (s *PlayProfileService) DeleteForUser(ctx context.Context, id, userID string) error {
row, err := s.repo.PlayProfile.FindByID(ctx, id)
if err != nil {
return err
}
if row == nil {
return ErrPlayProfileNotFound
}
if row.UserID != userID {
return ErrPlayProfileForbidden
}
return s.repo.PlayProfile.Delete(ctx, id)
}
// VerifyPIN validates that the caller can switch to a PIN-protected profile.
func (s *PlayProfileService) VerifyPIN(ctx context.Context, id, userID, pin string) (*ProfileView, error) {
row, err := s.repo.PlayProfile.FindByID(ctx, id)
if err != nil {
return nil, err
}
if row == nil {
return nil, ErrPlayProfileNotFound
}
if row.UserID != userID {
return nil, ErrPlayProfileForbidden
}
if row.RequirePIN {
if row.PINHash == "" || hashPIN(pin) != row.PINHash {
return nil, ErrPlayProfilePINInvalid
}
}
view := toProfileView(*row)
return &view, nil
}
// TouchActive bumps the LastActiveAt timestamp; called by the player
// when a profile is selected.
func (s *PlayProfileService) TouchActive(ctx context.Context, id string) error {
now := time.Now()
return s.repo.PlayProfile.Update(ctx, id, map[string]any{
"last_active_at": &now,
})
}