Files
usernode/internal/service/lifecycle.go
T

133 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}