Files
MeBox/internal/service/api_config.go
T
ShukeBta ff14251371 feat: 前端显示元数据信息并添加智能分类状态提示
- Media 类型添加 languages/countries/genres 字段
- MediaDetailPage 显示元数据 badges(Languages/Countries/Genres)
- ToolsPage OrganizePanel 显示智能分类启用状态
- Badge 组件支持 className prop
- 添加 parseCSV 辅助函数处理逗号分隔字符串
2026-05-18 02:57:45 +08:00

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
}