diff --git a/backend/internal/bootstrap/app.go b/backend/internal/bootstrap/app.go index 3d91b21..b11190d 100644 --- a/backend/internal/bootstrap/app.go +++ b/backend/internal/bootstrap/app.go @@ -126,7 +126,7 @@ func NewApp(ctx context.Context) (*App, error) { v1Svc := service.NewV1Service(cfg, modelRepo, userRepo, eventRepo, tokenRepo, siteRepo, cgroupRepo, concSvc, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient, customClient, rustfsClient) siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle) showcaseSvc := service.NewShowcaseService(showcaseRepo) - adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient) + adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo) adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo) cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo) apiKeySvc := service.NewAPIKeyService(apiKeyRepo) @@ -135,6 +135,8 @@ func NewApp(ctx context.Context) (*App, error) { // Enable refresh-then-retry on a mid-request Adobe 401 (re-mint access token // from the cookie). Wired post-construction to avoid a ctor init cycle. v1Svc.SetRefresh(refreshSvc) + bannedWordRepo := repo.NewBannedWordRepository(db) + v1Svc.SetBannedWords(bannedWordRepo) userGenSvc := service.NewUserGenerationService(v1Svc, eventRepo, userRepo, modelRepo) engine := router.New(cfg, authSvc, router.Handlers{ @@ -155,6 +157,7 @@ func NewApp(ctx context.Context) (*App, error) { ConcGroups: handler.NewConcurrencyGroupHandler(cgroupSvc), Announcement: handler.NewAnnouncementHandler(announcementSvc), Payment: handler.NewPaymentHandler(paymentSvc), + BannedWords: handler.NewBannedWordsHandler(bannedWordRepo), }) // Background self-healing sweep (quota recovery, cookie refresh, stale-pending diff --git a/backend/internal/http/handler/admin_read.go b/backend/internal/http/handler/admin_read.go index 6f9b50c..75e5925 100644 --- a/backend/internal/http/handler/admin_read.go +++ b/backend/internal/http/handler/admin_read.go @@ -29,6 +29,7 @@ func (h *AdminReadHandler) Users(c *gin.Context) { for _, user := range users { row := userPublic(user) row["generation_count"] = user.GenerationCount + row["banned_word_hits"] = user.BannedWordHits out = append(out, row) } c.JSON(http.StatusOK, gin.H{"data": out, "stats": stats}) @@ -56,7 +57,7 @@ func (h *AdminReadHandler) Logs(c *gin.Context) { } } - items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", "", c.Query("source"), false) + items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, nil, since, "", "", c.Query("source"), false, false, false) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) return @@ -158,6 +159,16 @@ func (h *AdminReadHandler) Invites(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": items, "stats": stats}) } +// DeleteImage removes one generated file (plus derived stills) and blanks the +// log rows referencing it. Admin 图片管理 delete; ?name= is the storage key. +func (h *AdminReadHandler) DeleteImage(c *gin.Context) { + if err := h.admin.DeleteFile(c.Request.Context(), c.Query("name")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + func (h *AdminReadHandler) Providers(c *gin.Context) { items, err := h.admin.Providers(c.Request.Context()) if err != nil { diff --git a/backend/internal/http/handler/banned_words.go b/backend/internal/http/handler/banned_words.go new file mode 100644 index 0000000..30356b7 --- /dev/null +++ b/backend/internal/http/handler/banned_words.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + + "backend/internal/repo" + "github.com/gin-gonic/gin" +) + +// BannedWordsHandler — admin 违禁词管理: list / add / delete prompt blocklist +// entries. The generation path (V1Service.checkBannedPrompt) enforces them. +type BannedWordsHandler struct { + words *repo.BannedWordRepository +} + +func NewBannedWordsHandler(words *repo.BannedWordRepository) *BannedWordsHandler { + return &BannedWordsHandler{words: words} +} + +func (h *BannedWordsHandler) List(c *gin.Context) { + items, err := h.words.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load banned words"}) + return + } + out := make([]gin.H, 0, len(items)) + for _, w := range items { + out = append(out, gin.H{ + "id": w.ID, + "word": w.Word, + "hits": w.Hits, + "created_at": w.CreatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +func (h *BannedWordsHandler) Create(c *gin.Context) { + var body struct { + Word string `json:"word"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + item, err := h.words.Create(c.Request.Context(), body.Word) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": item.ID, "word": item.Word, "hits": item.Hits, "created_at": item.CreatedAt}}) +} + +func (h *BannedWordsHandler) Delete(c *gin.Context) { + n, err := h.words.Delete(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "delete failed"}) + return + } + if n == 0 { + c.JSON(http.StatusNotFound, gin.H{"detail": "not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 9218419..5bd7a60 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -38,6 +38,22 @@ func (h *UserGenerationHandler) MyImages(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": items}) } +// DeleteMyFile removes ONE of the caller's own generated files (plus its +// thumbnail) and blanks the log rows referencing it, so the 画图台 grid and +// 创作记录 gallery stop showing it. ?file= is the storage key (owner/name). +func (h *UserGenerationHandler) DeleteMyFile(c *gin.Context) { + user := currentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"}) + return + } + if err := h.admin.DeleteOwnedFile(c.Request.Context(), service.OwnerDir(user), c.Query("file")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + func (h *UserGenerationHandler) Generate(c *gin.Context) { user := currentUser(c) if user == nil { @@ -70,7 +86,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) { switch { case errors.Is(err, service.ErrUnknownModel): c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) - case errors.Is(err, service.ErrUnsupportedParams): + case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt): c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) case errors.Is(err, service.ErrInsufficientFunds): c.JSON(http.StatusPaymentRequired, gin.H{"detail": "积分不足"}) @@ -132,7 +148,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) { switch { case errors.Is(err, service.ErrUnknownModel): c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) - case errors.Is(err, service.ErrUnsupportedParams): + case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt): c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) case errors.Is(err, service.ErrProviderQuota): c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()}) @@ -206,7 +222,14 @@ func (h *UserGenerationHandler) Logs(c *gin.Context) { // rows with real media (success + stored file), not failed/pending events. hasFile := c.Query("has_file") == "1" || c.Query("has_file") == "true" - items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, statuses, nil, userID, excludeSource, source, hasFile) + // Media views hide homepage showcase files — those belong to the public + // landing page, not to the caller's personal works. Galleries imply it via + // has_file; the 画图台 grid opts in with exclude_showcase=1. + excludeShowcase := hasFile || c.Query("exclude_showcase") == "1" + // media=1 (画图台 grid): only pending rows or rows with a stored file, so a + // deleted work's blanked row doesn't consume one of the grid's slots. + mediaOnly := c.Query("media") == "1" + items, total, stats, err := h.admin.Logs(c.Request.Context(), limit, offset, kind, status, statuses, nil, userID, excludeSource, source, hasFile, excludeShowcase, mediaOnly) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load logs"}) return diff --git a/backend/internal/http/handler/v1.go b/backend/internal/http/handler/v1.go index 4bd0827..f3fcbe4 100644 --- a/backend/internal/http/handler/v1.go +++ b/backend/internal/http/handler/v1.go @@ -316,7 +316,7 @@ func (h *V1Handler) writeV1Error(c *gin.Context, err error, payload map[string]a switch { case errors.Is(err, service.ErrUnknownModel): c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) - case errors.Is(err, service.ErrUnsupportedParams): + case errors.Is(err, service.ErrUnsupportedParams), errors.Is(err, service.ErrBannedPrompt): c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) case errors.Is(err, service.ErrInsufficientFunds): c.JSON(http.StatusPaymentRequired, gin.H{"detail": err.Error()}) diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index 9fb91c9..792341f 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -28,6 +28,7 @@ type Handlers struct { ConcGroups *handler.ConcurrencyGroupHandler Announcement *handler.AnnouncementHandler Payment *handler.PaymentHandler + BannedWords *handler.BannedWordsHandler } func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.Engine { @@ -87,6 +88,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin. userAuthed.POST("/test", handlers.UserGen.Test) userAuthed.GET("/jobs/mine", handlers.UserGen.MyJobs) userAuthed.GET("/my-images", handlers.UserGen.MyImages) + userAuthed.DELETE("/my-files", handlers.UserGen.DeleteMyFile) userAuthed.GET("/announcement", handlers.Announcement.Get) userAuthed.POST("/announcement/seen", handlers.Announcement.MarkSeen) userAuthed.GET("/pay/config", handlers.Payment.Config) @@ -137,6 +139,10 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin. authed.GET("/accounts/:pool/:id/email", handlers.ProviderAdmin.AccountEmail) authed.GET("/providers", handlers.AdminRead.Providers) authed.GET("/images", handlers.AdminRead.Images) + authed.DELETE("/images", handlers.AdminRead.DeleteImage) + authed.GET("/banned-words", handlers.BannedWords.List) + authed.POST("/banned-words", handlers.BannedWords.Create) + authed.DELETE("/banned-words/:id", handlers.BannedWords.Delete) authed.GET("/refresh/profiles", handlers.ProviderAdmin.RefreshProfiles) authed.POST("/refresh/profiles/:profile_id/refresh-now", handlers.ProviderAdmin.RefreshNow) authed.PATCH("/refresh/profiles/:profile_id", handlers.ProviderAdmin.RefreshUpdate) diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index f2ec7ea..ec37cf3 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -26,6 +26,7 @@ type User struct { CheckinLast string `gorm:"size:32"` CheckinStreak int `gorm:"not null;default:0"` GenerationCount int64 `gorm:"not null;default:0"` + BannedWordHits int64 `gorm:"not null;default:0"` // 提示词命中违禁词被拦截的累计次数 LastLoginAt *time.Time LastLoginIP string `gorm:"size:128"` CreatedAt time.Time @@ -33,6 +34,17 @@ type User struct { APIKeys []APIKey `gorm:"foreignKey:UserID"` } +// BannedWord is an admin-managed prompt blocklist entry. Generation requests +// whose prompt contains Word (case-insensitive substring) are rejected before +// reaching any provider; Hits counts how many requests each word blocked. +type BannedWord struct { + ID string `gorm:"primaryKey;size:32"` + Word string `gorm:"size:255;uniqueIndex;not null"` + Hits int64 `gorm:"not null;default:0"` + CreatedAt time.Time + UpdatedAt time.Time +} + type APIKey struct { ID string `gorm:"primaryKey;size:32"` UserID string `gorm:"size:32;index;not null"` @@ -206,6 +218,7 @@ type SiteSetting struct { func AutoMigrateModels() []any { return []any{ &User{}, + &BannedWord{}, &APIKey{}, &ShowcaseItem{}, &EventLog{}, diff --git a/backend/internal/repo/banned_word_repo.go b/backend/internal/repo/banned_word_repo.go new file mode 100644 index 0000000..7a2b113 --- /dev/null +++ b/backend/internal/repo/banned_word_repo.go @@ -0,0 +1,59 @@ +package repo + +import ( + "context" + "errors" + "strings" + "time" + + "backend/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type BannedWordRepository struct { + db *gorm.DB +} + +func NewBannedWordRepository(db *gorm.DB) *BannedWordRepository { + return &BannedWordRepository{db: db} +} + +func (r *BannedWordRepository) List(ctx context.Context) ([]model.BannedWord, error) { + var items []model.BannedWord + err := r.db.WithContext(ctx).Order("hits DESC, created_at DESC").Find(&items).Error + return items, err +} + +func (r *BannedWordRepository) Create(ctx context.Context, word string) (*model.BannedWord, error) { + word = strings.TrimSpace(word) + if word == "" { + return nil, errors.New("违禁词不能为空") + } + item := &model.BannedWord{ + ID: strings.ReplaceAll(uuid.NewString(), "-", "")[:32], + Word: word, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := r.db.WithContext(ctx).Create(item).Error; err != nil { + return nil, errors.New("添加失败(可能已存在)") + } + return item, nil +} + +func (r *BannedWordRepository) Delete(ctx context.Context, id string) (int64, error) { + res := r.db.WithContext(ctx).Delete(&model.BannedWord{}, "id = ?", id) + return res.RowsAffected, res.Error +} + +// RecordHit bumps the word's block counter and, when userID is set, the user's +// 违禁词触发次数 shown on the admin users table. Best-effort bookkeeping. +func (r *BannedWordRepository) RecordHit(ctx context.Context, wordID, userID string) { + _ = r.db.WithContext(ctx).Model(&model.BannedWord{}).Where("id = ?", wordID). + UpdateColumn("hits", gorm.Expr("hits + 1")).Error + if userID != "" { + _ = r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID). + UpdateColumn("banned_word_hits", gorm.Expr("banned_word_hits + 1")).Error + } +} diff --git a/backend/internal/repo/event_repo.go b/backend/internal/repo/event_repo.go index 2c01f89..10831fe 100644 --- a/backend/internal/repo/event_repo.go +++ b/backend/internal/repo/event_repo.go @@ -24,7 +24,9 @@ type EventListFilter struct { UserID string ExcludeSource string // when set, omit rows with this source (e.g. hide API-key "v1" usage from the customer logs page) Source string // when set, keep ONLY rows with this source (admin 来源 filter): "v1" (API key) / "user" (前台) / "admin" (测试模型) - HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media) + HasFile bool // when true, keep ONLY rows with a non-empty file (the 创作记录 gallery — paginates over real media) + ExcludeFiles []string // when set, omit rows whose file is in this list (e.g. hide homepage showcase media from user galleries) + MediaOnly bool // when true, keep only rows that are pending or have a stored file — the 画图台 grid, so deleted works don't eat a slot } type EventStats struct { @@ -66,6 +68,12 @@ func (r *EventRepository) List(ctx context.Context, filter EventListFilter) ([]m if filter.HasFile { q = q.Where("file <> ''") } + if len(filter.ExcludeFiles) > 0 { + q = q.Where("file NOT IN ?", filter.ExcludeFiles) + } + if filter.MediaOnly { + q = q.Where("(status = 'pending' OR file <> '')") + } var total int64 if err := q.Count(&total).Error; err != nil { diff --git a/backend/internal/service/admin_read.go b/backend/internal/service/admin_read.go index 2033ba9..d49829d 100644 --- a/backend/internal/service/admin_read.go +++ b/backend/internal/service/admin_read.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "sort" "strings" "time" @@ -21,9 +22,10 @@ type AdminReadService struct { tokens *repo.TokenRepository cdks *repo.CDKRepository store *storage.Client + showcase *repo.ShowcaseRepository } -func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client) *AdminReadService { +func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models *repo.ModelRepository, events *repo.EventRepository, settings *repo.SiteSettingRepository, tokens *repo.TokenRepository, cdks *repo.CDKRepository, store *storage.Client, showcase *repo.ShowcaseRepository) *AdminReadService { return &AdminReadService{ cfg: cfg, users: users, @@ -33,9 +35,28 @@ func NewAdminReadService(cfg *config.Config, users *repo.UserRepository, models tokens: tokens, cdks: cdks, store: store, + showcase: showcase, } } +// showcaseFileList returns the homepage showcase image keys (no leading slash). +// User-facing galleries and the admin image manager hide these files — they +// belong to the public landing page, not to anyone's personal works. +func (s *AdminReadService) showcaseFileList(ctx context.Context) []string { + if s.showcase == nil { + return nil + } + set, err := s.showcase.PublicFileSet(ctx) + if err != nil || len(set) == 0 { + return nil + } + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + return out +} + func (s *AdminReadService) Users(ctx context.Context) ([]model.User, map[string]any, error) { users, err := s.users.List(ctx) if err != nil { @@ -91,7 +112,11 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er return out, nil } -func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID, excludeSource, source string, hasFile bool) ([]model.EventLog, int64, *repo.EventStats, error) { +func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, status string, statuses []string, since *time.Time, userID, excludeSource, source string, hasFile, excludeShowcase, mediaOnly bool) ([]model.EventLog, int64, *repo.EventStats, error) { + var excludeFiles []string + if excludeShowcase { + excludeFiles = s.showcaseFileList(ctx) + } items, total, err := s.events.List(ctx, repo.EventListFilter{ Limit: limit, Offset: offset, @@ -103,6 +128,8 @@ func (s *AdminReadService) Logs(ctx context.Context, limit, offset int, kind, st ExcludeSource: excludeSource, Source: source, HasFile: hasFile, + ExcludeFiles: excludeFiles, + MediaOnly: mediaOnly, }) if err != nil { return nil, 0, nil, err @@ -406,8 +433,19 @@ func (s *AdminReadService) Images(ctx context.Context, limit, offset int, kind s if err != nil { return nil, 0, nil, err } + // Homepage showcase media never shows in the image manager — it's public + // landing-page content, managed on the 首页内容 page instead. + pinned := map[string]struct{}{} + if s.showcase != nil { + if set, perr := s.showcase.PublicFileSet(ctx); perr == nil { + pinned = set + } + } filtered := make([]generatedFile, 0, len(allFiles)) for _, item := range allFiles { + if _, ok := pinned[strings.TrimLeft(item.Name, "/")]; ok { + continue + } if kind == "" || item.Kind == kind { filtered = append(filtered, item) } @@ -523,6 +561,44 @@ func (s *AdminReadService) RecentImagesOwned(ctx context.Context, owner string, return out, nil } +// DeleteOwnedFile removes a generated media object (and its thumbnail) that +// lives under the given owner directory, then blanks the file reference on the +// matching log rows so galleries and the 画图台 grid stop showing it. The owner +// prefix check keeps a user from deleting anyone else's files. +func (s *AdminReadService) DeleteOwnedFile(ctx context.Context, owner, rel string) error { + owner = strings.TrimSpace(owner) + rel = strings.TrimLeft(strings.TrimSpace(rel), "/") + if owner == "" { + return errors.New("invalid file") + } + if !strings.HasPrefix(rel, owner+"/") { + return errors.New("file not owned by caller") + } + return s.DeleteFile(ctx, rel) +} + +// DeleteFile removes any generated media object (and its derived stills) and +// blanks the log rows referencing it. Admin 图片管理 delete — no owner check. +func (s *AdminReadService) DeleteFile(ctx context.Context, rel string) error { + rel = strings.TrimLeft(strings.TrimSpace(rel), "/") + if rel == "" || strings.Contains(rel, "..") { + return errors.New("invalid file") + } + if s.store == nil || !s.store.Configured() { + return errors.New("storage not configured") + } + if err := s.store.Delete(ctx, rel); err != nil { + return err + } + // Best-effort derived stills; old files may not have them. + _ = s.store.Delete(ctx, ThumbKey(rel)) + _ = s.store.Delete(ctx, LastFrameKey(rel)) + if _, err := s.events.ClearFiles(ctx, []string{rel}); err != nil { + return err + } + return nil +} + func (s *AdminReadService) eventIndexByFile(ctx context.Context) (map[string]model.EventLog, error) { items, err := s.events.RecentByFile(ctx, 10000) if err != nil { diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index efdd0b2..2e261c3 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -37,6 +37,7 @@ var ( ErrInvalidAPIKey = errors.New("invalid api key") ErrUnknownModel = errors.New("unknown model") ErrUnsupportedParams = errors.New("unsupported or unpriced parameters for this model") + ErrBannedPrompt = errors.New("prompt contains banned content") ErrInsufficientFunds = errors.New("insufficient credits") ErrGenerationPending = errors.New("generation executor not implemented yet") ErrProviderAuth = errors.New("provider token invalid or expired") @@ -83,6 +84,9 @@ type V1Service struct { // 401 mid-flight (set via SetRefresh — wired after construction to avoid an // init cycle). nil for deployments without cookie refresh. refresh *RefreshProfileService + // banned is the admin-managed prompt blocklist (set via SetBannedWords). + // nil disables the check. + banned *repo.BannedWordRepository // tokenCursors holds one strict round-robin cursor per pool (key: pool name, // value: *uint64). Each pick advances the pool's cursor by one so accounts @@ -244,6 +248,36 @@ func (s *V1Service) Inflight() *InflightRegistry { return s.inflight } // without reordering). Enables refresh-then-retry on a mid-request 401. func (s *V1Service) SetRefresh(r *RefreshProfileService) { s.refresh = r } +// SetBannedWords wires the prompt blocklist in after construction. +func (s *V1Service) SetBannedWords(r *repo.BannedWordRepository) { s.banned = r } + +// checkBannedPrompt rejects the request when the prompt contains any banned +// word (case-insensitive substring). A hit bumps the word's counter and the +// user's 违禁词触发次数 before rejecting. +func (s *V1Service) checkBannedPrompt(ctx context.Context, principal *APIPrincipal, prompt string) error { + if s.banned == nil || strings.TrimSpace(prompt) == "" { + return nil + } + words, err := s.banned.List(ctx) + if err != nil || len(words) == 0 { + return nil + } + lower := strings.ToLower(prompt) + for _, w := range words { + term := strings.ToLower(strings.TrimSpace(w.Word)) + if term == "" || !strings.Contains(lower, term) { + continue + } + userID := "" + if principal != nil && principal.User != nil { + userID = principal.User.ID + } + s.banned.RecordHit(ctx, w.ID, userID) + return fmt.Errorf("%w: banned word \"%s\"", ErrBannedPrompt, w.Word) + } + return nil +} + // refreshAdobeToken re-mints an Adobe account's access token from its cookie // (RefreshNow) and returns the updated row. Used to retry a 401 with a fresh // token instead of replaying the stale one. Returns false if refresh is @@ -339,6 +373,11 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri // generation from running on for minutes and surfacing a late "success" on an // already-abandoned event. ctx = context.WithoutCancel(ctx) + if source != "admin" { + if err := s.checkBannedPrompt(ctx, principal, in.Prompt); err != nil { + return nil, err + } + } genCtx, cancel := context.WithTimeout(ctx, 8*time.Minute) defer cancel() @@ -577,6 +616,11 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri // context (12-min backstop — video polls up to 10 min — and registered so the // maintenance sweep can cancel a stuck render when it abandons the row). ctx = context.WithoutCancel(ctx) + if source != "admin" { + if err := s.checkBannedPrompt(ctx, principal, in.Prompt); err != nil { + return nil, err + } + } genCtx, cancel := context.WithTimeout(ctx, 12*time.Minute) defer cancel() @@ -712,6 +756,9 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri // the background, and returns the OpenAI video object (status "queued"). func (s *V1Service) StartVideoJob(ctx context.Context, principal *APIPrincipal, in V1VideoRequest) (map[string]any, error) { ctx = context.WithoutCancel(ctx) + if err := s.checkBannedPrompt(ctx, principal, in.Prompt); err != nil { + return nil, err + } modelItem, resolution, aspectRatio, duration, price, err := s.prepareVideo(ctx, principal, in, true) if err != nil { return nil, err diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 57a7155..487de1d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "vivid-frontend", "version": "0.1.0", "dependencies": { + "fflate": "^0.8.2", "marked": "^18.0.5", "qrcode": "^1.5.4", "vue": "^3.5.13", @@ -1497,6 +1498,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9b93cad..4f39242 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "fflate": "^0.8.2", "marked": "^18.0.5", "qrcode": "^1.5.4", "vue": "^3.5.13", diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 686b73f..a24bf38 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -21,6 +21,7 @@ const PATHS = { chevron: '', check: '', shield: '', + ban: '', receipt: '', } diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 5fd032a..086d5ff 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -14,6 +14,7 @@ const tabs = [ { label: '账号管理', to: '/admin/accounts', icon: 'plug' }, { label: '用户管理', to: '/admin/users', icon: 'accounts' }, { label: '并发分组', to: '/admin/concurrency', icon: 'shield' }, + { label: '违禁词管理', to: '/admin/banned-words', icon: 'ban' }, { label: '订单管理', to: '/admin/orders', icon: 'receipt' }, { label: '兑换码管理', to: '/admin/cdks', icon: 'spark' }, { label: '邀请日志', to: '/admin/invites', icon: 'accounts' }, diff --git a/frontend/src/main.js b/frontend/src/main.js index ef9c306..b09c1e3 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -23,6 +23,7 @@ import ModelsView from './views/ModelsView.vue' import AccountsView from './views/AccountsView.vue' import UsersView from './views/UsersView.vue' import ConcurrencyView from './views/ConcurrencyView.vue' +import BannedWordsView from './views/BannedWordsView.vue' import CdksView from './views/CdksView.vue' import InvitesAdminView from './views/InvitesAdminView.vue' import ImagesView from './views/ImagesView.vue' @@ -56,6 +57,7 @@ const routes = [ { path: 'accounts', component: AccountsView, meta: { label: '账号管理' } }, { path: 'users', component: UsersView, meta: { label: '用户管理' } }, { path: 'concurrency', component: ConcurrencyView, meta: { label: '并发分组' } }, + { path: 'banned-words', component: BannedWordsView, meta: { label: '违禁词管理' } }, { path: 'orders', component: AdminOrdersView, meta: { label: '订单管理' } }, { path: 'cdks', component: CdksView, meta: { label: '兑换码管理' } }, { path: 'invites', component: InvitesAdminView, meta: { label: '邀请日志' } }, diff --git a/frontend/src/views/AccountsView.vue b/frontend/src/views/AccountsView.vue index bd6f967..6b0d344 100644 --- a/frontend/src/views/AccountsView.vue +++ b/frontend/src/views/AccountsView.vue @@ -279,13 +279,13 @@ function toggleSelect(id) { s.has(id) ? s.delete(id) : s.add(id) selected.value = s } -// Header checkbox controls the whole filtered set (not just the visible page). +// Header checkbox selects/deselects the CURRENT PAGE only. const allSelected = computed(() => - filtered.value.length > 0 && filtered.value.every((a) => selected.value.has(a.id))) + pagedItems.value.length > 0 && pagedItems.value.every((a) => selected.value.has(a.id))) function toggleSelectAll() { const s = new Set(selected.value) - if (allSelected.value) filtered.value.forEach((a) => s.delete(a.id)) - else filtered.value.forEach((a) => s.add(a.id)) + if (allSelected.value) pagedItems.value.forEach((a) => s.delete(a.id)) + else pagedItems.value.forEach((a) => s.add(a.id)) selected.value = s } async function deleteSelected() { diff --git a/frontend/src/views/BannedWordsView.vue b/frontend/src/views/BannedWordsView.vue new file mode 100644 index 0000000..33bdf57 --- /dev/null +++ b/frontend/src/views/BannedWordsView.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/views/CdksView.vue b/frontend/src/views/CdksView.vue index 802751d..c112a1e 100644 --- a/frontend/src/views/CdksView.vue +++ b/frontend/src/views/CdksView.vue @@ -75,12 +75,13 @@ function toggleSelect(code) { s.has(code) ? s.delete(code) : s.add(code) selected.value = s } +// Header checkbox selects/deselects the CURRENT PAGE only. const allSelected = computed(() => - filtered.value.length > 0 && filtered.value.every((c) => selected.value.has(c.code))) + pagedItems.value.length > 0 && pagedItems.value.every((c) => selected.value.has(c.code))) function toggleSelectAll() { const s = new Set(selected.value) - if (allSelected.value) filtered.value.forEach((c) => s.delete(c.code)) - else filtered.value.forEach((c) => s.add(c.code)) + if (allSelected.value) pagedItems.value.forEach((c) => s.delete(c.code)) + else pagedItems.value.forEach((c) => s.add(c.code)) selected.value = s } async function delSelected() { diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index c27d9c0..8e9694a 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -234,8 +234,9 @@ function useExample(ex) {
- + +
{{ w.title }}
diff --git a/frontend/src/views/ImagesView.vue b/frontend/src/views/ImagesView.vue index 1002aba..2232afd 100644 --- a/frontend/src/views/ImagesView.vue +++ b/frontend/src/views/ImagesView.vue @@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { api, generatedUrl, thumbUrl } from '../api' import { fmtTs, fmtSize } from '../utils/format' import { copyText } from '../utils/clipboard' +import { zipSync } from 'fflate' import Icon from '../components/Icon.vue' import MediaLightbox from '../components/MediaLightbox.vue' @@ -38,6 +39,92 @@ async function load() { loading.value = false } +// Admin delete: remove the file (+derived stills) from storage, then reload +// so pagination and the KPI strip stay accurate. +async function deleteFile(f) { + if (!f || !f.name) return + if (!confirm('确定删除这个文件?删除后不可恢复')) return + const r = await api('/images?name=' + encodeURIComponent(f.name), { method: 'DELETE' }) + flash(r.ok ? '已删除' : (r.data?.detail || '删除失败')) + if (r.ok) load() +} + +// multi-select (keyed by file name) — bulk delete/download from the toolbar. +const picked = ref(new Set()) +function togglePick(f) { + const s = new Set(picked.value) + s.has(f.name) ? s.delete(f.name) : s.add(f.name) + picked.value = s +} +const pageAllPicked = computed(() => + items.value.length > 0 && items.value.every((f) => picked.value.has(f.name))) +function togglePickAll() { + const s = new Set(picked.value) + if (pageAllPicked.value) items.value.forEach((f) => s.delete(f.name)) + else items.value.forEach((f) => s.add(f.name)) + picked.value = s +} +async function deletePicked() { + const names = [...picked.value] + if (!names.length) return + if (!confirm(`确定删除选中的 ${names.length} 个文件?删除后不可恢复`)) return + let ok = 0 + for (const n of names) { + const r = await api('/images?name=' + encodeURIComponent(n), { method: 'DELETE' }) + if (r.ok) ok++ + } + picked.value = new Set() + flash(`已删除 ${ok} 个`) + load() +} +// Single pick → direct file download; multiple → bundle into one zip. +const zipping = ref(false) +async function downloadPicked() { + const names = [...picked.value] + if (!names.length) return + if (names.length === 1) { + const a = document.createElement('a') + a.href = generatedUrl(names[0]) + a.download = names[0].split('/').pop() + document.body.appendChild(a) + a.click() + a.remove() + return + } + zipping.value = true + flash('打包中…') + try { + // Fetch concurrently (10 at a time) so large batches pack fast. + const bufs = [] + let next = 0 + await Promise.all(Array.from({ length: Math.min(10, names.length) }, async () => { + while (next < names.length) { + const i = next++ + bufs[i] = await (await fetch(generatedUrl(names[i]))).arrayBuffer() + } + })) + const entries = {} + names.forEach((n, i) => { + let name = n.split('/').pop() + while (entries[name]) name = '_' + name + entries[name] = [new Uint8Array(bufs[i]), { level: 0 }] + }) + const zipped = zipSync(entries) + const url = URL.createObjectURL(new Blob([zipped], { type: 'application/zip' })) + const a = document.createElement('a') + a.href = url + a.download = `图片-${names.length}个-${Date.now()}.zip` + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 30000) + flash('已打包下载') + } catch { + flash('打包失败') + } + zipping.value = false +} + function absUrl(name) { const u = generatedUrl(name) return u.startsWith('http') ? u : location.origin + u @@ -148,9 +235,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
- +
+ + + +
@@ -186,11 +286,17 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
- - - {{ f.kind === 'video' ? '视频' : '图像' }} - + +
+ + + {{ f.kind === 'video' ? '视频' : '图像' }} + +
@@ -202,6 +308,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKey)) class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center"> +
@@ -304,4 +414,29 @@ html.dark .fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); } .pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); } .pg-on { background: rgb(15 23 42); color: white; box-shadow: none; } html.dark .pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); } + +.btn-soft.danger { + color: rgb(253 164 175); + background: rgb(244 63 94 / 0.12); + box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); +} +.btn-soft.danger:hover { + color: white; + background: rgb(244 63 94 / 0.25); +} + +/* card select toggle — always visible rounded-square check button */ +.pick { + width: 1.4rem; height: 1.4rem; border-radius: 0.375rem; + display: inline-flex; align-items: center; justify-content: center; + color: rgb(255 255 255 / 0.85); + background: rgb(0 0 0 / 0.45); + box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.75); + transition: background 0.15s, box-shadow 0.15s; +} +.pick svg { opacity: 0; transition: opacity 0.15s; } +.pick:hover { background: rgb(0 0 0 / 0.65); } +.pick:hover svg { opacity: 0.6; } +.pick-on { background: rgb(217 70 239); box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.9); } +.pick-on svg { opacity: 1; } diff --git a/frontend/src/views/PlaygroundView.vue b/frontend/src/views/PlaygroundView.vue index 1818f25..d0ce0f5 100644 --- a/frontend/src/views/PlaygroundView.vue +++ b/frontend/src/views/PlaygroundView.vue @@ -396,7 +396,7 @@ let prevPending = 0 async function loadHistory() { // Server-side filter: status IN (pending, success), newest 12 — exactly the // rows the grid shows, in one query (no client over-fetch). - const r = await api('/logs?limit=10&statuses=pending,success&source=user') + const r = await api('/logs?limit=10&statuses=pending,success&source=user&exclude_showcase=1&media=1') if (!r.ok) return history.value = (r.data?.data || []) .filter((e) => e.status === 'pending' || e.file) @@ -423,6 +423,22 @@ async function loadHistory() { prevPending = serverPending.size } +// Delete one of my works: remove the stored file (+thumb) server-side, then +// drop the card locally so it disappears before the next history poll. +async function deleteItem(item) { + if (!item || !item.url) return + if (!confirm('确定删除这个作品?删除后不可恢复')) return + const rel = (item.url || '').split('?')[0].split('/images/').pop() + const r = await api('/my-files?file=' + encodeURIComponent(rel), { method: 'DELETE' }) + if (r.ok) { + tasks.value = tasks.value.filter((t) => t.id !== item.id) + history.value = history.value.filter((h) => h.id !== item.id) + flash('已删除') + } else { + flash(r.data?.detail || '删除失败') + } +} + // Click a generated IMAGE → use it as a reference. Single-ref model: replace the // existing ref. Multi-ref: append if there's room, else replace the last one. function useAsRef(item) { @@ -726,6 +742,10 @@ onUnmounted(() => { class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center"> +
(toast.value = ''), 1500) + if (r.ok) load() +} +// multi-select (keyed by file path) — bulk delete/download from the filter bar. +const picked = ref(new Set()) +function togglePick(e) { + if (!e.file) return + const s = new Set(picked.value) + s.has(e.file) ? s.delete(e.file) : s.add(e.file) + picked.value = s +} +const pageAllPicked = computed(() => { + const files = filtered.value.filter((e) => e.status === 'success' && e.file) + return files.length > 0 && files.every((e) => picked.value.has(e.file)) +}) +function togglePickAll() { + const s = new Set(picked.value) + const files = filtered.value.filter((e) => e.status === 'success' && e.file) + if (pageAllPicked.value) files.forEach((e) => s.delete(e.file)) + else files.forEach((e) => s.add(e.file)) + picked.value = s +} +async function deletePicked() { + const files = [...picked.value] + if (!files.length) return + if (!confirm(`确定删除选中的 ${files.length} 个作品?删除后不可恢复`)) return + let ok = 0 + for (const f of files) { + const r = await api('/my-files?file=' + encodeURIComponent(f), { method: 'DELETE' }) + if (r.ok) ok++ + } + picked.value = new Set() + toast.value = `已删除 ${ok} 个` + setTimeout(() => (toast.value = ''), 1500) + load() +} +// Single pick → direct file download; multiple → bundle into one zip. +const zipping = ref(false) +async function downloadPicked() { + const files = [...picked.value] + if (!files.length) return + if (files.length === 1) { + const a = document.createElement('a') + a.href = generatedUrl(files[0]) + a.download = files[0].split('/').pop() + document.body.appendChild(a) + a.click() + a.remove() + return + } + zipping.value = true + toast.value = '打包中…' + try { + // Fetch concurrently (10 at a time) so large batches pack fast. + const bufs = [] + let next = 0 + await Promise.all(Array.from({ length: Math.min(10, files.length) }, async () => { + while (next < files.length) { + const i = next++ + bufs[i] = await (await fetch(generatedUrl(files[i]))).arrayBuffer() + } + })) + const entries = {} + files.forEach((f, i) => { + let name = f.split('/').pop() + while (entries[name]) name = '_' + name + entries[name] = [new Uint8Array(bufs[i]), { level: 0 }] + }) + const zipped = zipSync(entries) + const url = URL.createObjectURL(new Blob([zipped], { type: 'application/zip' })) + const a = document.createElement('a') + a.href = url + a.download = `作品-${files.length}个-${Date.now()}.zip` + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 30000) + toast.value = '已打包下载' + } catch { + toast.value = '打包失败' + } + zipping.value = false + setTimeout(() => (toast.value = ''), 1500) +} + const lightbox = ref(null) // Videos whose first-frame thumbnail is missing (old videos) — fall back to // the muted
@@ -217,11 +323,18 @@ onUnmounted(() => {
- - - {{ e.kind === 'video' ? '视频' : '图像' }} - + +
+ + + {{ e.kind === 'video' ? '视频' : '图像' }} + +
{ class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center"> +
@@ -309,4 +426,19 @@ onUnmounted(() => { } .pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); } .pg-on { background: rgb(15 23 42); color: white; box-shadow: none; } + +/* card select toggle — always visible rounded-square check button */ +.pick { + width: 1.4rem; height: 1.4rem; border-radius: 0.375rem; + display: inline-flex; align-items: center; justify-content: center; + color: rgb(255 255 255 / 0.85); + background: rgb(0 0 0 / 0.45); + box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.75); + transition: background 0.15s, box-shadow 0.15s; +} +.pick svg { opacity: 0; transition: opacity 0.15s; } +.pick:hover { background: rgb(0 0 0 / 0.65); } +.pick:hover svg { opacity: 0.6; } +.pick-on { background: rgb(217 70 239); box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.9); } +.pick-on svg { opacity: 1; } diff --git a/frontend/src/views/UsersView.vue b/frontend/src/views/UsersView.vue index 690a681..347ddc5 100644 --- a/frontend/src/views/UsersView.vue +++ b/frontend/src/views/UsersView.vue @@ -169,12 +169,13 @@ function toggleSelect(id) { s.has(id) ? s.delete(id) : s.add(id) selected.value = s } +// Header checkbox selects/deselects the CURRENT PAGE only. const allSelected = computed(() => - filtered.value.length > 0 && filtered.value.every((u) => selected.value.has(u.id))) + pagedItems.value.length > 0 && pagedItems.value.every((u) => selected.value.has(u.id))) function toggleSelectAll() { const s = new Set(selected.value) - if (allSelected.value) filtered.value.forEach((u) => s.delete(u.id)) - else filtered.value.forEach((u) => s.add(u.id)) + if (allSelected.value) pagedItems.value.forEach((u) => s.delete(u.id)) + else pagedItems.value.forEach((u) => s.add(u.id)) selected.value = s } async function delSelected() { @@ -274,6 +275,7 @@ async function quickCredits(u, delta) { + @@ -294,6 +296,7 @@ async function quickCredits(u, delta) { 积分 累计充值 生图次数 + 违禁触发 注册时间 最近登录 登录 IP @@ -350,6 +353,10 @@ async function quickCredits(u, delta) { :class="u.generation_count > 0 ? 'text-white/85' : 'text-white/25'"> {{ (u.generation_count || 0).toLocaleString('en-US') }} + + {{ (u.banned_word_hits || 0).toLocaleString('en-US') }} +
{{ fmtDate(u.created_at) }}