feat(api): add homepage video settings endpoints

This commit is contained in:
Haitao Pan 2026-03-18 15:14:08 +08:00
parent e7001750a3
commit 0180e6ace5
6 changed files with 528 additions and 0 deletions

View File

@ -331,6 +331,7 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
authGroup.GET("/mfa/status", h.mfaStatus)
authGroup.GET("/sync/config", h.syncConfigSnapshot)
authGroup.POST("/sync/ack", h.syncConfigAck)
authGroup.GET("/homepage-video", h.getHomepageVideoPublic)
// Sandbox binding read endpoint.
// Used by the Console Guest/Demo experience. Must be readable either via a
@ -366,6 +367,8 @@ func RegisterRoutes(r *gin.Engine, opts ...Option) {
authProtected.GET("/admin/settings", h.getAdminSettings)
authProtected.POST("/admin/settings", h.updateAdminSettings)
authProtected.GET("/admin/homepage-video", h.getHomepageVideoSettings)
authProtected.PUT("/admin/homepage-video", h.updateHomepageVideoSettings)
// Backward-compatible auth-scoped admin routes consumed by the dashboard BFF.
authProtected.GET("/admin/users/metrics", h.adminUsersMetrics)

114
api/homepage_video.go Normal file
View File

@ -0,0 +1,114 @@
package api
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"account/internal/service"
)
type homepageVideoEntryPayload struct {
Domain string `json:"domain,omitempty"`
VideoURL string `json:"videoUrl"`
PosterURL string `json:"posterUrl"`
}
func toHomepageVideoEntryPayload(entry service.HomepageVideoEntry) homepageVideoEntryPayload {
return homepageVideoEntryPayload{
Domain: strings.TrimSpace(entry.DomainKey),
VideoURL: strings.TrimSpace(entry.VideoURL),
PosterURL: strings.TrimSpace(entry.PosterURL),
}
}
func (h *handler) getHomepageVideoPublic(c *gin.Context) {
entry, err := service.ResolveHomepageVideoEntry(c.Request.Context(), h.resolveTenantHost(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"resolved": toHomepageVideoEntryPayload(entry),
})
}
func (h *handler) getHomepageVideoSettings(c *gin.Context) {
if _, ok := h.requireAdminPermission(c, permissionAdminSettingsRead); !ok {
return
}
settings, err := service.GetHomepageVideoSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
overrides := make([]homepageVideoEntryPayload, 0, len(settings.Overrides))
for _, item := range settings.Overrides {
overrides = append(overrides, toHomepageVideoEntryPayload(item))
}
c.JSON(http.StatusOK, gin.H{
"defaultEntry": toHomepageVideoEntryPayload(settings.DefaultEntry),
"overrides": overrides,
})
}
func (h *handler) updateHomepageVideoSettings(c *gin.Context) {
adminUser, ok := h.requireAdminPermission(c, permissionAdminSettingsWrite)
if !ok {
return
}
if h.isReadOnlyAccount(adminUser) {
respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only")
return
}
var req struct {
DefaultEntry homepageVideoEntryPayload `json:"defaultEntry"`
Overrides []homepageVideoEntryPayload `json:"overrides"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
overrides := make([]service.HomepageVideoEntry, 0, len(req.Overrides))
for _, item := range req.Overrides {
overrides = append(overrides, service.HomepageVideoEntry{
DomainKey: item.Domain,
VideoURL: item.VideoURL,
PosterURL: item.PosterURL,
})
}
settings, err := service.SaveHomepageVideoSettings(c.Request.Context(), service.HomepageVideoSettings{
DefaultEntry: service.HomepageVideoEntry{
VideoURL: req.DefaultEntry.VideoURL,
PosterURL: req.DefaultEntry.PosterURL,
},
Overrides: overrides,
}, adminUser.Email)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, service.ErrServiceDBNotInitialized) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
responseOverrides := make([]homepageVideoEntryPayload, 0, len(settings.Overrides))
for _, item := range settings.Overrides {
responseOverrides = append(responseOverrides, toHomepageVideoEntryPayload(item))
}
c.JSON(http.StatusOK, gin.H{
"defaultEntry": toHomepageVideoEntryPayload(settings.DefaultEntry),
"overrides": responseOverrides,
})
}

193
api/homepage_video_test.go Normal file
View File

@ -0,0 +1,193 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"account/internal/model"
"account/internal/service"
"account/internal/store"
)
type homepageVideoTestEnv struct {
router *gin.Engine
adminToken string
userToken string
}
func setupHomepageVideoTestRouter(t *testing.T) homepageVideoTestEnv {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.AdminSetting{}, &model.HomepageVideoSetting{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
service.SetDB(db)
t.Cleanup(func() {
service.SetDB(nil)
sqlDB, _ := db.DB()
sqlDB.Close()
})
memoryStore := store.NewMemoryStore()
ctx := context.Background()
createUser := func(name, email, password, role string, level int) string {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := &store.User{
Name: name,
Email: email,
PasswordHash: string(hashed),
Role: role,
Level: level,
EmailVerified: true,
}
if err := memoryStore.CreateUser(ctx, user); err != nil {
t.Fatalf("create user: %v", err)
}
return password
}
adminPassword := createUser("admin", "admin@example.com", "AdminPass123!", store.RoleAdmin, store.LevelAdmin)
userPassword := createUser("user", "user@example.com", "UserPass123!", store.RoleUser, store.LevelUser)
router := gin.New()
RegisterRoutes(router, WithStore(memoryStore), WithEmailVerification(false))
login := func(email, password string) string {
payload := map[string]string{
"email": email,
"password": password,
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("login failed for %s: %d %s", email, resp.Code, resp.Body.String())
}
var result struct {
Token string `json:"token"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &result); err != nil {
t.Fatalf("decode login response: %v", err)
}
return result.Token
}
return homepageVideoTestEnv{
router: router,
adminToken: login("admin@example.com", adminPassword),
userToken: login("user@example.com", userPassword),
}
}
func TestHomepageVideoPublicDefaults(t *testing.T) {
env := setupHomepageVideoTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/auth/homepage-video", nil)
req.Header.Set("X-Forwarded-Host", "cn-www.svc.plus")
resp := httptest.NewRecorder()
env.router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d (%s)", resp.Code, resp.Body.String())
}
var payload struct {
Resolved struct {
Domain string `json:"domain"`
VideoURL string `json:"videoUrl"`
} `json:"resolved"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Resolved.Domain != "cn-www.svc.plus" {
t.Fatalf("expected cn override, got %q", payload.Resolved.Domain)
}
if payload.Resolved.VideoURL == "" {
t.Fatalf("expected resolved video url")
}
}
func TestHomepageVideoAdminReadWrite(t *testing.T) {
env := setupHomepageVideoTestRouter(t)
body, _ := json.Marshal(map[string]any{
"defaultEntry": map[string]string{
"videoUrl": "https://www.youtube.com/watch?v=test-main",
"posterUrl": "https://cdn.svc.plus/default-poster.png",
},
"overrides": []map[string]string{
{
"domain": "demo.svc.plus",
"videoUrl": "https://www.bilibili.com/video/BV1demo",
"posterUrl": "https://cdn.svc.plus/demo-poster.png",
},
},
})
req := httptest.NewRequest(http.MethodPut, "/api/auth/admin/homepage-video", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp := httptest.NewRecorder()
env.router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d (%s)", resp.Code, resp.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/auth/admin/homepage-video", nil)
req.Header.Set("Authorization", "Bearer "+env.adminToken)
resp = httptest.NewRecorder()
env.router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d (%s)", resp.Code, resp.Body.String())
}
var payload struct {
DefaultEntry struct {
VideoURL string `json:"videoUrl"`
} `json:"defaultEntry"`
Overrides []struct {
Domain string `json:"domain"`
} `json:"overrides"`
}
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.DefaultEntry.VideoURL != "https://www.youtube.com/watch?v=test-main" {
t.Fatalf("unexpected default video url: %q", payload.DefaultEntry.VideoURL)
}
if len(payload.Overrides) != 1 || payload.Overrides[0].Domain != "demo.svc.plus" {
t.Fatalf("unexpected overrides payload: %+v", payload.Overrides)
}
}
func TestHomepageVideoAdminUnauthorized(t *testing.T) {
env := setupHomepageVideoTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/auth/admin/homepage-video", nil)
req.Header.Set("Authorization", "Bearer "+env.userToken)
resp := httptest.NewRecorder()
env.router.ServeHTTP(resp, req)
if resp.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d", resp.Code)
}
}

View File

@ -1293,6 +1293,7 @@ func openAdminSettingsDB(cfg config.Store) (*gorm.DB, func(context.Context) erro
if err := db.AutoMigrate(
&model.AdminSetting{},
&model.HomepageVideoSetting{},
&model.SandboxBinding{},
&model.Tenant{},
&model.TenantDomain{},

View File

@ -0,0 +1,29 @@
package model
import (
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// HomepageVideoSetting stores the homepage product demo media config.
type HomepageVideoSetting struct {
UUID string `gorm:"column:uuid;type:uuid;primaryKey"`
DomainKey string `gorm:"column:domain_key;type:text;not null;uniqueIndex:idx_homepage_video_domain_key"`
VideoURL string `gorm:"column:video_url;type:text;not null"`
PosterURL string `gorm:"column:poster_url;type:text;not null;default:''"`
UpdatedBy string `gorm:"column:updated_by;type:text;not null;default:''"`
CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;autoUpdateTime"`
}
func (HomepageVideoSetting) TableName() string { return "homepage_video_settings" }
func (setting *HomepageVideoSetting) BeforeCreate(tx *gorm.DB) error {
if strings.TrimSpace(setting.UUID) == "" {
setting.UUID = uuid.NewString()
}
return nil
}

View File

@ -0,0 +1,188 @@
package service
import (
"context"
"errors"
"slices"
"strings"
"account/internal/model"
"account/internal/store"
"gorm.io/gorm"
)
const homepageVideoDefaultDomainKey = "__default__"
var errHomepageVideoSettingsDBNotInitialized = errors.New("homepage video settings db not initialized")
type HomepageVideoEntry struct {
DomainKey string
VideoURL string
PosterURL string
}
type HomepageVideoSettings struct {
DefaultEntry HomepageVideoEntry
Overrides []HomepageVideoEntry
}
func defaultHomepageVideoSettings() HomepageVideoSettings {
return HomepageVideoSettings{
DefaultEntry: HomepageVideoEntry{
VideoURL: "https://www.youtube.com/watch?v=UW6DY6HQnmo",
},
Overrides: []HomepageVideoEntry{
{
DomainKey: "cn-www.svc.plus",
VideoURL: "https://www.bilibili.com/video/BV12DwazxEkL/?spm_id_from=333.1387.homepage.video_card.click&vd_source=e14d146f9a815c7d11e1a1fc23565ffd",
},
},
}
}
func GetHomepageVideoSettings(ctx context.Context) (HomepageVideoSettings, error) {
database := currentDB()
if database == nil {
return defaultHomepageVideoSettings(), nil
}
var rows []model.HomepageVideoSetting
if err := database.WithContext(ctx).Order("domain_key ASC").Find(&rows).Error; err != nil {
return HomepageVideoSettings{}, err
}
if len(rows) == 0 {
return defaultHomepageVideoSettings(), nil
}
result := HomepageVideoSettings{}
for _, row := range rows {
entry := HomepageVideoEntry{
DomainKey: normalizeHomepageVideoDomainKey(row.DomainKey),
VideoURL: strings.TrimSpace(row.VideoURL),
PosterURL: strings.TrimSpace(row.PosterURL),
}
if row.DomainKey == homepageVideoDefaultDomainKey {
result.DefaultEntry = entry
continue
}
result.Overrides = append(result.Overrides, entry)
}
if strings.TrimSpace(result.DefaultEntry.VideoURL) == "" {
result.DefaultEntry = defaultHomepageVideoSettings().DefaultEntry
}
slices.SortFunc(result.Overrides, func(left, right HomepageVideoEntry) int {
return strings.Compare(left.DomainKey, right.DomainKey)
})
return result, nil
}
func SaveHomepageVideoSettings(
ctx context.Context,
settings HomepageVideoSettings,
updatedBy string,
) (HomepageVideoSettings, error) {
database := currentDB()
if database == nil {
return HomepageVideoSettings{}, errHomepageVideoSettingsDBNotInitialized
}
normalized, err := normalizeHomepageVideoSettings(settings)
if err != nil {
return HomepageVideoSettings{}, err
}
rows := make([]model.HomepageVideoSetting, 0, 1+len(normalized.Overrides))
rows = append(rows, model.HomepageVideoSetting{
DomainKey: homepageVideoDefaultDomainKey,
VideoURL: normalized.DefaultEntry.VideoURL,
PosterURL: normalized.DefaultEntry.PosterURL,
UpdatedBy: strings.TrimSpace(updatedBy),
})
for _, item := range normalized.Overrides {
rows = append(rows, model.HomepageVideoSetting{
DomainKey: item.DomainKey,
VideoURL: item.VideoURL,
PosterURL: item.PosterURL,
UpdatedBy: strings.TrimSpace(updatedBy),
})
}
if err := database.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.HomepageVideoSetting{}).Error; err != nil {
return err
}
return tx.Create(&rows).Error
}); err != nil {
return HomepageVideoSettings{}, err
}
return normalized, nil
}
func ResolveHomepageVideoEntry(ctx context.Context, host string) (HomepageVideoEntry, error) {
settings, err := GetHomepageVideoSettings(ctx)
if err != nil {
return HomepageVideoEntry{}, err
}
normalizedHost := store.NormalizeHostname(host)
for _, item := range settings.Overrides {
if item.DomainKey == normalizedHost {
return item, nil
}
}
return settings.DefaultEntry, nil
}
func normalizeHomepageVideoSettings(settings HomepageVideoSettings) (HomepageVideoSettings, error) {
defaultVideoURL := strings.TrimSpace(settings.DefaultEntry.VideoURL)
if defaultVideoURL == "" {
return HomepageVideoSettings{}, errors.New("default videoUrl is required")
}
normalized := HomepageVideoSettings{
DefaultEntry: HomepageVideoEntry{
VideoURL: defaultVideoURL,
PosterURL: strings.TrimSpace(settings.DefaultEntry.PosterURL),
},
}
seen := map[string]struct{}{}
for _, item := range settings.Overrides {
domainKey := normalizeHomepageVideoDomainKey(item.DomainKey)
if domainKey == "" {
return HomepageVideoSettings{}, errors.New("override domain is required")
}
if _, exists := seen[domainKey]; exists {
return HomepageVideoSettings{}, errors.New("override domain must be unique")
}
videoURL := strings.TrimSpace(item.VideoURL)
if videoURL == "" {
return HomepageVideoSettings{}, errors.New("override videoUrl is required")
}
seen[domainKey] = struct{}{}
normalized.Overrides = append(normalized.Overrides, HomepageVideoEntry{
DomainKey: domainKey,
VideoURL: videoURL,
PosterURL: strings.TrimSpace(item.PosterURL),
})
}
slices.SortFunc(normalized.Overrides, func(left, right HomepageVideoEntry) int {
return strings.Compare(left.DomainKey, right.DomainKey)
})
return normalized, nil
}
func normalizeHomepageVideoDomainKey(value string) string {
normalized := store.NormalizeHostname(value)
if normalized == homepageVideoDefaultDomainKey {
return ""
}
return normalized
}