Files
pangolin/server/internal/notices/store.go
T

221 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}