ed1f805cbc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
221 lines
6.8 KiB
Go
221 lines
6.8 KiB
Go
// 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
|
||
}
|