From ed1f805cbc9ee29a04375030119f075bace3ef7d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 13 Jul 2026 13:36:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(server/notices):=20Store=20=E2=80=94=20?= =?UTF-8?q?=E5=B9=BF=E6=92=AD=E2=88=AA=E5=AE=9A=E5=90=91=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2/=E5=B7=B2=E8=AF=BB=E6=B0=B4=E4=BD=8D/?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E5=86=85=E6=8F=92=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu --- server/internal/notices/store.go | 220 +++++++++++++++++++ server/internal/notices/store_sqlite_test.go | 96 ++++++++ 2 files changed, 316 insertions(+) create mode 100644 server/internal/notices/store.go create mode 100644 server/internal/notices/store_sqlite_test.go diff --git a/server/internal/notices/store.go b/server/internal/notices/store.go new file mode 100644 index 0000000..8ba3bfa --- /dev/null +++ b/server/internal/notices/store.go @@ -0,0 +1,220 @@ +// Package notices 承载系统通知(广播 + 定向)的数据访问层:合并查询、已读水位、 +// 事务内插入(供其他领域事件钩子复用)、后台管理(nodectl)增删改查。 +package notices + +import ( + "context" + "database/sql" + "time" +) + +// Notice 是对外(API/nodectl)的通知视图。json tag 是 API 契约,勿改。 +type Notice struct { + ID int64 `json:"id"` + Type string `json:"type"` + UserID *int64 `json:"-"` + TitleZH string `json:"title_zh"` + TitleEN string `json:"title_en"` + BodyZH string `json:"body_zh,omitempty"` + BodyEN string `json:"body_en,omitempty"` + Link string `json:"link,omitempty"` + PublishedAt time.Time `json:"published_at"` + Unread bool `json:"unread"` +} + +// AdminRow 是 nodectl list 消费的后台视图:不含正文,多一个 status 摘要字段。 +type AdminRow struct { + ID int64 + Type string + TitleZH string + PublishedAt time.Time + Status string // active|revoked|expired +} + +type Store struct{ db *sql.DB } + +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// ListForUser 返回广播(user_id IS NULL)∪ 定向(user_id=uid)的未撤回、未过期通知, +// 按 published_at 倒序,并根据 users.notices_read_at 水位计算每条 Unread 与 unreadCount。 +// 水位为 NULL 时按零值时间处理,即全部未读。 +func (s *Store) ListForUser(ctx context.Context, uid int64, now time.Time, limit int) ([]Notice, int, error) { + var readAt sql.NullTime + if err := s.db.QueryRowContext(ctx, `SELECT notices_read_at FROM users WHERE id=?`, uid).Scan(&readAt); err != nil { + return nil, 0, err + } + watermark := time.Time{} + if readAt.Valid { + watermark = readAt.Time + } + + rows, err := s.db.QueryContext(ctx, ` + SELECT id, type, user_id, title_zh, title_en, COALESCE(body_zh,''), COALESCE(body_en,''), + COALESCE(link,''), published_at + FROM notices + WHERE (user_id IS NULL OR user_id = ?) + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) + ORDER BY published_at DESC + LIMIT ? + `, uid, now, limit) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var items []Notice + unreadCount := 0 + for rows.Next() { + var n Notice + var userID sql.NullInt64 + if err := rows.Scan(&n.ID, &n.Type, &userID, &n.TitleZH, &n.TitleEN, &n.BodyZH, &n.BodyEN, &n.Link, &n.PublishedAt); err != nil { + return nil, 0, err + } + if userID.Valid { + id := userID.Int64 + n.UserID = &id + } + n.Unread = n.PublishedAt.After(watermark) + if n.Unread { + unreadCount++ + } + items = append(items, n) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return items, unreadCount, nil +} + +// MarkRead 把用户的通知已读水位推进到 at。 +func (s *Store) MarkRead(ctx context.Context, uid int64, at time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET notices_read_at=? WHERE id=?`, at, uid) + return err +} + +// InsertNoticeTx 在调用方已开的事务内插入一条定向通知(不自开/自提交事务), +// 供其他领域的事件钩子(如奖励发放)在同一事务里原子写入。 +func (s *Store) InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO notices (type, user_id, title_zh, title_en, body_zh, body_en, link, published_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, typ, userID, titleZH, titleEN, nullIfEmpty(bodyZH), nullIfEmpty(bodyEN), nullIfEmpty(link), now) + return err +} + +// InsertBroadcast 插入一条全员广播通知(user_id=NULL),自管连接,返回新 id。供 +// nodectl / 发版流程使用。expiresAt 可为 nil(永不过期)。 +func (s *Store) InsertBroadcast(ctx context.Context, typ, titleZH, titleEN, bodyZH, bodyEN, link string, publishedAt time.Time, expiresAt *time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT INTO notices (type, user_id, title_zh, title_en, body_zh, body_en, link, published_at, expires_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?) + `, typ, titleZH, titleEN, nullIfEmpty(bodyZH), nullIfEmpty(bodyEN), nullIfEmpty(link), publishedAt, nullTime(expiresAt)) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// Revoke 撤回一条通知(置 revoked_at)。目标不存在或已撤回时返回 sql.ErrNoRows。 +func (s *Store) Revoke(ctx context.Context, id int64, at time.Time) error { + res, err := s.db.ExecContext(ctx, `UPDATE notices SET revoked_at=? WHERE id=? AND revoked_at IS NULL`, at, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +// ListAdmin 供 nodectl list 使用:全字段倒序;includeInactive=false 时只保留 +// 未撤回、未过期(与 ListForUser 相同判据)的记录。 +func (s *Store) ListAdmin(ctx context.Context, includeInactive bool, limit int) ([]AdminRow, error) { + now := time.Now().UTC() + query := ` + SELECT id, type, title_zh, published_at, revoked_at, expires_at + FROM notices + ` + args := []any{} + if !includeInactive { + query += ` WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)` + args = append(args, now) + } + query += ` ORDER BY published_at DESC LIMIT ?` + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminRow + for rows.Next() { + var r AdminRow + var revokedAt, expiresAt sql.NullTime + if err := rows.Scan(&r.ID, &r.Type, &r.TitleZH, &r.PublishedAt, &revokedAt, &expiresAt); err != nil { + return nil, err + } + switch { + case revokedAt.Valid: + r.Status = "revoked" + case expiresAt.Valid && !expiresAt.Time.After(now): + r.Status = "expired" + default: + r.Status = "active" + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// MarkEmailSent 标记该通知的邮件兜底已发送(幂等:只在未发送过时置位)。 +func (s *Store) MarkEmailSent(ctx context.Context, id int64, at time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE notices SET email_sent_at=? WHERE id=? AND email_sent_at IS NULL`, at, id) + return err +} + +// ListActiveUserEmails 返回所有 active 用户的邮箱,供邮件兜底批量发送。 +func (s *Store) ListActiveUserEmails(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT email FROM users WHERE status='active'`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var email string + if err := rows.Scan(&email); err != nil { + return nil, err + } + out = append(out, email) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func nullIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} + +func nullTime(t *time.Time) any { + if t == nil { + return nil + } + return *t +} diff --git a/server/internal/notices/store_sqlite_test.go b/server/internal/notices/store_sqlite_test.go new file mode 100644 index 0000000..4a21722 --- /dev/null +++ b/server/internal/notices/store_sqlite_test.go @@ -0,0 +1,96 @@ +package notices + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +func openDB(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatal(err) + } + _ = store.ApplyCodesLibMigrations(context.Background(), db, "sqlite") + return db +} + +func seedU(t *testing.T, db *sql.DB, id int64, uuid string) { + t.Helper() + if _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) + VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()); err != nil { + t.Fatal(err) + } +} + +func TestListForUser_MergeFilterUnread(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + seedU(t, db, 2, "u2") + st := NewStore(db) + now := time.Now().UTC() + // 广播1条 + 我的定向1条 + 他人定向1条 + 已撤回1条 + 已过期1条 + if _, err := st.InsertBroadcast(context.Background(), "news", "b1", "b1", "", "", "", now.Add(-3*time.Hour), nil); err != nil { + t.Fatal(err) + } + tx, _ := db.Begin() + if err := st.InsertNoticeTx(context.Background(), tx, 1, "reward", "mine", "mine", "", "", "", now.Add(-2*time.Hour)); err != nil { + t.Fatal(err) + } + if err := st.InsertNoticeTx(context.Background(), tx, 2, "reward", "other", "other", "", "", "", now.Add(-1*time.Hour)); err != nil { + t.Fatal(err) + } + _ = tx.Commit() + rid, _ := st.InsertBroadcast(context.Background(), "promo", "revoked", "revoked", "", "", "", now, nil) + _ = st.Revoke(context.Background(), rid, now) + exp := now.Add(-time.Minute) + _, _ = st.InsertBroadcast(context.Background(), "promo", "expired", "expired", "", "", "", now.Add(-4*time.Hour), &exp) + + items, unread, err := st.ListForUser(context.Background(), 1, now, 50) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatalf("items=%d want 2(广播+我的;他人/撤回/过期均排除): %+v", len(items), items) + } + if items[0].TitleZH != "mine" || items[1].TitleZH != "b1" { + t.Fatalf("排序应 published_at 倒序: %+v", items) + } + if unread != 2 { + t.Fatalf("水位为空→全未读, unread=%d", unread) + } + + // 置水位到 -2.5h:广播(-3h)已读、定向(-2h)未读 + if err := st.MarkRead(context.Background(), 1, now.Add(-150*time.Minute)); err != nil { + t.Fatal(err) + } + items, unread, _ = st.ListForUser(context.Background(), 1, now, 50) + if unread != 1 || !items[0].Unread || items[1].Unread { + t.Fatalf("水位判定错: unread=%d items=%+v", unread, items) + } +} + +func TestInsertNoticeTx_RollsBackWithTx(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + tx, _ := db.Begin() + if err := st.InsertNoticeTx(context.Background(), tx, 1, "reward", "t", "t", "", "", "", time.Now().UTC()); err != nil { + t.Fatal(err) + } + _ = tx.Rollback() + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM notices`).Scan(&n) + if n != 0 { + t.Fatalf("回滚后应零孤儿通知, got %d", n) + } +}