feat(M0): 工程骨架 — Go 后端 + Vue3 前端脚手架

- Go 工程:cmd/usernode CLI(serve/migrate/admin create/reset-password/user otp)
  + internal/{config,model,service,system,auth,cron,api,router,server,pkg,webui}
- 配置:TOML + 环境变量覆盖(USERNODE_<SEC>_<FIELD>),config.example.toml
- 数据:GORM 双驱动(SQLite/MySQL)8 表模型,migrate 子命令可跑通
- 系统层:system.Manager 接口(useradd/userdel/passwd 白名单,dev dry-run)
- HTTP:Gin 路由骨架(/api/v1 + 501 占位),healthz,slog 结构化日志,优雅启停
- 前端:Vite + Vue3 + TS + Element Plus 最小可运行(web/),go:embed 打通
- 构建:deploy/Containerfile 多阶段单二进制镜像,web/Containerfile.dev 前端 dev
  镜像,Makefile(build/test/dev/web-build/image);go.mod 锁定 go 1.22
- 验证:go build/vet/test 通过;podman 镜像构建运行 healthz+embed 通过
This commit is contained in:
2026-08-29 22:43:27 +08:00
parent f70b344c85
commit ae45aba607
46 changed files with 2878 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
// Package config 负责加载 TOML 配置文件并支持环境变量覆盖。
//
// 覆盖规则:环境变量名 = USERNODE_<SECTION>_<FIELD>(字段名 camelCase 转
// SCREAMING_SNAKE),例如 server.listen 对应 USERNODE_SERVER_LISTEN
// database.driver 对应 USERNODE_DATABASE_DRIVER。仅当环境变量存在时覆盖,
// 便于容器/CI 场景注入敏感配置(如 SMTP 密码)而不落地到文件。
package config
import (
"errors"
"fmt"
"os"
"reflect"
"strconv"
"strings"
"time"
"unicode"
"github.com/BurntSushi/toml"
)
const envPrefix = "USERNODE_"
// Config 为全部配置的根。各子结构对应 config.example.toml 的一个 section。
type Config struct {
App AppConfig `toml:"app"`
Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
Log LogConfig `toml:"log"`
Policy PolicyConfig `toml:"policy"`
SMTP SMTPConfig `toml:"smtp"`
System SystemConfig `toml:"system"`
}
type AppConfig struct {
Name string `toml:"name"`
Env string `toml:"env"` // development / production
}
type ServerConfig struct {
Listen string `toml:"listen"` // 监听地址,如 0.0.0.0:8080
SessionTTL time.Duration `toml:"session_ttl"`
TrustedProxies []string `toml:"trusted_proxies"`
}
type DatabaseConfig struct {
Driver string `toml:"driver"` // sqlite / mysql
DSN string `toml:"dsn"`
}
type LogConfig struct {
Level string `toml:"level"` // debug / info / warn / error
Format string `toml:"format"` // text / json
}
// PolicyConfig 为账号生命周期与 OTP 策略的全局默认值,可在 settings 表按需覆盖(M4)。
type PolicyConfig struct {
DefaultTTL time.Duration `toml:"default_ttl"` // 新账号默认有效期
RecyclePeriod time.Duration `toml:"recycle_period"` // 到期后回收期,期内可延期恢复
AuditRetention time.Duration `toml:"audit_retention"` // 审计保留时长
OTPTTL time.Duration `toml:"otp_ttl"` // OTP 验证码有效期
OTPCooldown time.Duration `toml:"otp_cooldown"` // OTP 发送冷却
}
type SMTPConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
Username string `toml:"username"`
Password string `toml:"password"`
From string `toml:"from"`
}
// SystemConfig 为系统账号操作层的本地实现配置(sudoers 白名单模式)。
type SystemConfig struct {
Sudo bool `toml:"sudo"` // 是否通过 sudo -n 执行系统命令;开发环境 false 时 dry-run
UserPrefix string `toml:"user_prefix"` // 外部用户系统账号前缀,默认 ext_
Group string `toml:"group"` // 外部用户所属组,默认 external
Shell string `toml:"shell"` // 默认 shell
HomeBase string `toml:"home_base"` // 家目录基路径
AuthorizedKeysDir string `toml:"authorized_keys_dir"` // authorized_keys 所在目录(测试可覆盖)
}
// Default 返回带开发环境默认值的配置,作为 config.example.toml 与未配置项的兜底。
func Default() *Config {
return &Config{
App: AppConfig{Name: "ws_usernode", Env: "development"},
Server: ServerConfig{
Listen: "127.0.0.1:8080",
SessionTTL: 24 * time.Hour,
TrustedProxies: []string{"127.0.0.1", "::1"},
},
Database: DatabaseConfig{Driver: "sqlite", DSN: "data/usernode.db"},
Log: LogConfig{Level: "info", Format: "text"},
Policy: PolicyConfig{
DefaultTTL: 90 * 24 * time.Hour,
RecyclePeriod: 30 * 24 * time.Hour,
AuditRetention: 30 * 24 * time.Hour,
OTPTTL: 10 * time.Minute,
OTPCooldown: 60 * time.Second,
},
SMTP: SMTPConfig{Port: 587},
System: SystemConfig{
Sudo: false,
UserPrefix: "ext_",
Group: "external",
Shell: "/bin/sh",
HomeBase: "/home",
AuthorizedKeysDir: ".ssh",
},
}
}
// Load 加载配置文件并以环境变量覆盖。path 为空时仅使用默认值 + 环境变量。
func Load(path string) (*Config, error) {
cfg := Default()
if path != "" {
if _, err := toml.DecodeFile(path, cfg); err != nil {
return nil, fmt.Errorf("load config %s: %w", path, err)
}
}
if err := applyEnvOverrides(cfg); err != nil {
return nil, err
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
// LoadDefault 仅用于测试或未指定配置文件时的最小加载。
func LoadDefault() (*Config, error) { return Load("") }
func (c *Config) validate() error {
if c.App.Env == "" {
c.App.Env = "development"
}
switch c.Database.Driver {
case "sqlite", "mysql":
default:
return fmt.Errorf("config: unsupported database driver %q (want sqlite or mysql)", c.Database.Driver)
}
if c.Database.DSN == "" {
return errors.New("config: database.dsn is required")
}
if c.System.UserPrefix == "" {
return errors.New("config: system.user_prefix must not be empty")
}
if c.System.Group == "" {
return errors.New("config: system.group must not be empty")
}
return nil
}
// applyEnvOverrides 通过反射遍历各 section,按 USERNODE_<SECTION>_<FIELD> 覆盖。
func applyEnvOverrides(cfg *Config) error {
v := reflect.ValueOf(cfg).Elem()
t := v.Type()
for i := 0; i < t.NumField(); i++ {
sec := t.Field(i)
secVal := v.Field(i)
if secVal.Kind() != reflect.Struct {
continue
}
prefix := envPrefix + strings.ToUpper(sec.Name) + "_"
for j := 0; j < secVal.Type().NumField(); j++ {
f := secVal.Type().Field(j)
envKey := prefix + strings.ToUpper(camelToSnake(f.Name))
if val, ok := os.LookupEnv(envKey); ok {
if err := setField(secVal.Field(j), val); err != nil {
return fmt.Errorf("config: env %s: %w", envKey, err)
}
}
}
}
return nil
}
// setField 按字段类型解析环境变量字符串。
func setField(f reflect.Value, val string) error {
switch f.Kind() {
case reflect.String:
f.SetString(val)
case reflect.Bool:
b, err := strconv.ParseBool(val)
if err != nil {
return err
}
f.SetBool(b)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
// time.Duration 的底层是 int64,需先尝试 ParseDuration
if _, ok := f.Interface().(time.Duration); ok {
d, err := time.ParseDuration(val)
if err != nil {
return err
}
f.SetInt(int64(d))
return nil
}
n, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return err
}
f.SetInt(n)
case reflect.Slice:
// 逗号分隔,如 USERNODE_SERVER_TRUSTED_PROXIES=1.2.3.4,5.6.7.8
if f.Type().Elem().Kind() != reflect.String {
return fmt.Errorf("unsupported slice element type %v", f.Type().Elem())
}
parts := strings.Split(val, ",")
out := reflect.MakeSlice(f.Type(), 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = reflect.Append(out, reflect.ValueOf(p))
}
}
f.Set(out)
default:
return fmt.Errorf("unsupported field type %v", f.Type())
}
return nil
}
// camelToSnake 将 camelCase 转为 SCREAMING_SNAKESessionTTL → SESSION_TTL
// OTPTTL → OTP_TTLTrustedProxies → TRUSTED_PROXIES)。
func camelToSnake(s string) string {
var b strings.Builder
runes := []rune(s)
for i, r := range runes {
if unicode.IsUpper(r) {
// 单词边界:前一个字符是小写/数字,或当前大写且其后是小写(连续大写结尾)
if i > 0 {
prev := runes[i-1]
nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1])
if unicode.IsLower(prev) || unicode.IsDigit(prev) || nextLower && unicode.IsUpper(prev) {
b.WriteByte('_')
}
}
b.WriteRune(unicode.ToLower(r))
} else {
b.WriteRune(r)
}
}
return b.String()
}