Files
image2api/backend/internal/service/app_settings.go
T
chiyiandClaude Opus 4.8 606caaf047 Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider
account pools, billing/admin, Docker one-command deploy with auto HTTPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:59:04 +08:00

439 lines
12 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"`
}
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,
}
}
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
}
return &CreditSettings{
CheckinEnabled: parseBoolSetting(checkinEnabledRaw, true),
CheckinReward: parseIntSetting(checkinRewardRaw, 3),
InviteEnabled: parseBoolSetting(inviteEnabledRaw, true),
InviteReward: parseIntSetting(inviteRewardRaw, 3),
}, 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),
}); 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
}
return SMTPConfig{
Host: current.Host,
Port: current.Port,
Username: current.Username,
Password: password,
FromAddr: current.FromAddr,
UseTLS: current.UseTLS,
}, 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
}