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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user