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
+115
View File
@@ -0,0 +1,115 @@
// Package service 承载业务逻辑,隔离 handler 与数据层/系统层。
package service
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
"ws_usernode/internal/auth"
"ws_usernode/internal/model"
"ws_usernode/internal/pkg"
)
// 管理员服务错误。
var (
ErrAdminExists = errors.New("service: 管理员已存在")
ErrAdminNotFound = errors.New("service: 管理员不存在")
ErrWeakPassword = errors.New("service: 密码过弱(至少 8 位,需含字母与数字)")
)
// AdminService 管理端账号服务(CLI admin create / reset-password 与登录共用)。
type AdminService struct {
db *gorm.DB
}
// NewAdminService 创建管理员服务。
func NewAdminService(db *gorm.DB) *AdminService {
return &AdminService{db: db}
}
// Create 创建初始管理员。password 为空时由调用方提示交互输入。
func (s *AdminService) Create(ctx context.Context, username, password, email string) (*model.AdminUser, error) {
if err := validatePassword(password); err != nil {
return nil, err
}
if err := pkg.ValidateEmail(email); err != nil {
return nil, err
}
username = strings.TrimSpace(username)
var count int64
if err := s.db.WithContext(ctx).Model(&model.AdminUser{}).Where("username = ?", username).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, ErrAdminExists
}
hash, err := auth.HashPassword(password)
if err != nil {
return nil, err
}
adm := &model.AdminUser{
Username: username,
PasswordHash: hash,
Email: email,
Role: "admin",
Status: model.StatusActive,
}
if err := s.db.WithContext(ctx).Create(adm).Error; err != nil {
return nil, err
}
return adm, nil
}
// ResetPassword 重置管理员密码(CLI 或邮件重置兜底)。
func (s *AdminService) ResetPassword(ctx context.Context, username, newPassword string) error {
if err := validatePassword(newPassword); err != nil {
return err
}
var adm model.AdminUser
if err := s.db.WithContext(ctx).First(&adm, "username = ?", username).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAdminNotFound
}
return err
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
return err
}
return s.db.WithContext(ctx).Model(&adm).Update("password_hash", hash).Error
}
// GetByUsername 按用户名查询管理员。
func (s *AdminService) GetByUsername(ctx context.Context, username string) (*model.AdminUser, error) {
var adm model.AdminUser
if err := s.db.WithContext(ctx).First(&adm, "username = ?", username).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAdminNotFound
}
return nil, err
}
return &adm, nil
}
// validatePassword 校验管理员密码强度(骨架阶段基础规则,M1 可加策略)。
func validatePassword(p string) error {
if len(p) < 8 {
return ErrWeakPassword
}
hasLetter, hasDigit := false, false
for _, r := range p {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
hasLetter = true
}
if r >= '0' && r <= '9' {
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return ErrWeakPassword
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package service
import (
"context"
"encoding/json"
"errors"
"io"
"time"
"gorm.io/gorm"
"ws_usernode/internal/model"
)
// AuditService 审计服务:append-only 记录(业务代码仅允许 INSERT),
// 保留策略与 CSV 导出归档在 M4 完成,这里给出接口与最小实现。
type AuditService struct {
db *gorm.DB
}
// NewAuditService 创建审计服务。
func NewAuditService(db *gorm.DB) *AuditService {
return &AuditService{db: db}
}
// Record 记录一条管理操作审计。detail 为任意结构体,入库前 JSON 序列化。
func (s *AuditService) Record(ctx context.Context, actorID uint, actorName, action, resourceType, resourceID string, detail any, ip, result string) error {
b, err := json.Marshal(detail)
if err != nil {
return err
}
entry := model.AuditLog{
ActorID: actorID,
ActorName: actorName,
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
Detail: string(b),
IP: ip,
Result: result,
}
return s.db.WithContext(ctx).Create(&entry).Error
}
// ExportCSV 导出审计为 CSV。M0 骨架:返回占位错误,M4 实现手动导出 + 每日归档。
func (s *AuditService) ExportCSV(ctx context.Context, w io.Writer, since, until *time.Time) error {
_ = w
_ = since
_ = until
return errors.New("service: 审计 CSV 导出将在 M4 实现")
}
+82
View File
@@ -0,0 +1,82 @@
package service
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
"ws_usernode/internal/model"
"ws_usernode/internal/pkg"
"ws_usernode/internal/system"
)
// 用户服务错误。
var (
ErrUserNotFound = errors.New("service: 用户不存在")
ErrUserExists = errors.New("service: 用户名已存在")
)
// UserService 外部用户生命周期服务。
// M0 提供查询与创建骨架;建号(useradd)、禁用/延期等系统操作 M1 接入 system.Manager。
type UserService struct {
db *gorm.DB
sys system.Manager
}
// NewUserService 创建用户服务。
func NewUserService(db *gorm.DB, sys system.Manager) *UserService {
return &UserService{db: db, sys: sys}
}
// GetByUsername 按用户名查询外部用户(含或不含 ext_ 前缀均可)。
func (s *UserService) GetByUsername(ctx context.Context, username string) (*model.User, error) {
if !strings.HasPrefix(username, "ext_") {
username = "ext_" + username
}
var u model.User
if err := s.db.WithContext(ctx).First(&u, "username = ?", username).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return &u, nil
}
// Create 创建外部用户记录并调用系统层建号。M0 阶段系统层为 dry-run。
// username 为不含前缀的申请名,内部加 ext_ 前缀。
func (s *UserService) Create(ctx context.Context, username, email, supervisor, purpose string, ttlSeconds int64) (*model.User, error) {
if err := pkg.ValidateUserName(username); err != nil {
return nil, err
}
if err := pkg.ValidateEmail(email); err != nil {
return nil, err
}
full := "ext_" + username
var count int64
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("username = ?", full).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, ErrUserExists
}
u := &model.User{
Username: full,
Email: email,
Supervisor: supervisor,
Purpose: purpose,
Status: model.UserStatusActive,
Shell: "/bin/sh",
}
if err := s.db.WithContext(ctx).Create(u).Error; err != nil {
return nil, err
}
// 系统账号创建(dry-run / 真实),失败时回滚 DB 记录
if err := s.sys.CreateUser(ctx, system.Account{Username: full}); err != nil {
_ = s.db.WithContext(ctx).Delete(u).Error
return nil, err
}
return u, nil
}