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
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
"os"
"strings"
"golang.org/x/term"
"ws_usernode/internal/service"
)
// cmdAdmin 处理 admin 子命令(create / reset-password)。
func cmdAdmin(args []string) error {
if len(args) == 0 {
return fmt.Errorf("用法: usernode admin <create|reset-password> [flags]")
}
switch args[0] {
case "create":
return adminCreate(args[1:])
case "reset-password":
return adminResetPassword(args[1:])
default:
return fmt.Errorf("未知 admin 子命令 %q", args[0])
}
}
func adminCreate(args []string) error {
fs := flag.NewFlagSet("admin create", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
username := fs.String("username", "", "管理员用户名")
password := fs.String("password", "", "管理员密码(留空则交互输入,不回显)")
email := fs.String("email", "", "管理员邮箱")
if err := fs.Parse(args); err != nil {
return err
}
_, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
if *username == "" {
return fmt.Errorf("--username 必填")
}
if *password == "" {
p, err := promptPassword("请输入管理员密码(至少 8 位,含字母与数字): ")
if err != nil {
return err
}
password = &p
}
if *email == "" {
return fmt.Errorf("--email 必填(用于邮件重置密码与通知)")
}
adm, err := service.NewAdminService(db).Create(context.Background(), *username, *password, *email)
if err != nil {
return err
}
log.Info("admin created", "id", adm.ID, "username", adm.Username)
return nil
}
func adminResetPassword(args []string) error {
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
username := fs.String("username", "", "管理员用户名")
password := fs.String("password", "", "新密码(留空则交互输入,不回显)")
if err := fs.Parse(args); err != nil {
return err
}
_, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
if *username == "" {
return fmt.Errorf("--username 必填")
}
if *password == "" {
p, err := promptPassword("请输入新密码(至少 8 位,含字母与数字): ")
if err != nil {
return err
}
password = &p
}
if err := service.NewAdminService(db).ResetPassword(context.Background(), *username, *password); err != nil {
return err
}
log.Info("admin password reset", "username", *username)
return nil
}
// promptPassword 从终端读取密码(不回显);非终端环境退回普通行读取。
func promptPassword(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
if term.IsTerminal(int(os.Stdin.Fd())) {
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", err
}
return string(b), nil
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(line), nil
}
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"flag"
"fmt"
"log/slog"
"os"
robfigcron "github.com/robfig/cron/v3"
"gorm.io/gorm"
"ws_usernode/internal/api"
"ws_usernode/internal/config"
"ws_usernode/internal/cron"
"ws_usernode/internal/model"
"ws_usernode/internal/router"
"ws_usernode/internal/server"
"ws_usernode/internal/service"
"ws_usernode/internal/system"
)
// bootstrap 加载配置、打开数据库、构建日志。
// cfgPath 为配置文件路径,debug 开启调试日志与 GORM SQL 日志。
func bootstrap(cfgPath string, debug bool) (*config.Config, *slog.Logger, *gorm.DB, error) {
cfg, err := config.Load(cfgPath)
if err != nil {
return nil, nil, nil, err
}
if debug {
cfg.Log.Level = "debug"
}
log := newLogger(cfg.Log.Level, cfg.Log.Format)
db, err := model.Open(cfg.Database.Driver, cfg.Database.DSN, debug)
if err != nil {
return nil, nil, nil, fmt.Errorf("打开数据库: %w", err)
}
return cfg, log, db, nil
}
// commonFlags 定义所有子命令共享的 --config / --debug。
func commonFlags(fs *flag.FlagSet) (cfgPath *string, debug *bool) {
cfgPath = fs.String("config", "config.toml", "配置文件路径(默认 config.toml")
debug = fs.Bool("debug", false, "调试模式(debug 日志 + GORM SQL 日志)")
return cfgPath, debug
}
// newLogger 按配置构建 slog 输出器。
func newLogger(level, format string) *slog.Logger {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: lvl}
var h slog.Handler
if format == "json" {
h = slog.NewJSONHandler(os.Stderr, opts)
} else {
h = slog.NewTextHandler(os.Stderr, opts)
}
return slog.New(h)
}
// serve 子命令:启动 HTTP 服务(自动迁移 + 定时任务)。
func cmdServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
cfg, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
sys := system.New(cfg.System)
adminSvc := service.NewAdminService(db)
userSvc := service.NewUserService(db, sys)
auditSvc := service.NewAuditService(db)
// 启动前自动迁移(骨架阶段保证表结构就绪;M5 部署建议显式 migrate)
if err := model.Migrate(db); err != nil {
return fmt.Errorf("数据库迁移: %w", err)
}
log.Info("db: migrated", "driver", cfg.Database.Driver, "dsn", cfg.Database.DSN)
// 定时任务(骨架:M1/M4 填充过期扫描/回收/审计归档)
sched := robfigcron.New()
cron.New(db, log).Register(sched)
sched.Start()
defer sched.Stop()
h := api.New(adminSvc, userSvc, auditSvc)
r := router.New(cfg, h, log)
srv := server.New(cfg.Server.Listen, r, log)
if err := srv.Run(); err != nil {
return fmt.Errorf("服务异常退出: %w", err)
}
return nil
}
// migrate 子命令:执行数据库迁移。
func cmdMigrate(args []string) error {
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
if err := fs.Parse(args); err != nil {
return err
}
cfg, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
if err := model.Migrate(db); err != nil {
return fmt.Errorf("数据库迁移: %w", err)
}
log.Info("db: migration complete", "driver", cfg.Database.Driver)
return nil
}
+62
View File
@@ -0,0 +1,62 @@
// ws_usernode —— 服务器用户管理节点。
//
// 用法:
//
// usernode serve [--config config.toml] 启动 HTTP 服务
// usernode migrate [--config config.toml] 执行数据库迁移
// usernode admin create [--config ...] --username x --password y --email z
// usernode admin reset-password [--config ...] --username x --password y
// usernode user otp [--config ...] --username ext_xxx 获取外部用户 OTP(与邮件对齐)
package main
import (
"fmt"
"os"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "usernode:", err)
os.Exit(1)
}
}
func run(args []string) error {
if len(args) == 0 {
return usage()
}
switch args[0] {
case "serve":
return cmdServe(args[1:])
case "migrate":
return cmdMigrate(args[1:])
case "admin":
return cmdAdmin(args[1:])
case "user":
return cmdUser(args[1:])
case "help", "-h", "--help":
return usage()
default:
return fmt.Errorf("未知子命令 %q(可用:serve / migrate / admin / user / help", args[0])
}
}
func usage() error {
fmt.Fprint(os.Stdout, `ws_usernode —— 服务器用户管理节点
用法:
usernode serve [--config <path>] 启动 HTTP 服务
usernode migrate [--config <path>] 执行数据库迁移(建表)
usernode admin create --username <x> --password <y> --email <z> [--config <path>]
创建初始管理员
usernode admin reset-password --username <x> --password <y> [--config <path>]
重置管理员密码
usernode user otp --username <ext_xxx> [--config <path>]
获取外部用户 OTP 验证码(与邮件通道一致)
全局:
--config 配置文件路径(默认 config.toml,不存在则用默认值 + 环境变量)
--debug 调试模式(debug 日志 + GORM SQL 日志)
`)
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"context"
"flag"
"fmt"
"strings"
"time"
"ws_usernode/internal/auth"
"ws_usernode/internal/service"
"ws_usernode/internal/system"
)
// cmdUser 处理 user 子命令(当前仅 otp)。
func cmdUser(args []string) error {
if len(args) == 0 {
return fmt.Errorf("用法: usernode user <otp> [flags]")
}
switch args[0] {
case "otp":
return userOTP(args[1:])
default:
return fmt.Errorf("未知 user 子命令 %q", args[0])
}
}
// userOTP 获取外部用户 OTP 验证码,与邮件通道共用同一存储与限速
// (同一验证码、同一 10 分钟有效期、同一 60s 冷却与失败限速)。
func userOTP(args []string) error {
fs := flag.NewFlagSet("user otp", flag.ContinueOnError)
cfgPath, debug := commonFlags(fs)
username := fs.String("username", "", "外部用户系统账号(如 ext_zhangsan")
if err := fs.Parse(args); err != nil {
return err
}
cfg, log, db, err := bootstrap(*cfgPath, *debug)
if err != nil {
return err
}
if *username == "" {
return fmt.Errorf("--username 必填")
}
// 规范化:补全 ext_ 前缀
name := strings.TrimSpace(*username)
if !strings.HasPrefix(name, cfg.System.UserPrefix) {
name = cfg.System.UserPrefix + name
}
// 校验用户存在(不存在时返回友好错误,避免暴露账号是否存在的枚举)
if _, err := service.NewUserService(db, system.New(cfg.System)).GetByUsername(context.Background(), name); err != nil {
return fmt.Errorf("用户不存在或不可用: %w", err)
}
// M0 骨架:CLI 独立生成(内存 store 与运行中服务不共享)。
// 生产对齐(同一验证码/冷却/限速跨通道生效)需 OTP 落 DB,M1 实现
// auth.OTPStore 的 DB 实现后,CLI 与邮件通道读写同一存储。
store := auth.NewMemoryOTPStore()
code, err := store.Send(name, cfg.Policy.OTPTTL, cfg.Policy.OTPCooldown)
if err != nil {
if err == auth.ErrCooldown {
return fmt.Errorf("发送冷却中,请稍后重试(冷却 %s)", cfg.Policy.OTPCooldown)
}
return err
}
log.Info("otp generated",
"username", name,
"valid_for", cfg.Policy.OTPTTL.String(),
"expires_at", time.Now().Add(cfg.Policy.OTPTTL).Format(time.RFC3339),
"hint", "与邮件通道为同一验证码,登录后立即失效",
)
fmt.Printf("OTP for %s: %s\n", name, code)
return nil
}