支持按用户保存播放器与弹幕偏好

在 user 记录上新增播放器音量、弹幕开关、透明度、字号、显示区域、合并偏好、自定义弹幕源以及 dandanplay 应用凭据字段,并提供 PUT /danmaku/settings 局部更新接口,带取值校验。
返回脱敏后的完整渲染配置,绝不回传应用密钥,仅在响应中标记密钥是否已配置。
结果缓存键加入用户 ID 与凭据指纹,避免不同用户或凭据串用缓存;旧版实例级设置仅作为未登录与迁移期的回退。
This commit is contained in:
truewhile
2026-09-13 23:01:43 +08:00
parent be9d3a6d9e
commit b7f1cd85f8
17 changed files with 856 additions and 209 deletions
+12 -16
View File
@@ -2,6 +2,7 @@
package handler
import (
"errors"
"net/http"
"strings"
@@ -16,10 +17,8 @@ import (
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := currentUserID(c)
// 弹幕合并偏好按用户存储:这里读取后作为本次抓取的选项传入。
opts := service.DanmakuFetchOptions{
MergeSources: svc.Danmaku.MergeSourcesEnabled(c.Request.Context(), uid),
}
// 按用户读取弹幕源、凭据、合并偏好和渲染参数。
opts := service.DanmakuFetchOptions{UserID: uid}
res, err := svc.Danmaku.FetchWithOptions(
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
if err != nil {
@@ -30,17 +29,15 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
}
}
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
// size, area, enabled) so the player can initialize its control panel without
// admin privileges.
// getDanmakuConfigHandler exposes the current user's player volume and danmaku
// preferences so the player can initialize without admin privileges.
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
}
}
// updateDanmakuSettingsHandler 持久化当前用户的弹幕偏好。目前只有合并开关,
// 落在 user 表上(与字幕简繁偏好同样按用户存储)。
// updateDanmakuSettingsHandler 持久化当前用户的播放器音量与弹幕偏好。
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := currentUserID(c)
@@ -48,21 +45,20 @@ func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
var req struct {
MergeSources *bool `json:"merge_sources"`
}
var req service.DanmakuSettingsPatch
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if req.MergeSources == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "merge_sources is required"})
cfg, err := svc.Danmaku.UpdateUserSettings(c.Request.Context(), uid, req)
if errors.Is(err, service.ErrNoDanmakuSettings) || errors.Is(err, service.ErrInvalidDanmakuSettings) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Danmaku.SetMergeSources(c.Request.Context(), uid, *req.MergeSources); err != nil {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"merge_sources": *req.MergeSources})
c.JSON(http.StatusOK, cfg)
}
}
+55
View File
@@ -133,3 +133,58 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
t.Fatal("config should reflect the persisted merge preference")
}
}
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
svc := newDanmakuSettingsService(t)
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if bytes.Contains(w.Body.Bytes(), []byte("my-app-secret")) {
t.Fatal("response must never expose the application secret")
}
var cfg service.DanmakuRenderConfig
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
t.Fatalf("unexpected render config: %+v", cfg)
}
if !cfg.MergeSources || cfg.Volume != 0.35 {
t.Fatalf("unexpected user preferences: %+v", cfg)
}
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
t.Fatalf("unexpected service config: %+v", cfg)
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
t.Fatalf("preferences not persisted: %+v", user)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"source":"ftp://dm.example.com"}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read user: %v", err)
}
if user.DanmakuSource != "" {
t.Fatalf("invalid source was persisted: %q", user.DanmakuSource)
}
}
+11 -4
View File
@@ -31,10 +31,17 @@ type User struct {
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// DanmakuMergeSources 是网页播放器的弹幕偏好:开启后,同一集的多个弹幕
// 来源会被合并并按「时间 + 内容」去重后一起展示。按用户存储,避免一个
// 用户的开关影响其他人。
DanmakuMergeSources bool `gorm:"default:false" json:"danmaku_merge_sources"`
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
DanmakuEnabled bool `gorm:"not null;default:true" json:"danmaku_enabled"`
DanmakuOpacity float64 `gorm:"not null;default:1" json:"danmaku_opacity"`
DanmakuFontSize int `gorm:"not null;default:24" json:"danmaku_font_size"`
DanmakuArea float64 `gorm:"not null;default:1" json:"danmaku_area"`
DanmakuMergeSources bool `gorm:"not null;default:false" json:"danmaku_merge_sources"`
DanmakuSource string `gorm:"size:512" json:"danmaku_source,omitempty"`
DanmakuAppID string `gorm:"size:128" json:"danmaku_app_id,omitempty"`
// DanmakuAppKey 只在服务端读取并用于请求签名,绝不通过用户资料接口下发。
DanmakuAppKey string `gorm:"size:256" json:"-"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
+247 -40
View File
@@ -28,9 +28,9 @@ import (
"github.com/truewhile/MeBox/internal/repository"
)
// Danmaku setting keys, managed through the admin settings UI (PUT
// /admin/settings). They are stored in the Setting table so the playback
// page can pull them without admin privileges.
// Legacy instance-level danmaku settings. Playback preferences now live on
// model.User; these keys remain only as a fallback for unauthenticated/legacy
// service paths and tests.
const (
DanmakuEnabledKey = "danmaku.enabled"
DanmakuSourceKey = "danmaku.source"
@@ -56,21 +56,55 @@ var danmakuOfficialBase = DanmakuDefaultSource
// DanmakuRenderConfig carries the renderer knobs to the web player.
type DanmakuRenderConfig struct {
Enabled bool `json:"enabled"`
Source string `json:"source,omitempty"`
Opacity string `json:"opacity"`
FontSize string `json:"font_size"`
Area string `json:"area"`
Enabled bool `json:"enabled"`
Source string `json:"source,omitempty"`
AppID string `json:"app_id,omitempty"`
Opacity string `json:"opacity"`
FontSize string `json:"font_size"`
Area string `json:"area"`
Volume float64 `json:"volume"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
MergeSources bool `json:"merge_sources"`
// AppKeyConfigured 只表明用户是否保存了应用密钥,不回传密钥明文。
AppKeyConfigured bool `json:"app_key_configured"`
}
// DanmakuFetchOptions 承载单次抓取的调用方偏好。
type DanmakuFetchOptions struct {
// MergeSources 为真时,同一集的多个来源会被合并去重后一起返回。
MergeSources bool
// UserID 非空时使用该用户的弹幕源、凭据与渲染偏好。
UserID string
}
// DanmakuSettingsPatch 是播放器弹幕设置的局部更新。nil 字段保持不变。
type DanmakuSettingsPatch struct {
Enabled *bool `json:"enabled"`
Source *string `json:"source"`
AppID *string `json:"app_id"`
AppKey *string `json:"app_key"`
ClearAppKey bool `json:"clear_app_key"`
Opacity *float64 `json:"opacity"`
FontSize *int `json:"font_size"`
Area *float64 `json:"area"`
MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"`
}
type danmakuUserContextKey struct{}
type danmakuUserContext struct {
source string
appID string
appKey string
}
// ErrNoDanmakuSettings 表示更新请求没有包含任何可写字段。
var ErrNoDanmakuSettings = errors.New("no settings to update")
// ErrInvalidDanmakuSettings 表示更新请求包含非法值。
var ErrInvalidDanmakuSettings = errors.New("invalid player settings")
// DanmakuFetchResult is what /api/danmaku/:id returns. Raw holds the upstream
// comment payload; parsing happens client-side. The dandanplay protocol has
// two payload shapes in the wild — the classic Bilibili-style XML and the
@@ -197,9 +231,11 @@ type danmakuResultCacheEntry struct {
storedAt time.Time
}
func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge bool) string {
func danmakuResultCacheKey(userID, source, credentialFingerprint, mediaID, keyword, episodeID string, merge bool) string {
return strings.Join([]string{
strings.TrimSpace(userID),
strings.TrimSpace(source),
strings.TrimSpace(credentialFingerprint),
mediaID,
strings.TrimSpace(keyword),
strings.TrimSpace(episodeID),
@@ -207,6 +243,18 @@ func danmakuResultCacheKey(source, mediaID, keyword, episodeID string, merge boo
}, "\x00")
}
// danmakuCredentialFingerprint keeps credential changes in the result cache
// key without persisting the AppSecret in plain text.
func danmakuCredentialFingerprint(appID, appKey string) string {
appID = strings.TrimSpace(appID)
appKey = strings.TrimSpace(appKey)
if appID == "" && appKey == "" {
return ""
}
sum := md5.Sum([]byte(appID + "\x00" + appKey))
return hex.EncodeToString(sum[:])
}
func (s *DanmakuService) resultCacheGet(key string) (*DanmakuFetchResult, bool) {
if s == nil || key == "" {
return nil, false
@@ -300,6 +348,7 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
}
if s == nil || s.repo == nil || s.repo.Setting == nil {
return cfg
@@ -319,39 +368,164 @@ func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
return cfg
}
// ConfigForUser 在全局渲染设置之外附加当前用户的个性化偏好。
// ConfigForUser 返回当前用户的播放器与弹幕偏好。用户不存在时回退到旧版
// 全局配置,便于旧客户端和迁移期平滑工作。
func (s *DanmakuService) ConfigForUser(ctx context.Context, userID string) DanmakuRenderConfig {
cfg := s.Config(ctx)
cfg.MergeSources = s.MergeSourcesEnabled(ctx, userID)
return cfg
if user, ok := s.findUser(ctx, userID); ok {
return danmakuConfigFromUser(user)
}
return s.Config(ctx)
}
// MergeSourcesEnabled 返回该用户的弹幕合并偏好,读取失败时回退为关闭。
func (s *DanmakuService) MergeSourcesEnabled(ctx context.Context, userID string) bool {
if s == nil || s.repo == nil || s.repo.User == nil {
return false
if user, ok := s.findUser(ctx, userID); ok {
return user.DanmakuMergeSources
}
userID = strings.TrimSpace(userID)
if userID == "" {
return false
}
user, err := s.repo.User.FindByID(ctx, userID)
if err != nil || user == nil {
return false
}
return user.DanmakuMergeSources
return false
}
// SetMergeSources 持久化该用户的弹幕合并偏好。
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.User, bool) {
if s == nil || s.repo == nil || s.repo.User == nil {
return errors.New("danmaku settings unavailable")
return nil, false
}
userID = strings.TrimSpace(userID)
if userID == "" {
return errors.New("missing user")
return nil, false
}
return s.repo.User.UpdateFields(ctx, userID, map[string]any{"danmaku_merge_sources": enabled})
user, err := s.repo.User.FindByID(ctx, userID)
return user, err == nil && user != nil
}
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if user == nil {
return DanmakuRenderConfig{Enabled: true, Opacity: "1", FontSize: "24", Area: "1", Volume: 1}
}
opacity := user.DanmakuOpacity
if opacity < 0.1 || opacity > 1 {
opacity = 1
}
fontSize := user.DanmakuFontSize
if fontSize < 10 || fontSize > 96 {
fontSize = 24
}
area := user.DanmakuArea
if area < 0.1 || area > 1 {
area = 1
}
volume := user.PlayerVolume
if volume < 0 || volume > 1 {
volume = 1
}
return DanmakuRenderConfig{
Enabled: user.DanmakuEnabled,
Source: strings.TrimSpace(user.DanmakuSource),
AppID: strings.TrimSpace(user.DanmakuAppID),
Opacity: strconv.FormatFloat(opacity, 'f', -1, 64),
FontSize: strconv.Itoa(fontSize),
Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume,
MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
}
}
// UpdateUserSettings 持久化当前用户的播放器与弹幕设置,并返回脱敏后的完整配置。
func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string, patch DanmakuSettingsPatch) (DanmakuRenderConfig, error) {
if s == nil || s.repo == nil || s.repo.User == nil {
return DanmakuRenderConfig{}, errors.New("player settings unavailable")
}
userID = strings.TrimSpace(userID)
if userID == "" {
return DanmakuRenderConfig{}, errors.New("missing user")
}
updates := map[string]any{}
if patch.Enabled != nil {
updates["danmaku_enabled"] = *patch.Enabled
}
if patch.Source != nil {
source, err := normalizeDanmakuSource(*patch.Source)
if err != nil {
return DanmakuRenderConfig{}, err
}
updates["danmaku_source"] = source
}
if patch.AppID != nil {
appID := strings.TrimSpace(*patch.AppID)
if len(appID) > 128 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: app_id is too long", ErrInvalidDanmakuSettings)
}
updates["danmaku_app_id"] = appID
}
if patch.ClearAppKey {
updates["danmaku_app_key"] = ""
} else if patch.AppKey != nil {
if key := strings.TrimSpace(*patch.AppKey); key != "" {
if len(key) > 256 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: app_key is too long", ErrInvalidDanmakuSettings)
}
updates["danmaku_app_key"] = key
}
}
if patch.Opacity != nil {
if *patch.Opacity < 0.1 || *patch.Opacity > 1 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: opacity must be between 0.1 and 1", ErrInvalidDanmakuSettings)
}
updates["danmaku_opacity"] = *patch.Opacity
}
if patch.FontSize != nil {
if *patch.FontSize < 10 || *patch.FontSize > 96 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: font_size must be between 10 and 96", ErrInvalidDanmakuSettings)
}
updates["danmaku_font_size"] = *patch.FontSize
}
if patch.Area != nil {
if *patch.Area < 0.1 || *patch.Area > 1 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: area must be between 0.1 and 1", ErrInvalidDanmakuSettings)
}
updates["danmaku_area"] = *patch.Area
}
if patch.MergeSources != nil {
updates["danmaku_merge_sources"] = *patch.MergeSources
}
if patch.Volume != nil {
if *patch.Volume < 0 || *patch.Volume > 1 {
return DanmakuRenderConfig{}, fmt.Errorf("%w: volume must be between 0 and 1", ErrInvalidDanmakuSettings)
}
updates["player_volume"] = *patch.Volume
}
if len(updates) == 0 {
return DanmakuRenderConfig{}, ErrNoDanmakuSettings
}
if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil {
return DanmakuRenderConfig{}, err
}
return s.ConfigForUser(ctx, userID), nil
}
// normalizeDanmakuSource validates a user-configured dandanplay endpoint. An
// empty value means the official endpoint; only explicit HTTP(S) origins are
// accepted so malformed values never reach the outbound request layer.
func normalizeDanmakuSource(raw string) (string, error) {
source := strings.TrimRight(strings.TrimSpace(raw), "/")
if source == "" {
return "", nil
}
if len(source) > 512 {
return "", fmt.Errorf("%w: source is too long", ErrInvalidDanmakuSettings)
}
u, err := url.Parse(source)
if err != nil || u.Host == "" || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) || u.User != nil {
return "", fmt.Errorf("%w: source must be an http(s) URL", ErrInvalidDanmakuSettings)
}
return source, nil
}
// SetMergeSources 持久化该用户的弹幕合并偏好,保留旧调用点兼容性。
func (s *DanmakuService) SetMergeSources(ctx context.Context, userID string, enabled bool) error {
_, err := s.UpdateUserSettings(ctx, userID, DanmakuSettingsPatch{MergeSources: &enabled})
return err
}
// Fetch retrieves danmaku for the given media. keyword overrides the
@@ -388,11 +562,23 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
return nil, errors.New("danmaku service unavailable")
}
cfg := s.Config(ctx)
credentialFingerprint := ""
if user, ok := s.findUser(ctx, opts.UserID); ok {
cfg = danmakuConfigFromUser(user)
opts.MergeSources = cfg.MergeSources
credentialFingerprint = danmakuCredentialFingerprint(user.DanmakuAppID, user.DanmakuAppKey)
ctx = context.WithValue(ctx, danmakuUserContextKey{}, danmakuUserContext{
source: cfg.Source,
appID: strings.TrimSpace(user.DanmakuAppID),
appKey: strings.TrimSpace(user.DanmakuAppKey),
})
}
if !cfg.Enabled {
return &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}, nil
}
key := danmakuResultCacheKey(cfg.Source, mediaID, keyword, episodeID, opts.MergeSources)
key := danmakuResultCacheKey(opts.UserID, cfg.Source, credentialFingerprint, mediaID, keyword, episodeID, opts.MergeSources)
if cached, ok := s.resultCacheGet(key); ok {
cached.DanmakuRenderConfig = cfg
return cached, nil
}
value, err, _ := s.fetchGroup.Do(key, func() (any, error) {
@@ -400,7 +586,7 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
if cached, ok := s.resultCacheGet(key); ok {
return cached, nil
}
res, err := s.fetchWithOptionsUncached(ctx, mediaID, keyword, episodeID, opts)
res, err := s.fetchWithOptionsUncached(ctx, cfg, mediaID, keyword, episodeID, opts)
if err != nil {
// 与原实现一致:失败时仍把已填充的渲染配置/匹配信息交给调用方。
return res, err
@@ -409,6 +595,9 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
return res, nil
})
res, _ := value.(*DanmakuFetchResult)
if res != nil {
res.DanmakuRenderConfig = cfg
}
if err != nil {
return cloneDanmakuFetchResult(res), err
}
@@ -416,8 +605,8 @@ func (s *DanmakuService) FetchWithOptions(ctx context.Context, mediaID, keyword,
}
// fetchWithOptionsUncached 是未命中缓存时执行的原始抓取流程。
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
res := &DanmakuFetchResult{DanmakuRenderConfig: s.Config(ctx), SourceType: "auto"}
func (s *DanmakuService) fetchWithOptionsUncached(ctx context.Context, cfg DanmakuRenderConfig, mediaID, keyword, episodeID string, opts DanmakuFetchOptions) (*DanmakuFetchResult, error) {
res := &DanmakuFetchResult{DanmakuRenderConfig: cfg, SourceType: "auto"}
if !res.Enabled {
return res, nil
}
@@ -870,19 +1059,37 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow
return string(body), nil
}
// danmakuCredentials resolves the application credentials for the official
// dandanplay API. Admin-configured values (danmaku.app_id / danmaku.app_key)
// win; otherwise the built-in obfuscated fallback pair is used. Returns
// ok=false for any other host so credentials — including the built-in pair —
// are never sent to third-party dandanplay protocol mirrors. The "official"
// host follows danmakuOfficialBase (overridable in tests).
// danmakuCredentials resolves application credentials. Per-user explicit
// credentials are used for any configured source. Legacy global credentials
// and the built-in fallback pair are only sent to the official host, never to
// third-party dandanplay protocol mirrors.
func (s *DanmakuService) danmakuCredentials(ctx context.Context, sourceURL string) (appID, appKey string, ok bool) {
u, err := url.Parse(sourceURL)
if err != nil {
return "", "", false
}
official, err := url.Parse(danmakuOfficialBase)
if err != nil || !strings.EqualFold(u.Hostname(), official.Hostname()) {
if err != nil {
return "", "", false
}
isOfficial := strings.EqualFold(u.Hostname(), official.Hostname())
if userCtx, ok := ctx.Value(danmakuUserContextKey{}).(danmakuUserContext); ok {
userSource := strings.TrimSpace(userCtx.source)
if userSource == "" {
userSource = danmakuOfficialBase
}
if sameDanmakuBase(sourceURL, userSource) {
id, key := strings.TrimSpace(userCtx.appID), strings.TrimSpace(userCtx.appKey)
if id != "" && key != "" {
return id, key, true
}
if id != "" || key != "" {
s.log.Warn("danmaku user credentials incomplete, ignoring them",
zap.Bool("has_app_id", id != ""), zap.Bool("has_app_key", key != ""))
}
}
}
if !isOfficial {
return "", "", false
}
var id, key string
+40
View File
@@ -60,6 +60,7 @@ func seedDanmakuMedia(t *testing.T, svc *DanmakuService, id, title, originalName
type danmakuSourceServer struct {
server *httptest.Server
lastSearch string // full query (anime=...&episode=...)
lastHeaders http.Header
searchResponse string // JSON body served for /api/v2/search/episodes
}
@@ -76,6 +77,7 @@ func newDanmakuSourceServerWithSearch(t *testing.T, searchResponse string) *danm
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
ds.lastSearch = r.URL.RawQuery
ds.lastHeaders = r.Header.Clone()
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, ds.searchResponse)
})
@@ -404,3 +406,41 @@ func TestDanmakuFetchCoalescesConcurrentRequests(t *testing.T) {
}
require.EqualValues(t, 1, atomic.LoadInt32(&searchCalls))
}
func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
srv := newDanmakuSourceServer(t)
svc := newDanmakuTestService(t)
ctx := context.Background()
user := model.User{Username: "danmaku-user", PasswordHash: "x", Role: "user", IsActive: true}
user.ID = "danmaku-user-1"
user.DanmakuEnabled = true
user.DanmakuSource = srv.URL()
user.DanmakuAppID = "user-app-id"
user.DanmakuAppKey = "user-app-secret"
user.DanmakuOpacity = 0.65
user.DanmakuFontSize = 30
user.DanmakuArea = 0.7
user.PlayerVolume = 0.42
require.NoError(t, svc.repo.User.Create(ctx, &user))
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
res, err := svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID})
require.NoError(t, err)
require.Contains(t, res.Raw, "弹幕A")
require.Equal(t, srv.URL(), res.Source)
require.Equal(t, 0.42, res.Volume)
require.Equal(t, "0.65", res.Opacity)
require.Equal(t, "30", res.FontSize)
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
require.NotEmpty(t, srv.lastHeaders.Get("X-Signature"))
// Credential rotation must invalidate the cached fetch for the same user.
require.NoError(t, svc.repo.User.UpdateFields(ctx, user.ID, map[string]any{
"danmaku_app_id": "user-app-id-2",
"danmaku_app_key": "user-app-secret-2",
}))
_, err = svc.FetchWithOptions(ctx, "per-user-media", "", "", DanmakuFetchOptions{UserID: user.ID})
require.NoError(t, err)
require.Equal(t, "user-app-id-2", srv.lastHeaders.Get("X-AppId"))
}