mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
ff14251371
- Media 类型添加 languages/countries/genres 字段 - MediaDetailPage 显示元数据 badges(Languages/Countries/Genres) - ToolsPage OrganizePanel 显示智能分类启用状态 - Badge 组件支持 className prop - 添加 parseCSV 辅助函数处理逗号分隔字符串
242 lines
7.5 KiB
Go
242 lines
7.5 KiB
Go
// Package service — third-party API key store.
|
|
//
|
|
// APIConfigService is a small CRUD layer over the api_configs table. It
|
|
// transparently encrypts the api_key column on write and decrypts it on
|
|
// read so values stored on disk are useless without the JWT secret.
|
|
//
|
|
// On first read it seeds the table with the providers MediaStation
|
|
// supports today (TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban).
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
|
)
|
|
|
|
// APIConfigService coordinates third-party API key storage.
|
|
type APIConfigService struct {
|
|
log *zap.Logger
|
|
repo *repository.Container
|
|
crypto *CryptoService
|
|
}
|
|
|
|
// NewAPIConfigService is the constructor.
|
|
func NewAPIConfigService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *APIConfigService {
|
|
return &APIConfigService{log: log, repo: repo, crypto: crypto}
|
|
}
|
|
|
|
// SeedDefaults inserts a row for every well-known provider on first run.
|
|
func (s *APIConfigService) SeedDefaults(ctx context.Context) error {
|
|
defaults := []model.APIConfig{
|
|
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Description: "TMDb (movies + tv)", Enabled: true},
|
|
{Provider: "bangumi", BaseURL: "https://api.bgm.tv", Description: "Bangumi (anime)", Enabled: true},
|
|
{Provider: "thetvdb", BaseURL: "https://api4.thetvdb.com/v4", Description: "TheTVDB (tv)", Enabled: true},
|
|
{Provider: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", Enabled: true},
|
|
{Provider: "douban", Description: "Douban cookie (zh metadata)", Enabled: true},
|
|
{Provider: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
|
|
}
|
|
for i := range defaults {
|
|
var existing model.APIConfig
|
|
err := s.repo.DB.WithContext(ctx).
|
|
Where("provider = ?", defaults[i].Provider).
|
|
First(&existing).Error
|
|
if err == nil {
|
|
continue
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
if err := s.repo.DB.WithContext(ctx).Create(&defaults[i]).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PublicView is the safe-to-display projection of an API config row.
|
|
// The plaintext key is never returned — only a mask.
|
|
type PublicView struct {
|
|
ID string `json:"id"`
|
|
Provider string `json:"provider"`
|
|
BaseURL string `json:"base_url,omitempty"`
|
|
Extra string `json:"extra,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
Description string `json:"description,omitempty"`
|
|
HasKey bool `json:"has_key"`
|
|
MaskedKey string `json:"masked_key,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// List returns every API config row (with masked keys).
|
|
func (s *APIConfigService) List(ctx context.Context) ([]PublicView, error) {
|
|
var rows []model.APIConfig
|
|
if err := s.repo.DB.WithContext(ctx).Order("provider asc").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]PublicView, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, s.toPublic(&r))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Get returns the public view for a single provider, or nil.
|
|
func (s *APIConfigService) Get(ctx context.Context, provider string) (*PublicView, error) {
|
|
row, err := s.findByProvider(ctx, provider)
|
|
if err != nil || row == nil {
|
|
return nil, err
|
|
}
|
|
v := s.toPublic(row)
|
|
return &v, nil
|
|
}
|
|
|
|
// Resolve returns the decrypted key + base url ready for use by an HTTP
|
|
// client. Empty struct (with no error) when the provider is unknown or
|
|
// the API key is empty.
|
|
type Resolved struct {
|
|
APIKey string
|
|
BaseURL string
|
|
Extra string
|
|
Enabled bool
|
|
}
|
|
|
|
// Resolve fetches the live configuration for a provider, decrypting the
|
|
// API key. Callers can use Resolved.APIKey != "" as the "configured" check.
|
|
func (s *APIConfigService) Resolve(ctx context.Context, provider string) (Resolved, error) {
|
|
row, err := s.findByProvider(ctx, provider)
|
|
if err != nil {
|
|
s.log.Warn("api_config.resolve: query failed", zap.String("provider", provider), zap.Error(err))
|
|
return Resolved{}, err
|
|
}
|
|
if row == nil {
|
|
s.log.Warn("api_config.resolve: provider not found", zap.String("provider", provider))
|
|
return Resolved{}, nil
|
|
}
|
|
resolved := Resolved{
|
|
APIKey: s.crypto.Decrypt(row.APIKey),
|
|
BaseURL: row.BaseURL,
|
|
Extra: row.Extra,
|
|
Enabled: row.Enabled,
|
|
}
|
|
s.log.Debug("api_config.resolve: success",
|
|
zap.String("provider", provider),
|
|
zap.Bool("has_key", resolved.APIKey != ""),
|
|
zap.Bool("enabled", resolved.Enabled))
|
|
return resolved, nil
|
|
}
|
|
|
|
// Update upserts a single provider's config. An empty patch.APIKey leaves
|
|
// the existing key untouched; pass "<clear>" sentinel to wipe it.
|
|
type APIConfigPatch struct {
|
|
APIKey *string `json:"api_key,omitempty"`
|
|
BaseURL *string `json:"base_url,omitempty"`
|
|
Extra *string `json:"extra,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
}
|
|
|
|
// Update applies the patch and returns the new public view.
|
|
func (s *APIConfigService) Update(ctx context.Context, provider string, patch APIConfigPatch) (*PublicView, error) {
|
|
provider = strings.TrimSpace(strings.ToLower(provider))
|
|
if provider == "" {
|
|
return nil, errors.New("provider required")
|
|
}
|
|
|
|
row, err := s.findByProvider(ctx, provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if row == nil {
|
|
row = &model.APIConfig{Provider: provider, Enabled: true}
|
|
if err := s.repo.DB.WithContext(ctx).Create(row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
updates := map[string]any{}
|
|
if patch.APIKey != nil {
|
|
v := strings.TrimSpace(*patch.APIKey)
|
|
if v == "" || v == "<clear>" {
|
|
updates["api_key"] = ""
|
|
} else {
|
|
updates["api_key"] = s.crypto.Encrypt(v)
|
|
}
|
|
}
|
|
if patch.BaseURL != nil {
|
|
updates["base_url"] = *patch.BaseURL
|
|
}
|
|
if patch.Extra != nil {
|
|
updates["extra"] = *patch.Extra
|
|
}
|
|
if patch.Enabled != nil {
|
|
updates["enabled"] = *patch.Enabled
|
|
}
|
|
if patch.Description != nil {
|
|
updates["description"] = *patch.Description
|
|
}
|
|
if len(updates) > 0 {
|
|
if err := s.repo.DB.WithContext(ctx).
|
|
Model(&model.APIConfig{}).
|
|
Where("id = ?", row.ID).
|
|
Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
row, _ = s.findByProvider(ctx, provider)
|
|
v := s.toPublic(row)
|
|
return &v, nil
|
|
}
|
|
|
|
// Delete clears a provider's API key (the row stays so the masked
|
|
// description is still useful). Non-existent providers are a no-op.
|
|
func (s *APIConfigService) Delete(ctx context.Context, provider string) error {
|
|
row, err := s.findByProvider(ctx, provider)
|
|
if err != nil || row == nil {
|
|
return err
|
|
}
|
|
return s.repo.DB.WithContext(ctx).
|
|
Model(&model.APIConfig{}).
|
|
Where("id = ?", row.ID).
|
|
Update("api_key", "").Error
|
|
}
|
|
|
|
func (s *APIConfigService) findByProvider(ctx context.Context, provider string) (*model.APIConfig, error) {
|
|
var row model.APIConfig
|
|
err := s.repo.DB.WithContext(ctx).Where("provider = ?", provider).First(&row).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &row, nil
|
|
}
|
|
|
|
func (s *APIConfigService) toPublic(r *model.APIConfig) PublicView {
|
|
plain := s.crypto.Decrypt(r.APIKey)
|
|
pv := PublicView{
|
|
ID: r.ID,
|
|
Provider: r.Provider,
|
|
BaseURL: r.BaseURL,
|
|
Extra: r.Extra,
|
|
Enabled: r.Enabled,
|
|
Description: r.Description,
|
|
HasKey: plain != "",
|
|
CreatedAt: r.CreatedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
}
|
|
if pv.HasKey {
|
|
pv.MaskedKey = MaskAPIKey(plain)
|
|
}
|
|
return pv
|
|
}
|