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>
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user