diff --git a/backend/internal/http/handler/app_settings.go b/backend/internal/http/handler/app_settings.go index ab25afb..37feec9 100644 --- a/backend/internal/http/handler/app_settings.go +++ b/backend/internal/http/handler/app_settings.go @@ -223,6 +223,31 @@ func (h *AppSettingsHandler) CreditsPut(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true, "data": data}) } +// DeAIGet returns the 去AI特征 per-tier surcharge. Also mounted publicly (the +// 画图台 needs it to show the price next to the toggle) — prices aren't secret. +func (h *AppSettingsHandler) DeAIGet(c *gin.Context) { + data, err := h.settings.DeAI(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load deai settings"}) + return + } + c.JSON(http.StatusOK, data) +} + +func (h *AppSettingsHandler) DeAIPut(c *gin.Context) { + var body service.DeAISettings + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + data, err := h.settings.SaveDeAI(c.Request.Context(), body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "data": data}) +} + func (h *AppSettingsHandler) LogsGet(c *gin.Context) { data, err := h.settings.Logs(c.Request.Context()) if err != nil { diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index e65713e..45d4a4f 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -68,6 +68,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) { Resolution string `json:"resolution"` Duration string `json:"duration"` ReferenceImages []string `json:"reference_images"` + DeAI bool `json:"deai"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) @@ -81,6 +82,7 @@ func (h *UserGenerationHandler) Generate(c *gin.Context) { Resolution: body.Resolution, Duration: body.Duration, ReferenceImages: body.ReferenceImages, + DeAI: body.DeAI, }) if err != nil { switch { diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index e4fbd9b..04014e0 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -65,6 +65,7 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin. publicAdmin.GET("/video-presets", handlers.UserGen.VideoPresets) publicAdmin.GET("/catalog", handlers.UserGen.Catalog) publicAdmin.GET("/models", handlers.UserGen.Models) + publicAdmin.GET("/deai-pricing", handlers.AppSettings.DeAIGet) // 易支付 async notify — called server-to-server by the pay platform, no auth. publicAdmin.GET("/pay/notify", handlers.Payment.Notify) publicAdmin.POST("/pay/notify", handlers.Payment.Notify) @@ -177,6 +178,8 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin. settings.PUT("/credits", handlers.AppSettings.CreditsPut) settings.GET("/logs", handlers.AppSettings.LogsGet) settings.PUT("/logs", handlers.AppSettings.LogsPut) + settings.GET("/deai", handlers.AppSettings.DeAIGet) + settings.PUT("/deai", handlers.AppSettings.DeAIPut) settings.GET("/media", handlers.AppSettings.MediaGet) settings.PUT("/media", handlers.AppSettings.MediaPut) settings.GET("/announcement", handlers.Announcement.AdminGet) diff --git a/backend/internal/service/app_settings.go b/backend/internal/service/app_settings.go index eeb3d15..a5e4a2b 100644 --- a/backend/internal/service/app_settings.go +++ b/backend/internal/service/app_settings.go @@ -48,6 +48,14 @@ type CreditSettings struct { CDKRedeemEnabled bool `json:"cdk_redeem_enabled"` } +// DeAISettings is the per-tier surcharge (积分) for the 去AI特征 option on the +// 画图台 — charged on top of the model's image price when the toggle is on. +type DeAISettings struct { + Price1K int `json:"price_1k"` + Price2K int `json:"price_2k"` + Price4K int `json:"price_4k"` +} + type ProxySettings struct { Proxy string `json:"proxy"` } @@ -334,6 +342,44 @@ func (s *AppSettingsService) TestProxy(ctx context.Context, proxy string) (map[s }, nil } +func (s *AppSettingsService) DeAI(ctx context.Context) (*DeAISettings, error) { + p1Raw, err := s.settings.GetValue(ctx, "deai.price_1k") + if err != nil { + return nil, err + } + p2Raw, err := s.settings.GetValue(ctx, "deai.price_2k") + if err != nil { + return nil, err + } + p4Raw, err := s.settings.GetValue(ctx, "deai.price_4k") + if err != nil { + return nil, err + } + return &DeAISettings{ + Price1K: clampNonNegative(parseIntSetting(p1Raw, 1)), + Price2K: clampNonNegative(parseIntSetting(p2Raw, 2)), + Price4K: clampNonNegative(parseIntSetting(p4Raw, 3)), + }, nil +} + +func (s *AppSettingsService) SaveDeAI(ctx context.Context, in DeAISettings) (*DeAISettings, error) { + if err := s.settings.UpsertValues(ctx, map[string]string{ + "deai.price_1k": strconv.Itoa(clampNonNegative(in.Price1K)), + "deai.price_2k": strconv.Itoa(clampNonNegative(in.Price2K)), + "deai.price_4k": strconv.Itoa(clampNonNegative(in.Price4K)), + }); err != nil { + return nil, err + } + return s.DeAI(ctx) +} + +func clampNonNegative(n int) int { + if n < 0 { + return 0 + } + return n +} + func (s *AppSettingsService) Credits(ctx context.Context) (*CreditSettings, error) { checkinEnabledRaw, err := s.settings.GetValue(ctx, "credits.checkin_enabled") if err != nil { diff --git a/backend/internal/service/deai.go b/backend/internal/service/deai.go new file mode 100644 index 0000000..41e85e6 --- /dev/null +++ b/backend/internal/service/deai.go @@ -0,0 +1,95 @@ +package service + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "math/rand" + "time" + + _ "image/gif" + + _ "golang.org/x/image/webp" +) + +// applyDeAI post-processes a generated image to weaken AI-detection artifacts: +// a small edge crop (kills border fingerprints), per-pixel low-amplitude noise, +// a subtle brightness/contrast jitter, and a JPEG re-encode round-trip that +// both introduces natural compression statistics and strips any embedded +// metadata/watermark chunks. Output is PNG (matching the stored content type). +func applyDeAI(b []byte) ([]byte, error) { + src, _, err := image.Decode(bytes.NewReader(b)) + if err != nil { + return nil, err + } + bounds := src.Bounds() + w, h := bounds.Dx(), bounds.Dy() + + // Crop a sliver off each edge (~0.3% of the dimension, 2–12px). + cropX := clampInt(w*3/1000, 2, 12) + cropY := clampInt(h*3/1000, 2, 12) + if w <= cropX*4 || h <= cropY*4 { + cropX, cropY = 0, 0 + } + x0, y0 := bounds.Min.X+cropX, bounds.Min.Y+cropY + x1, y1 := bounds.Max.X-cropX, bounds.Max.Y-cropY + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + // Subtle tone jitter: contrast ±2%, brightness ±2 levels. + contrast := 1.0 + (rng.Float64()-0.5)*0.04 + brightness := (rng.Float64() - 0.5) * 4.0 + + out := image.NewRGBA(image.Rect(0, 0, x1-x0, y1-y0)) + for y := y0; y < y1; y++ { + for x := x0; x < x1; x++ { + r, g, bl, a := src.At(x, y).RGBA() + out.SetRGBA(x-x0, y-y0, color.RGBA{ + R: jitterChannel(r, contrast, brightness, rng), + G: jitterChannel(g, contrast, brightness, rng), + B: jitterChannel(bl, contrast, brightness, rng), + A: uint8(a >> 8), + }) + } + } + + // JPEG round-trip: natural compression statistics + strips metadata. + var jbuf bytes.Buffer + if err := jpeg.Encode(&jbuf, out, &jpeg.Options{Quality: 93}); err != nil { + return nil, err + } + rt, err := jpeg.Decode(bytes.NewReader(jbuf.Bytes())) + if err != nil { + return nil, err + } + var pbuf bytes.Buffer + if err := png.Encode(&pbuf, rt); err != nil { + return nil, err + } + return pbuf.Bytes(), nil +} + +// jitterChannel applies contrast/brightness around mid-gray plus ±2 noise to a +// 16-bit color channel, returning the clamped 8-bit value. +func jitterChannel(v uint32, contrast, brightness float64, rng *rand.Rand) uint8 { + f := float64(v>>8) + f = (f-128.0)*contrast + 128.0 + brightness + (rng.Float64()-0.5)*4.0 + if f < 0 { + f = 0 + } + if f > 255 { + f = 255 + } + return uint8(f + 0.5) +} + +func clampInt(n, lo, hi int) int { + if n < lo { + return lo + } + if n > hi { + return hi + } + return n +} diff --git a/backend/internal/service/user_generation.go b/backend/internal/service/user_generation.go index 2ab24ae..7d2a745 100644 --- a/backend/internal/service/user_generation.go +++ b/backend/internal/service/user_generation.go @@ -33,6 +33,9 @@ type UserGenerateRequest struct { Resolution string Duration string ReferenceImages []string + // DeAI applies 去AI特征 post-processing to the generated image (image only) + // and charges the per-tier surcharge on top of the model price. + DeAI bool // AccountID pins an admin test to one specific provider account (账号生图测试). AccountID string } @@ -75,6 +78,7 @@ func (s *UserGenerationService) Generate(ctx context.Context, user *model.User, AspectRatio: in.Ratio, Resolution: in.Resolution, ReferenceImages: in.ReferenceImages, + DeAI: in.DeAI, }) if err != nil { return nil, err diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index 55e5085..0f1b6c0 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -199,6 +199,10 @@ type V1ImageRequest struct { Resolution string N int ReferenceImages []string + // DeAI applies 去AI特征 post-processing (crop / noise / tone jitter + + // re-encode) to the output and charges the per-tier surcharge on top of + // the model price. Playground-only; the /v1 OpenAI path never sets it. + DeAI bool // BaseURL is the scheme+host of the inbound request (e.g. "https://host"), // used to build absolute, directly-downloadable output URLs. Empty falls // back to a relative "/images/..." path. @@ -584,6 +588,13 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri _ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0) return nil, fmt.Errorf("%w: %s", ErrProviderUnsupported, modelItem.Provider) } + // 去AI特征: post-process before storing/returning. Best-effort — a decode + // failure keeps the original bytes rather than failing a paid generation. + if in.DeAI { + if processed, derr := applyDeAI(imageBytes); derr == nil { + imageBytes = processed + } + } if !noStore { // Upload to RustFS. On failure the generation fails and credits are // refunded — we never fall back to local disk. @@ -1104,7 +1115,11 @@ func (s *V1Service) prepareImage(ctx context.Context, principal *APIPrincipal, i resolution = fb } } - price, err := s.chargeForModel(ctx, principal, modelItem, "image", resolution, "", charge) + var surcharge float64 + if in.DeAI { + surcharge = s.deaiSurcharge(ctx, resolution) + } + price, err := s.chargeForModel(ctx, principal, modelItem, "image", resolution, "", surcharge, charge) if err != nil { return nil, "", "", 0, err } @@ -1172,14 +1187,14 @@ func (s *V1Service) prepareVideo(ctx context.Context, principal *APIPrincipal, i if resolution == "" { resolution = "720p" } - price, err := s.chargeForModel(ctx, principal, modelItem, "video", resolution, duration, charge) + price, err := s.chargeForModel(ctx, principal, modelItem, "video", resolution, duration, 0, charge) if err != nil { return nil, "", "", "", 0, err } return modelItem, resolution, aspectRatio, duration, price, nil } -func (s *V1Service) chargeForModel(ctx context.Context, principal *APIPrincipal, modelItem *model.ModelConfig, kind, resolution, duration string, charge bool) (float64, error) { +func (s *V1Service) chargeForModel(ctx context.Context, principal *APIPrincipal, modelItem *model.ModelConfig, kind, resolution, duration string, surcharge float64, charge bool) (float64, error) { // 代理用户走代理价(某档未设代理价则回退普通价)。principal.User 即将被扣费的 // 用户,无论画图台还是 key 调用都从这里取,所以一处即覆盖所有路径。 agent := principal != nil && principal.User != nil && principal.User.Role == "agent" @@ -1187,6 +1202,7 @@ func (s *V1Service) chargeForModel(ctx context.Context, principal *APIPrincipal, if !ok { return 0, ErrUnsupportedParams } + price += surcharge if !charge || principal == nil || principal.User == nil { return 0, nil } @@ -2863,6 +2879,30 @@ func guessRatio(w, h int) string { // firstPricedResolution returns the model's lowest priced image tier (1K/2K/4K // order), or "" if none is priced. Used to rescue a request whose resolution // the model doesn't support. +// deaiSurcharge returns the 去AI特征 surcharge (积分) for an image resolution +// tier, from site settings (defaults: 1K=1, 2K=2, 4K=3). +func (s *V1Service) deaiSurcharge(ctx context.Context, resolution string) float64 { + key, def := "deai.price_1k", 1 + switch strings.ToUpper(strings.TrimSpace(resolution)) { + case "2K": + key, def = "deai.price_2k", 2 + case "4K": + key, def = "deai.price_4k", 3 + } + if s.settings == nil { + return float64(def) + } + raw, err := s.settings.GetValue(ctx, key) + if err != nil { + return float64(def) + } + n := parseIntSetting(raw, def) + if n < 0 { + n = 0 + } + return float64(n) +} + func firstPricedResolution(item *model.ModelConfig) string { if item == nil { return "" diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue index 16409eb..82497bf 100644 --- a/frontend/src/views/ConfigView.vue +++ b/frontend/src/views/ConfigView.vue @@ -120,6 +120,24 @@ const smtpBusy = ref(false); const smtpSaved = ref(false) const credits = reactive({ checkin_enabled: true, checkin_reward: 3, invite_enabled: true, invite_reward: 3, cdk_redeem_enabled: true }) const credBusy = ref(false); const credSaved = ref(false) +// ---- deai (去AI特征 附加价格) ---- +const deaiCfg = reactive({ price_1k: 1, price_2k: 2, price_4k: 3 }) +const deaiBusy = ref(false); const deaiSaved = ref(false) +async function loadDeai() { + const r = await api('/settings/deai') + if (r.ok && r.data) Object.assign(deaiCfg, r.data) +} +async function saveDeai() { + deaiBusy.value = true; deaiSaved.value = false + const r = await api('/settings/deai', jsonBody('PUT', { + price_1k: Number(deaiCfg.price_1k) || 0, + price_2k: Number(deaiCfg.price_2k) || 0, + price_4k: Number(deaiCfg.price_4k) || 0, + })) + deaiBusy.value = false + if (r.ok) { deaiSaved.value = true; setTimeout(() => (deaiSaved.value = false), 2000) } +} + // ---- announcement (公告, markdown; re-pops for users who haven't seen edits) ---- const ann = reactive({ content: '' }) const annBusy = ref(false); const annSaved = ref(false) @@ -269,7 +287,7 @@ async function saveCredits() { if (r.ok) { credSaved.value = true; setTimeout(() => (credSaved.value = false), 2000) } } -onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnouncement(); loadPay(); loadProxy(); loadLogs(); loadMedia() }) +onMounted(() => { loadSite(); loadReg(); loadSmtp(); loadCredits(); loadAnnouncement(); loadPay(); loadProxy(); loadLogs(); loadMedia(); loadDeai() })