增加违禁词管理 增加多选操作
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user