diff --git a/api/api.go b/api/api.go index 98df9b8..640d308 100644 --- a/api/api.go +++ b/api/api.go @@ -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) diff --git a/api/homepage_video.go b/api/homepage_video.go new file mode 100644 index 0000000..dee3790 --- /dev/null +++ b/api/homepage_video.go @@ -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, + }) +} diff --git a/api/homepage_video_test.go b/api/homepage_video_test.go new file mode 100644 index 0000000..de9bc54 --- /dev/null +++ b/api/homepage_video_test.go @@ -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) + } +} diff --git a/cmd/accountsvc/main.go b/cmd/accountsvc/main.go index 644a14c..fc16655 100644 --- a/cmd/accountsvc/main.go +++ b/cmd/accountsvc/main.go @@ -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{}, diff --git a/internal/model/homepage_video_setting.go b/internal/model/homepage_video_setting.go new file mode 100644 index 0000000..433ed0a --- /dev/null +++ b/internal/model/homepage_video_setting.go @@ -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 +} diff --git a/internal/service/homepage_video_settings.go b/internal/service/homepage_video_settings.go new file mode 100644 index 0000000..3fccdaf --- /dev/null +++ b/internal/service/homepage_video_settings.go @@ -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 +}