diff --git a/server/internal/notices/service.go b/server/internal/notices/service.go new file mode 100644 index 0000000..b005e7b --- /dev/null +++ b/server/internal/notices/service.go @@ -0,0 +1,160 @@ +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 +} diff --git a/server/internal/notices/service_test.go b/server/internal/notices/service_test.go new file mode 100644 index 0000000..1bcfdc3 --- /dev/null +++ b/server/internal/notices/service_test.go @@ -0,0 +1,246 @@ +package notices + +import ( + "context" + "database/sql" + "errors" + "sync" + "testing" + "time" +) + +// fakeMailer 记录每次 Send 调用,便于断言收件人集合与失败模拟。 +type fakeMailer struct { + mu sync.Mutex + sent []string // to + failTo map[string]bool +} + +func (f *fakeMailer) Send(ctx context.Context, to, subject, body string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.failTo != nil && f.failTo[to] { + return errors.New("fake smtp send failure") + } + f.sent = append(f.sent, to) + return nil +} + +type auditRow struct { + Target string + Meta string +} + +func auditRows(t *testing.T, db *sql.DB, action string) []auditRow { + t.Helper() + rows, err := db.Query(`SELECT target, COALESCE(meta,'') FROM audit_log WHERE action=? ORDER BY id`, action) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []auditRow + for rows.Next() { + var r auditRow + if err := rows.Scan(&r.Target, &r.Meta); err != nil { + t.Fatal(err) + } + out = append(out, r) + } + return out +} + +func TestPublish_ValidationErrors(t *testing.T) { + db := openDB(t) + st := NewStore(db) + svc := NewService(st, nil, db) + ctx := context.Background() + + cases := []struct { + name string + in PublishInput + }{ + {"bad type", PublishInput{Type: "spam", TitleZH: "标题", TitleEN: "title"}}, + {"empty title zh", PublishInput{Type: "news", TitleZH: "", TitleEN: "title"}}, + {"empty title en", PublishInput{Type: "news", TitleZH: "标题", TitleEN: ""}}, + {"email non-important", PublishInput{Type: "news", TitleZH: "标题", TitleEN: "title", Email: true}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := svc.Publish(ctx, c.in); err == nil { + t.Fatalf("want error, got nil") + } + }) + } +} + +func TestPublish_SuccessNoEmail(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + svc := NewService(st, nil, db) + ctx := context.Background() + + id, err := svc.Publish(ctx, PublishInput{ + Type: "news", TitleZH: "标题", TitleEN: "title", BodyZH: "正文", BodyEN: "body", + }) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if id == 0 { + t.Fatalf("want non-zero id") + } + + items, _, err := st.ListForUser(ctx, 1, time.Now().UTC(), 50) + if err != nil { + t.Fatal(err) + } + found := false + for _, it := range items { + if it.ID == id { + found = true + } + } + if !found { + t.Fatalf("published notice not visible via ListForUser: %+v", items) + } + + rows := auditRows(t, db, "notice_publish") + if len(rows) != 1 { + t.Fatalf("want 1 audit row, got %d: %+v", len(rows), rows) + } +} + +func TestPublish_EmailImportant_AllActiveUsersNotified(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + seedU(t, db, 2, "u2") + st := NewStore(db) + mailer := &fakeMailer{} + svc := NewService(st, mailer, db) + ctx := context.Background() + + id, err := svc.Publish(ctx, PublishInput{ + Type: "important", TitleZH: "重要通知", TitleEN: "Important notice", + BodyZH: "维护中", BodyEN: "under maintenance", Email: true, + }) + if err != nil { + t.Fatalf("Publish: %v", err) + } + + mailer.mu.Lock() + sentCount := len(mailer.sent) + mailer.mu.Unlock() + if sentCount != 2 { + t.Fatalf("want 2 emails sent (all active users), got %d: %+v", sentCount, mailer.sent) + } + + var emailSentAt sql.NullTime + if err := db.QueryRow(`SELECT email_sent_at FROM notices WHERE id=?`, id).Scan(&emailSentAt); err != nil { + t.Fatal(err) + } + if !emailSentAt.Valid { + t.Fatalf("email_sent_at not set after Publish with Email=true") + } + + // 再次 Publish 是新公告(幂等的是单公告不重发,不是全局):第二条也应各自发全量邮件。 + id2, err := svc.Publish(ctx, PublishInput{ + Type: "important", TitleZH: "第二条", TitleEN: "Second notice", Email: true, + }) + if err != nil { + t.Fatalf("Publish #2: %v", err) + } + if id2 == id { + t.Fatalf("want new notice id, got same as first") + } + mailer.mu.Lock() + sentCount2 := len(mailer.sent) + mailer.mu.Unlock() + if sentCount2 != 4 { + t.Fatalf("want 4 emails total after 2nd publish, got %d: %+v", sentCount2, mailer.sent) + } +} + +func TestPublish_EmailSendFailure_ContinuesAndStillMarksSent(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + seedU(t, db, 2, "u2") + st := NewStore(db) + mailer := &fakeMailer{failTo: map[string]bool{"u1@x": true}} + svc := NewService(st, mailer, db) + ctx := context.Background() + + id, err := svc.Publish(ctx, PublishInput{ + Type: "important", TitleZH: "重要", TitleEN: "Important", Email: true, + }) + if err != nil { + t.Fatalf("Publish should not fail even if one recipient's send fails: %v", err) + } + mailer.mu.Lock() + sent := append([]string(nil), mailer.sent...) + mailer.mu.Unlock() + if len(sent) != 1 || sent[0] != "u2@x" { + t.Fatalf("want only u2@x to have succeeded, got %+v", sent) + } + + var emailSentAt sql.NullTime + if err := db.QueryRow(`SELECT email_sent_at FROM notices WHERE id=?`, id).Scan(&emailSentAt); err != nil { + t.Fatal(err) + } + if !emailSentAt.Valid { + t.Fatalf("email_sent_at should still be set after best-effort send attempt") + } +} + +func TestPublish_EmailTrueWithNilMailer_ReturnsError(t *testing.T) { + db := openDB(t) + st := NewStore(db) + svc := NewService(st, nil, db) + ctx := context.Background() + + if _, err := svc.Publish(ctx, PublishInput{ + Type: "important", TitleZH: "标题", TitleEN: "title", Email: true, + }); err == nil { + t.Fatalf("want error when mailer is nil and Email=true") + } +} + +func TestRevokeByID_HidesFromListAndAudits(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + svc := NewService(st, nil, db) + ctx := context.Background() + + id, err := svc.Publish(ctx, PublishInput{Type: "news", TitleZH: "标题", TitleEN: "title"}) + if err != nil { + t.Fatal(err) + } + + items, _, _ := st.ListForUser(ctx, 1, time.Now().UTC(), 50) + if len(items) != 1 { + t.Fatalf("expected notice visible before revoke, got %d", len(items)) + } + + if err := svc.RevokeByID(ctx, id); err != nil { + t.Fatalf("RevokeByID: %v", err) + } + + items, _, _ = st.ListForUser(ctx, 1, time.Now().UTC(), 50) + if len(items) != 0 { + t.Fatalf("expected no notices visible after revoke, got %+v", items) + } + + rows := auditRows(t, db, "notice_revoke") + if len(rows) != 1 { + t.Fatalf("want 1 audit row for revoke, got %d: %+v", len(rows), rows) + } +} + +func TestRevokeByID_NotFound(t *testing.T) { + db := openDB(t) + st := NewStore(db) + svc := NewService(st, nil, db) + if err := svc.RevokeByID(context.Background(), 9999); err == nil { + t.Fatalf("want error for nonexistent notice id") + } +}