Files
image2api/backend/internal/service/app_settings.go
T
chiyiandClaude Opus 4.8 5cf6206ee9 feat: 并发分组系统 + 品牌/Logo 上传 + 兑换码开关 + 文档分辨率表
并发分组(新功能):
- 新表 concurrency_groups(名称/上限/默认),用户加 concurrency_group_id
- 启动自动建「默认并发」组(上限10、默认),老用户回填、新注册自动绑定
- 并发计数改用 Redis(自愈 sorted-set + TTL + fail-open):
  · 用户并发(画图台 + API key 合计)受其分组上限限制,0=不限制 → 超返回 429
  · 账号级并发也从内存 gate 换成同一套 Redis(6 处调用点)
  · 移除旧的「已有正在生成的任务」单任务锁
- 后台「并发分组」新菜单:增删改、设默认、用户数;默认组不可删(删别的组成员转默认)
- 用户管理:并发列 + 新建/编辑可选分组
- 个人设置页:账户信息卡(用户名/邮箱/角色/余额/并发);/me 暴露 concurrency_group/limit

品牌 / Logo(上传到 RustFS):
- Logo 改成拖拽/点击上传,点保存才上传;替换自动删旧;branding/ 设为公开且被清理任务 pin 住(永不删)
- 有自定义就用:前台左侧 nav + 后台侧栏 + favicon(浏览器标签);没有则默认 V 图标
- 前台页头还原成文字;首页 Hero 子标题用 site.subtitle(默认那句宣传语,设置页预填)
- 邮件验证码标题用站点名;新增 POST/DELETE /settings/logo + POST /settings/asset(首页底图上传)

兑换码开关:
- 系统设置→积分 新增「开启兑换码」(默认开);关闭后后端拒绝兑换、前台隐藏兑换入口(/site 暴露 cdk_redeem_enabled)

文档 / 分辨率:
- 去掉 quality 参数:size(宽x高)同时决定比例 + 分辨率档(长边映射 1K/2K/4K)
- 文档加「分辨率对照表」(14 个比例 × 1K/2K/4K → size 该传的值);guessRatio 与自定义模型 RATIO_OPTS 对齐到 14 个

其它:
- 删模型时同步清掉各上游账号「支持模型」里的该 id
- 首页设置/兑换码弹窗去固定高度滚动条;展示位弹窗浅色主题适配

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:52:31 +08:00

519 lines
15 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"backend/internal/repo"
"backend/internal/storage"
)
type AppSettingsService struct {
settings *repo.SiteSettingRepository
events *repo.EventRepository
smtp *SMTPService
store *storage.Client
}
type RegistrationSettings struct {
Open bool `json:"open"`
EmailCode bool `json:"email_code"`
AllowPasswordReset bool `json:"allow_password_reset"`
AllowedDomains []string `json:"allowed_email_domains"`
CodeTTLSeconds int `json:"code_ttl_seconds"`
}
type SMTPSettings struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
FromAddr string `json:"from_addr"`
UseTLS bool `json:"use_tls"`
}
type CreditSettings struct {
CheckinEnabled bool `json:"checkin_enabled"`
CheckinReward int `json:"checkin_reward"`
InviteEnabled bool `json:"invite_enabled"`
InviteReward int `json:"invite_reward"`
CDKRedeemEnabled bool `json:"cdk_redeem_enabled"`
}
type ProxySettings struct {
Proxy string `json:"proxy"`
}
type RetentionSettings struct {
RetentionDays int `json:"retention_days"`
}
type MediaRetentionResult struct {
Settings *RetentionSettings
Removed int `json:"removed"`
FreedBytes int64 `json:"freed_bytes"`
}
func NewAppSettingsService(settings *repo.SiteSettingRepository, events *repo.EventRepository, smtp *SMTPService, store *storage.Client) *AppSettingsService {
return &AppSettingsService{
settings: settings,
events: events,
smtp: smtp,
store: store,
}
}
// UploadLogo stores a new site logo in object storage under branding/, deletes
// the previously-uploaded one (if any), persists site.logo, and returns its URL.
func (s *AppSettingsService) UploadLogo(ctx context.Context, data []byte, contentType string) (string, error) {
if s.store == nil || !s.store.Configured() {
return "", errors.New("对象存储未配置")
}
if len(data) == 0 {
return "", errors.New("空文件")
}
if len(data) > 4*1024*1024 {
return "", errors.New("logo 不能超过 4MB")
}
key := "branding/logo-" + randomUpper(10) + "." + logoExt(contentType)
if err := s.store.Put(ctx, key, data, contentType); err != nil {
return "", err
}
url := "/images/" + key
// Delete the previous uploaded logo (best-effort), then point site.logo at the new one.
if old, _ := s.settings.GetValue(ctx, "site.logo"); strings.HasPrefix(old, "/images/branding/") {
_ = s.store.Delete(ctx, strings.TrimPrefix(old, "/images/"))
}
if err := s.settings.UpsertValue(ctx, "site.logo", url); err != nil {
return "", err
}
return url, nil
}
// UploadAsset stores a public image (e.g. a 首页内容 底图) under branding/ and
// returns its storage path (for form.image). Does NOT touch site settings.
func (s *AppSettingsService) UploadAsset(ctx context.Context, data []byte, contentType string) (string, error) {
if s.store == nil || !s.store.Configured() {
return "", errors.New("对象存储未配置")
}
if len(data) == 0 {
return "", errors.New("空文件")
}
if len(data) > 8*1024*1024 {
return "", errors.New("图片不能超过 8MB")
}
key := "branding/sc-" + randomUpper(10) + "." + logoExt(contentType)
if err := s.store.Put(ctx, key, data, contentType); err != nil {
return "", err
}
return key, nil
}
// RemoveLogo deletes the uploaded logo and resets site.logo to the built-in default (empty).
func (s *AppSettingsService) RemoveLogo(ctx context.Context) error {
if old, _ := s.settings.GetValue(ctx, "site.logo"); strings.HasPrefix(old, "/images/branding/") && s.store != nil {
_ = s.store.Delete(ctx, strings.TrimPrefix(old, "/images/"))
}
return s.settings.UpsertValue(ctx, "site.logo", "")
}
func logoExt(contentType string) string {
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "image/jpeg", "image/jpg":
return "jpg"
case "image/webp":
return "webp"
case "image/svg+xml":
return "svg"
case "image/gif":
return "gif"
default:
return "png"
}
}
func (s *AppSettingsService) Registration(ctx context.Context) (*RegistrationSettings, error) {
openRaw, err := s.settings.GetValue(ctx, "auth.open")
if err != nil {
return nil, err
}
emailCodeRaw, err := s.settings.GetValue(ctx, "auth.email_code")
if err != nil {
return nil, err
}
resetRaw, err := s.settings.GetValue(ctx, "auth.allow_password_reset")
if err != nil {
return nil, err
}
domainsRaw, err := s.settings.GetValue(ctx, "auth.allowed_email_domains")
if err != nil {
return nil, err
}
ttlRaw, err := s.settings.GetValue(ctx, "auth.code_ttl_seconds")
if err != nil {
return nil, err
}
ttl, _ := strconv.Atoi(strings.TrimSpace(ttlRaw))
if ttl < 60 {
ttl = 600
}
return &RegistrationSettings{
Open: parseBoolSetting(openRaw, true),
EmailCode: parseBoolSetting(emailCodeRaw, false),
AllowPasswordReset: parseBoolSetting(resetRaw, false),
AllowedDomains: parseCSVSetting(domainsRaw),
CodeTTLSeconds: ttl,
}, nil
}
func (s *AppSettingsService) SaveRegistration(ctx context.Context, in RegistrationSettings) (*RegistrationSettings, error) {
// Empty list is allowed and means "no domain restriction": EmailDomainAllowed
// returns true for everyone when the whitelist is empty.
domains := ValidateAllowedEmailDomains(in.AllowedDomains)
if in.CodeTTLSeconds < 60 {
in.CodeTTLSeconds = 600
}
if err := s.settings.UpsertValues(ctx, map[string]string{
"auth.open": strconv.FormatBool(in.Open),
"auth.email_code": strconv.FormatBool(in.EmailCode),
"auth.allow_password_reset": strconv.FormatBool(in.AllowPasswordReset),
"auth.allowed_email_domains": strings.Join(domains, ","),
"auth.code_ttl_seconds": strconv.Itoa(in.CodeTTLSeconds),
}); err != nil {
return nil, err
}
return s.Registration(ctx)
}
func (s *AppSettingsService) SMTP(ctx context.Context) (*SMTPSettings, error) {
host, err := s.settings.GetValue(ctx, "smtp.host")
if err != nil {
return nil, err
}
portRaw, err := s.settings.GetValue(ctx, "smtp.port")
if err != nil {
return nil, err
}
username, err := s.settings.GetValue(ctx, "smtp.username")
if err != nil {
return nil, err
}
password, err := s.settings.GetValue(ctx, "smtp.password")
if err != nil {
return nil, err
}
fromAddr, err := s.settings.GetValue(ctx, "smtp.from_addr")
if err != nil {
return nil, err
}
useTLSRaw, err := s.settings.GetValue(ctx, "smtp.use_tls")
if err != nil {
return nil, err
}
port, _ := strconv.Atoi(strings.TrimSpace(portRaw))
if port <= 0 {
port = 587
}
return &SMTPSettings{
Host: strings.TrimSpace(host),
Port: port,
Username: strings.TrimSpace(username),
Password: maskedSecret(password),
FromAddr: strings.TrimSpace(fromAddr),
UseTLS: parseBoolSetting(useTLSRaw, true),
}, nil
}
func (s *AppSettingsService) SaveSMTP(ctx context.Context, in SMTPSettings) (*SMTPSettings, error) {
host := strings.TrimSpace(in.Host)
username := strings.TrimSpace(in.Username)
fromAddr := strings.TrimSpace(in.FromAddr)
if host == "" || username == "" || fromAddr == "" {
return nil, errors.New("请填写 主机 / 用户名 / 发件地址")
}
if _, err := ValidateEmail(fromAddr); err != nil {
return nil, err
}
if in.Port <= 0 {
return nil, errors.New("port 必须是正整数")
}
updates := map[string]string{
"smtp.host": host,
"smtp.port": strconv.Itoa(in.Port),
"smtp.username": username,
"smtp.from_addr": fromAddr,
"smtp.use_tls": strconv.FormatBool(in.UseTLS),
}
if strings.TrimSpace(in.Password) != "" && strings.TrimSpace(in.Password) != "***" {
updates["smtp.password"] = in.Password
}
if err := s.settings.UpsertValues(ctx, updates); err != nil {
return nil, err
}
return s.SMTP(ctx)
}
func (s *AppSettingsService) TestSMTP(ctx context.Context, to string) error {
to, err := ValidateEmail(to)
if err != nil {
return err
}
cfg, err := s.loadSMTPConfig(ctx)
if err != nil {
return err
}
return s.smtp.SendCode(ctx, cfg, to, "123456", "register")
}
func (s *AppSettingsService) Proxy(ctx context.Context) (*ProxySettings, error) {
proxy, err := s.settings.GetValue(ctx, "proxy.url")
if err != nil {
return nil, err
}
return &ProxySettings{Proxy: strings.TrimSpace(proxy)}, nil
}
func (s *AppSettingsService) SaveProxy(ctx context.Context, proxy string) (*ProxySettings, error) {
proxy = strings.TrimSpace(proxy)
if err := s.settings.UpsertValue(ctx, "proxy.url", proxy); err != nil {
return nil, err
}
return &ProxySettings{Proxy: proxy}, nil
}
// TestProxy routes a probe request through the given proxy to an IP-echo service
// and reports the egress IP + latency. Tests the value passed in (so the admin
// can verify before saving). Mirrors how generation calls go out — same HTTP
// CONNECT through the proxy — so a green result means upstream calls will route.
func (s *AppSettingsService) TestProxy(ctx context.Context, proxy string) (map[string]any, error) {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return nil, errors.New("代理地址为空,请先填写")
}
parsed, err := url.Parse(proxy)
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("代理地址格式不正确(应形如 http://user:pass@host:port)")
}
transport := &http.Transport{Proxy: http.ProxyURL(parsed)}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 12 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.ipify.org?format=json", nil)
if err != nil {
return nil, err
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("通过代理请求失败:%v", err)
}
defer resp.Body.Close()
elapsed := int(time.Since(start).Milliseconds())
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("代理已连接,但探测返回 HTTP %d", resp.StatusCode)
}
var echo struct {
IP string `json:"ip"`
}
_ = json.Unmarshal(body, &echo)
return map[string]any{
"exit_ip": echo.IP,
"elapsed_ms": elapsed,
}, nil
}
func (s *AppSettingsService) Credits(ctx context.Context) (*CreditSettings, error) {
checkinEnabledRaw, err := s.settings.GetValue(ctx, "credits.checkin_enabled")
if err != nil {
return nil, err
}
checkinRewardRaw, err := s.settings.GetValue(ctx, "credits.checkin_reward")
if err != nil {
return nil, err
}
inviteEnabledRaw, err := s.settings.GetValue(ctx, "credits.invite_enabled")
if err != nil {
return nil, err
}
inviteRewardRaw, err := s.settings.GetValue(ctx, "credits.invite_reward")
if err != nil {
return nil, err
}
cdkRaw, _ := s.settings.GetValue(ctx, "credits.cdk_redeem_enabled")
return &CreditSettings{
CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true),
CheckinReward: parseIntSetting(checkinRewardRaw, 3),
InviteEnabled: parseBoolSetting(inviteEnabledRaw, true),
InviteReward: parseIntSetting(inviteRewardRaw, 3),
CDKRedeemEnabled: parseBoolSetting(cdkRaw, true),
}, nil
}
func (s *AppSettingsService) SaveCredits(ctx context.Context, in CreditSettings) (*CreditSettings, error) {
if in.CheckinReward < 0 {
in.CheckinReward = 0
}
if in.InviteReward < 0 {
in.InviteReward = 0
}
if err := s.settings.UpsertValues(ctx, map[string]string{
"credits.checkin_enabled": strconv.FormatBool(in.CheckinEnabled),
"credits.checkin_reward": strconv.Itoa(in.CheckinReward),
"credits.invite_enabled": strconv.FormatBool(in.InviteEnabled),
"credits.invite_reward": strconv.Itoa(in.InviteReward),
"credits.cdk_redeem_enabled": strconv.FormatBool(in.CDKRedeemEnabled),
}); err != nil {
return nil, err
}
return s.Credits(ctx)
}
func (s *AppSettingsService) Logs(ctx context.Context) (*RetentionSettings, error) {
return s.retention(ctx, "logs.retention_days")
}
func (s *AppSettingsService) SaveLogs(ctx context.Context, days int) (*RetentionSettings, error) {
days, err := normalizeRetentionDays(days)
if err != nil {
return nil, err
}
if err := s.settings.UpsertValue(ctx, "logs.retention_days", strconv.Itoa(days)); err != nil {
return nil, err
}
if s.events != nil {
_, _ = s.events.PurgeOlderThan(ctx, time.Duration(days)*24*time.Hour)
}
return s.Logs(ctx)
}
func (s *AppSettingsService) Media(ctx context.Context) (*RetentionSettings, error) {
return s.retention(ctx, "media.retention_days")
}
func (s *AppSettingsService) SaveMedia(ctx context.Context, days int) (*MediaRetentionResult, error) {
days, err := normalizeRetentionDays(days)
if err != nil {
return nil, err
}
if err := s.settings.UpsertValue(ctx, "media.retention_days", strconv.Itoa(days)); err != nil {
return nil, err
}
removed, freed := s.pruneGeneratedFiles(ctx, time.Duration(days)*24*time.Hour)
settings, err := s.Media(ctx)
if err != nil {
return nil, err
}
return &MediaRetentionResult{
Settings: settings,
Removed: removed,
FreedBytes: freed,
}, nil
}
func (s *AppSettingsService) loadSMTPConfig(ctx context.Context) (SMTPConfig, error) {
current, err := s.SMTP(ctx)
if err != nil {
return SMTPConfig{}, err
}
password, err := s.settings.GetValue(ctx, "smtp.password")
if err != nil {
return SMTPConfig{}, err
}
// The verification-email subject uses the site title, e.g. "<title> 邮箱验证码".
title, _ := s.settings.GetValue(ctx, "site.title")
title = strings.TrimSpace(title)
if title == "" {
title = "Vivid"
}
return SMTPConfig{
Host: current.Host,
Port: current.Port,
Username: current.Username,
Password: password,
FromAddr: current.FromAddr,
UseTLS: current.UseTLS,
Subject: title + " 邮箱验证码",
}, nil
}
func maskedSecret(v string) string {
if strings.TrimSpace(v) == "" {
return ""
}
return "***"
}
func parseIntSetting(v string, fallback int) int {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return fallback
}
return n
}
func (s *AppSettingsService) retention(ctx context.Context, key string) (*RetentionSettings, error) {
raw, err := s.settings.GetValue(ctx, key)
if err != nil {
return nil, err
}
days := parseIntSetting(raw, 30)
if days < 1 {
days = 30
}
return &RetentionSettings{RetentionDays: days}, nil
}
func normalizeRetentionDays(days int) (int, error) {
if days < 1 {
return 0, errors.New("留存天数至少为 1 天")
}
if days > 365 {
return 0, errors.New("留存天数最多 365 天")
}
return days, nil
}
// pruneGeneratedFiles deletes RustFS objects older than maxAge and blanks the
// matching event_log.file refs. Returns how many were removed and bytes freed.
// (The maintenance loop does the same automatically every 60s; this gives the
// admin an immediate result when they shorten the media retention window.)
func (s *AppSettingsService) pruneGeneratedFiles(ctx context.Context, maxAge time.Duration) (int, int64) {
if s.store == nil || !s.store.Configured() || maxAge <= 0 {
return 0, 0
}
objs, err := s.store.List(ctx, "")
if err != nil {
return 0, 0
}
cutoff := time.Now().Add(-maxAge)
removed := 0
var freed int64
var clearedKeys []string
for _, o := range objs {
if !o.LastModified.Before(cutoff) {
continue
}
if err := s.store.Delete(ctx, o.Key); err == nil {
removed++
freed += o.Size
clearedKeys = append(clearedKeys, o.Key)
}
}
if len(clearedKeys) > 0 {
_, _ = s.events.ClearFiles(ctx, clearedKeys)
}
return removed, freed
}