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_keys(SSH 立即失效)+ 状态置 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) } }