feat(server/notices): Service — 发布校验/审计/important 邮件兜底(幂等)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user