package notices import ( "context" "database/sql" "encoding/json" "fmt" "log/slog" "strings" "time" ) // EmailSender 抽象邮件兜底通道(生产实现按 SMTP/第三方 API 注入,测试用 fake)。 type EmailSender interface { Send(ctx context.Context, to, subject, body string) error } // validNoticeTypes 与 migrations/sqlite/000026_notices.up.sql 的 CHECK 约束一致, // 六值全部放行(nodectl 手发场景约束更窄,交由调用层——如 nodectl 命令——自行收紧)。 var validNoticeTypes = map[string]bool{ "important": true, "feature": true, "news": true, "reward": true, "version": true, "promo": true, } // PublishInput 是 Service.Publish 的入参;Email=true 仅 important 类型允许。 type PublishInput struct { Type string TitleZH string TitleEN string BodyZH string BodyEN string Link string ExpiresAt *time.Time Email bool // 仅 important 允许 true } // Service 承载通知发布/撤回的业务规则:字段校验、审计留痕、important 邮件兜底。 // // 审计选型(简案,见 task-5-brief):Service 直接持 *sql.DB 自写 // `INSERT INTO audit_log(actor,action,target,meta,at)`,不复用 codes.Store.WriteAuditLog—— // 避免 notices 包为了一个方法反向依赖 codes 包,保持依赖方向干净。actor 固定为 "nodectl" // (当前唯一调用方是后台管理命令);失败仅记录,不影响主流程(照仓库既有审计写入惯例)。 type Service struct { st *Store mailer EmailSender db *sql.DB } // NewService 组装通知发布服务。mailer 可为 nil(仅当调用方保证不会传 Email=true 时安全)。 func NewService(st *Store, mailer EmailSender, db *sql.DB) *Service { return &Service{st: st, mailer: mailer, db: db} } func (s *Service) writeAudit(ctx context.Context, action, target string, meta map[string]any) { metaJSON := "null" if meta != nil { if b, err := json.Marshal(meta); err == nil { metaJSON = string(b) } } _, err := s.db.ExecContext(ctx, `INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`, "nodectl", action, target, metaJSON, time.Now().UTC()) if err != nil { // 审计失败不影响主流程,照仓库惯例仅记录。 slog.Warn("notices: write audit log failed", "action", action, "target", target, "err", err) } } // Publish 校验并落库一条广播通知;Email=true 时对全体 active 用户尽力发送兜底邮件 // (单个失败仅记录日志、不中断,全部尝试后置位 email_sent_at)。 func (s *Service) Publish(ctx context.Context, in PublishInput) (int64, error) { if !validNoticeTypes[in.Type] { return 0, fmt.Errorf("notices: invalid type %q", in.Type) } if strings.TrimSpace(in.TitleZH) == "" { return 0, fmt.Errorf("notices: title_zh required") } if strings.TrimSpace(in.TitleEN) == "" { return 0, fmt.Errorf("notices: title_en required") } if in.Email && in.Type != "important" { return 0, fmt.Errorf("notices: email fallback only allowed for type=important, got %q", in.Type) } if in.Email && s.mailer == nil { return 0, fmt.Errorf("notices: email requested but no mailer configured") } now := time.Now().UTC() id, err := s.st.InsertBroadcast(ctx, in.Type, in.TitleZH, in.TitleEN, in.BodyZH, in.BodyEN, in.Link, now, in.ExpiresAt) if err != nil { return 0, fmt.Errorf("notices: insert broadcast: %w", err) } s.writeAudit(ctx, "notice_publish", fmt.Sprintf("notice:%d", id), map[string]any{ "type": in.Type, "title_zh": in.TitleZH, }) if in.Email { s.sendEmailFallback(ctx, id, in) } return id, nil } // sendEmailFallback 对全体 active 用户逐个发送兜底邮件;单个失败仅记录日志继续, // 全部尝试后统一置位 email_sent_at(幂等:Store.MarkEmailSent 只在未发送过时置位)。 func (s *Service) sendEmailFallback(ctx context.Context, id int64, in PublishInput) { emails, err := s.st.ListActiveUserEmails(ctx) if err != nil { slog.Warn("notices: list active user emails failed", "notice_id", id, "err", err) return } subject := fmt.Sprintf("【穿山甲】%s / %s", in.TitleZH, in.TitleEN) body := emailBody(in) for _, to := range emails { if err := s.mailer.Send(ctx, to, subject, body); err != nil { slog.Warn("notices: send email fallback failed", "notice_id", id, "to", to, "err", err) continue } } if err := s.st.MarkEmailSent(ctx, id, time.Now().UTC()); err != nil { slog.Warn("notices: mark email sent failed", "notice_id", id, "err", err) } } func emailBody(in PublishInput) string { var b strings.Builder b.WriteString(in.TitleZH) b.WriteString(" / ") b.WriteString(in.TitleEN) b.WriteString("\n\n") if in.BodyZH != "" { b.WriteString(in.BodyZH) b.WriteString("\n\n") } if in.BodyEN != "" { b.WriteString(in.BodyEN) b.WriteString("\n\n") } b.WriteString("在 App 内查看详情 / View details in the app") return b.String() } // RevokeByID 撤回一条通知并留痕审计;目标不存在或已撤回时返回错误。 func (s *Service) RevokeByID(ctx context.Context, id int64) error { if err := s.st.Revoke(ctx, id, time.Now().UTC()); err != nil { return fmt.Errorf("notices: revoke %d: %w", id, err) } s.writeAudit(ctx, "notice_revoke", fmt.Sprintf("notice:%d", id), nil) return nil }