Files
kevin 9bf397064d 新增文件上传接口与用户头像裁剪上传
- 新增 storage 配置(存储目录、单文件大小上限),ConfigVersion 3→4 自动补全
- internal/file:上传(sha256 秒传去重)、删除(上传者或管理员,引用中 409)、公开查看;本地存储 + 操作日志 + 引用计数,仅安全类型内联防存储型 XSS
- internal/avatar:PUT/DELETE /api/me/avatar,自动管理头像文件引用与旧头像解绑
- 前端引入 vue-advanced-cropper,个人中心支持上传/更换/删除头像,裁剪输出 512×512 JPEG;http 请求支持 FormData
- 导出 auth.CurrentUser、新增 model.User.IsAdmin 与 testutil 多部件上传辅助,补充接口测试并重新生成 Swagger 文档
2026-09-21 20:50:19 +08:00

319 lines
8.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
_ "embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"time"
"github.com/goccy/go-yaml"
)
//go:embed config.default.yaml
var defaultConfigYAML []byte
type Config struct {
Version int `yaml:"version"`
Server ServerConfig `yaml:"server"`
Log LogConfig `yaml:"log"`
Static StaticConfig `yaml:"static"`
Storage StorageConfig `yaml:"storage"`
API APIConfig `yaml:"api"`
Auth AuthConfig `yaml:"auth"`
Database DatabaseConfig `yaml:"database"`
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Sock string `yaml:"sock"`
Mode string `yaml:"mode"`
TrustedProxies []string `yaml:"trusted_proxies"`
}
// TCPEnabled 是否启用 TCP 监听(port 为 0 表示不启用)。
func (s ServerConfig) TCPEnabled() bool {
return s.Port > 0
}
// SockEnabled 是否启用 unix socket 监听(sock 留空表示不启用)。
func (s ServerConfig) SockEnabled() bool {
return strings.TrimSpace(s.Sock) != ""
}
type LogConfig struct {
Level string `yaml:"level"`
AccessLog bool `yaml:"access_log"`
}
type StaticConfig struct {
Dir string `yaml:"dir"`
}
// StorageConfig 上传文件存储配置。
type StorageConfig struct {
Dir string `yaml:"dir"` // 存储根目录
MaxSizeMB int `yaml:"max_size_mb"` // 单文件大小上限(MB
}
// MaxUploadBytes 单文件大小上限(字节)。
func (c *Config) MaxUploadBytes() int64 {
return int64(c.Storage.MaxSizeMB) << 20
}
type APIConfig struct {
Prefix string `yaml:"prefix"`
CORS CORSConfig `yaml:"cors"`
}
type CORSConfig struct {
Enabled bool `yaml:"enabled"`
AllowOrigins []string `yaml:"allow_origins"`
AllowMethods []string `yaml:"allow_methods"`
AllowHeaders []string `yaml:"allow_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
MaxAge string `yaml:"max_age"`
}
type AuthConfig struct {
Secret string `yaml:"secret"`
TokenTTL string `yaml:"token_ttl"`
}
type DatabaseConfig struct {
Driver string `yaml:"driver"` // sqlite3 / mysql
ConnectTimeout string `yaml:"connect_timeout"` // 建立连接超时
SQLite SQLiteConfig `yaml:"sqlite"`
MySQL MySQLConfig `yaml:"mysql"`
}
type SQLiteConfig struct {
Path string `yaml:"path"`
}
type MySQLConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
Database string `yaml:"database"`
Charset string `yaml:"charset"`
MaxOpenConns int `yaml:"max_open_conns"`
MaxIdleConns int `yaml:"max_idle_conns"`
ConnMaxLifetime string `yaml:"conn_max_lifetime"`
}
func defaultConfig() *Config {
return &Config{
Server: ServerConfig{
Host: "0.0.0.0",
Port: 8080,
Sock: "web.sock",
Mode: "release",
TrustedProxies: []string{},
},
Log: LogConfig{
Level: "info",
AccessLog: true,
},
Static: StaticConfig{
Dir: "./dist",
},
Storage: StorageConfig{
Dir: "./data/uploads",
MaxSizeMB: 10,
},
API: APIConfig{
Prefix: "/api",
CORS: CORSConfig{
Enabled: true,
AllowOrigins: []string{"http://localhost:5173"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
AllowCredentials: false,
MaxAge: "12h",
},
},
Auth: AuthConfig{
TokenTTL: "24h",
},
Database: DatabaseConfig{
Driver: "sqlite3",
ConnectTimeout: "10s",
SQLite: SQLiteConfig{
Path: "./data/rill.db",
},
MySQL: MySQLConfig{
Host: "127.0.0.1",
Port: 3306,
User: "root",
Password: "",
Database: "rill",
Charset: "utf8mb4",
MaxOpenConns: 10,
MaxIdleConns: 5,
ConnMaxLifetime: "1h",
},
},
}
}
// LoadConfig 读取 YAML 配置文件,未指定的配置项使用默认值;文件不存在时自动生成默认配置文件。
func LoadConfig(path string) (*Config, error) {
cfg := defaultConfig()
data, err := os.ReadFile(path)
switch {
case err == nil:
data = applyConfigUpgrade(path, data)
case errors.Is(err, fs.ErrNotExist):
if err := generateConfig(path); err != nil {
return nil, err
}
data = defaultConfigYAML
default:
return nil, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func generateConfig(path string) error {
if dir := filepath.Dir(path); dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建配置目录 %s 失败: %w", dir, err)
}
}
if err := os.WriteFile(path, defaultConfigYAML, 0o644); err != nil {
return fmt.Errorf("生成默认配置文件 %s 失败: %w", path, err)
}
slog.Info("配置文件不存在,已生成默认配置", "path", path)
return nil
}
func (c *Config) validate() error {
switch c.Server.Mode {
case "debug", "release", "test":
default:
return fmt.Errorf("server.mode 无效: %q(可选: debug/release/test", c.Server.Mode)
}
if c.Server.Port < 0 || c.Server.Port > 65535 {
return fmt.Errorf("server.port 无效: %d0 表示不启用 TCP", c.Server.Port)
}
if !c.Server.TCPEnabled() && !c.Server.SockEnabled() {
return fmt.Errorf("server.port 与 server.sock 至少需要启用一个")
}
for _, proxy := range c.Server.TrustedProxies {
value := strings.TrimSpace(proxy)
if value == "" || net.ParseIP(value) != nil {
continue
}
if _, _, err := net.ParseCIDR(value); err != nil {
return fmt.Errorf("server.trusted_proxies 无效: %q(需为 IP 或 CIDR", proxy)
}
}
if _, err := parseLogLevel(c.Log.Level); err != nil {
return err
}
if _, err := time.ParseDuration(c.API.CORS.MaxAge); err != nil {
return fmt.Errorf("api.cors.max_age 无效: %w", err)
}
if _, err := time.ParseDuration(c.Auth.TokenTTL); err != nil {
return fmt.Errorf("auth.token_ttl 无效: %w", err)
}
if err := c.validateStorage(); err != nil {
return err
}
if err := c.validateDatabase(); err != nil {
return err
}
return nil
}
func (c *Config) validateStorage() error {
if strings.TrimSpace(c.Storage.Dir) == "" {
return fmt.Errorf("storage.dir 不能为空")
}
if c.Storage.MaxSizeMB < 1 || c.Storage.MaxSizeMB > 1024 {
return fmt.Errorf("storage.max_size_mb 无效: %d(可选范围: 1-1024", c.Storage.MaxSizeMB)
}
return nil
}
func (c *Config) validateDatabase() error {
if _, err := time.ParseDuration(c.Database.ConnectTimeout); err != nil {
return fmt.Errorf("database.connect_timeout 无效: %w", err)
}
switch c.Database.Driver {
case "sqlite3":
if strings.TrimSpace(c.Database.SQLite.Path) == "" {
return fmt.Errorf("database.sqlite.path 不能为空")
}
case "mysql":
db := c.Database.MySQL
if strings.TrimSpace(db.Host) == "" {
return fmt.Errorf("database.mysql.host 不能为空")
}
if db.Port <= 0 || db.Port > 65535 {
return fmt.Errorf("database.mysql.port 无效: %d", db.Port)
}
if strings.TrimSpace(db.Database) == "" {
return fmt.Errorf("database.mysql.database 不能为空")
}
if db.MaxOpenConns < 0 || db.MaxIdleConns < 0 {
return fmt.Errorf("database.mysql 连接池大小不能为负数")
}
if _, err := time.ParseDuration(db.ConnMaxLifetime); err != nil {
return fmt.Errorf("database.mysql.conn_max_lifetime 无效: %w", err)
}
default:
return fmt.Errorf("database.driver 无效: %q(可选: sqlite3/mysql", c.Database.Driver)
}
return nil
}
func (c *Config) LogLevel() slog.Level {
level, _ := parseLogLevel(c.Log.Level)
return level
}
func (c *Config) CORSMaxAge() time.Duration {
maxAge, _ := time.ParseDuration(c.API.CORS.MaxAge)
return maxAge
}
// TokenTTLDuration 登录凭证有效期,配置无效时返回 0。
func (c *Config) TokenTTLDuration() time.Duration {
ttl, _ := time.ParseDuration(c.Auth.TokenTTL)
return ttl
}
func parseLogLevel(level string) (slog.Level, error) {
switch level {
case "debug":
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return slog.LevelInfo, fmt.Errorf("log.level 无效: %q(可选: debug/info/warn/error", level)
}
}