增加违禁词管理 增加多选操作

This commit is contained in:
2026-07-05 03:00:49 +08:00
parent 4d5b49c2a9
commit 10d0732f8c
24 changed files with 836 additions and 35 deletions
+78 -2
View File
@@ -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 {
+47
View File
@@ -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