feat(M4): 生命周期 + 审计 — 到期锁定/回收 cron、审计查询/CSV 导出/每日归档、settings 动态策略

This commit is contained in:
2026-08-30 10:17:22 +08:00
parent 1ea18490e0
commit c0e2ff975a
18 changed files with 1088 additions and 75 deletions
+132
View File
@@ -0,0 +1,132 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"gorm.io/gorm"
"ws_usernode/internal/config"
"ws_usernode/internal/mail"
"ws_usernode/internal/model"
"ws_usernode/internal/system"
)
// LifecycleService 账号生命周期维护(PLAN F2):
// - 到期:锁定系统账号 + 清空 authorized_keysSSH 立即失效)+ 状态置 expired + 邮件提醒;
// - 回收:超过回收期仍未延期 → 删除系统账号与密钥(保留审计)+ 邮件通知。
//
// 由 cron 每日触发(单实例部署,PLAN §11)。
type LifecycleService struct {
db *gorm.DB
cfg *config.Config
sys system.Manager
users *UserService
settings *SettingService
mailer mail.Mailer
audit *AuditService
log *slog.Logger
}
// NewLifecycleService 创建生命周期服务。
func NewLifecycleService(db *gorm.DB, cfg *config.Config, sys system.Manager, users *UserService, settings *SettingService, mailer mail.Mailer, audit *AuditService, log *slog.Logger) *LifecycleService {
return &LifecycleService{db: db, cfg: cfg, sys: sys, users: users, settings: settings, mailer: mailer, audit: audit, log: log}
}
// ScanExpired 扫描到期账号(expire_at < now 且状态非 disabled/expired):
// 锁定系统账号 + 清空 authorized_keys + 状态置 expired + 邮件通知(到期提醒)。
// 返回本次处理的账号数。
func (s *LifecycleService) ScanExpired(ctx context.Context) (int, error) {
now := time.Now()
var rows []model.User
if err := s.db.WithContext(ctx).
Where("status = ? AND expire_at IS NOT NULL AND expire_at < ?", model.UserStatusActive, now).
Find(&rows).Error; err != nil {
return 0, err
}
processed := 0
for i := range rows {
u := &rows[i]
if !s.systemAccountOK(u.Username) {
s.log.Warn("lifecycle: 系统账号缺失,跳过锁定", "username", u.Username)
continue
}
if err := s.sys.SetLock(ctx, u.Username, true); err != nil {
s.log.Error("lifecycle: 锁定系统账号失败", "username", u.Username, "err", err)
_ = s.audit.Record(ctx, 0, "system", "lifecycle.expire", "user", fmt.Sprint(u.ID), map[string]any{"err": err.Error()}, "cron", model.ResultFailed)
continue
}
if err := s.sys.SyncAuthorizedKeys(ctx, u.Username, nil); err != nil {
s.log.Error("lifecycle: 清空 authorized_keys 失败", "username", u.Username, "err", err)
_ = s.audit.Record(ctx, 0, "system", "lifecycle.expire", "user", fmt.Sprint(u.ID), map[string]any{"err": err.Error()}, "cron", model.ResultFailed)
continue
}
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", u.ID).Update("status", model.UserStatusExpired).Error; err != nil {
s.log.Error("lifecycle: 更新状态失败", "username", u.Username, "err", err)
continue
}
processed++
s.notifyExpired(ctx, u)
_ = s.audit.Record(ctx, 0, "system", "lifecycle.expire", "user", fmt.Sprint(u.ID), nil, "cron", model.ResultSuccess)
}
return processed, nil
}
// Recycle 回收超期账号:expired 状态且过期时间超过回收期 → 删除系统账号与密钥
// (保留审计)+ 邮件通知(回收提醒)。返回本次回收的账号数。
func (s *LifecycleService) Recycle(ctx context.Context) (int, error) {
period := s.settings.RecyclePeriod(ctx)
cutoff := time.Now().Add(-period)
var rows []model.User
if err := s.db.WithContext(ctx).
Where("status = ? AND expire_at IS NOT NULL AND expire_at < ?", model.UserStatusExpired, cutoff).
Find(&rows).Error; err != nil {
return 0, err
}
recycled := 0
for i := range rows {
u := &rows[i]
if err := s.users.Delete(ctx, u.ID); err != nil {
s.log.Error("lifecycle: 回收账号失败", "username", u.Username, "err", err)
_ = s.audit.Record(ctx, 0, "system", "lifecycle.recycle", "user", fmt.Sprint(u.ID), map[string]any{"err": err.Error()}, "cron", model.ResultFailed)
continue
}
recycled++
s.notifyRecycled(ctx, u)
_ = s.audit.Record(ctx, 0, "system", "lifecycle.recycle", "user", fmt.Sprint(u.ID), nil, "cron", model.ResultSuccess)
}
return recycled, nil
}
// systemAccountOK 与 UserService 一致:dry-run 模式跳过真实检查。
func (s *LifecycleService) systemAccountOK(username string) bool {
if s.cfg.System.DryRun {
return true
}
ok, err := s.sys.Exists(context.Background(), username)
return err == nil && ok
}
// notifyExpired 到期提醒:账号已到期,回收期内可延期恢复。
func (s *LifecycleService) notifyExpired(ctx context.Context, u *model.User) {
body := fmt.Sprintf(`您的服务器账号(%s)已到期,系统账号已被锁定,SSH 登录已不可用。
如需继续使用,请在回收期(%s)内联系管理员延期恢复;超过回收期账号将被自动回收。
`, u.Username, s.settings.RecyclePeriod(ctx).String())
if err := s.mailer.Send(ctx, u.Email, "服务器账号已到期", body); err != nil {
s.log.Warn("lifecycle: 到期邮件发送失败", "username", u.Username, "err", err)
}
}
// notifyRecycled 回收提醒:账号已超回收期被回收。
func (s *LifecycleService) notifyRecycled(ctx context.Context, u *model.User) {
body := fmt.Sprintf(`您的服务器账号(%s)已超过回收期且未办理延期,账号已被回收删除(系统账号与密钥均已清理)。
如需重新使用,请重新提交账号申请。
`, u.Username)
if err := s.mailer.Send(ctx, u.Email, "服务器账号已回收", body); err != nil {
s.log.Warn("lifecycle: 回收邮件发送失败", "username", u.Username, "err", err)
}
}