2026-06-25 19:57:16 +08:00

161 lines
5.9 KiB
Go

package confirmnotice
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
)
const (
deliveryStatusDelivering = "delivering"
deliveryStatusDelivered = "delivered"
deliveryStatusRetryable = "retryable"
deliveryStatusFailed = "failed"
)
// MySQLRepository stores confirm IM delivery state in notice_delivery_events.
type MySQLRepository struct {
db *sql.DB
}
func NewMySQLRepository(db *sql.DB) *MySQLRepository {
return &MySQLRepository{db: db}
}
// ClaimConfirmDelivery creates or locks one delivery row; already terminal rows are acknowledged.
func (r *MySQLRepository) ClaimConfirmDelivery(ctx context.Context, workerID string, delivery Delivery, lockTTL time.Duration) (Delivery, bool, error) {
if r == nil || r.db == nil {
return Delivery{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured")
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return Delivery{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
delivery.AppCode = appcode.Normalize(delivery.AppCode)
now := time.Now()
nowMS := now.UnixMilli()
lockUntilMS := now.Add(lockTTL).UnixMilli()
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return Delivery{}, false, err
}
defer tx.Rollback()
status, retryCount, lockUntil, nextRetry, err := r.lockDelivery(ctx, tx, delivery)
if errors.Is(err, sql.ErrNoRows) {
_, err := tx.ExecContext(ctx, `
INSERT INTO notice_delivery_events (
source_name, app_code, source_event_id, channel, notice_type, target_user_id,
status, retry_count, locked_by, lock_until_ms, next_retry_at_ms,
payload_json, last_error, delivered_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, CAST(? AS JSON), NULL, 0, ?, ?)`,
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel, delivery.NoticeType, delivery.TargetUserID,
deliveryStatusDelivering, workerID, lockUntilMS, string(delivery.PayloadJSON), nowMS, nowMS,
)
if err != nil {
return Delivery{}, false, err
}
if err := tx.Commit(); err != nil {
return Delivery{}, false, err
}
delivery.RetryCount = 0
return delivery, true, nil
}
if err != nil {
return Delivery{}, false, err
}
if status == deliveryStatusDelivered || status == deliveryStatusFailed {
return Delivery{}, false, tx.Commit()
}
if status == deliveryStatusDelivering && lockUntil > nowMS {
return Delivery{}, false, tx.Commit()
}
if status == deliveryStatusRetryable && nextRetry > nowMS {
return Delivery{}, false, tx.Commit()
}
_, err = tx.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, notice_type = ?, target_user_id = ?, retry_count = ?, locked_by = ?,
lock_until_ms = ?, next_retry_at_ms = 0, payload_json = CAST(? AS JSON),
last_error = NULL, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
deliveryStatusDelivering, delivery.NoticeType, delivery.TargetUserID, retryCount, workerID,
lockUntilMS, string(delivery.PayloadJSON), nowMS,
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
)
if err != nil {
return Delivery{}, false, err
}
if err := tx.Commit(); err != nil {
return Delivery{}, false, err
}
delivery.RetryCount = retryCount
return delivery, true, nil
}
func (r *MySQLRepository) lockDelivery(ctx context.Context, tx *sql.Tx, delivery Delivery) (string, int, int64, int64, error) {
var status string
var retryCount int
var lockUntil int64
var nextRetry int64
err := tx.QueryRowContext(ctx, `
SELECT status, retry_count, lock_until_ms, next_retry_at_ms
FROM notice_delivery_events
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
FOR UPDATE`,
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
).Scan(&status, &retryCount, &lockUntil, &nextRetry)
return status, retryCount, lockUntil, nextRetry, err
}
func (r *MySQLRepository) MarkConfirmDelivered(ctx context.Context, delivery Delivery, deliveredPayload []byte, nowMs int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, payload_json = CAST(? AS JSON), last_error = NULL,
delivered_at_ms = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
deliveryStatusDelivered, string(deliveredPayload), nowMs, nowMs,
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
)
return err
}
func (r *MySQLRepository) MarkConfirmRetryable(ctx context.Context, delivery Delivery, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0,
next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
deliveryStatusRetryable, retryCount, nextRetryAtMS, truncateError(lastErr), nowMs,
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
)
return err
}
func (r *MySQLRepository) MarkConfirmFailed(ctx context.Context, delivery Delivery, retryCount int, lastErr string, nowMs int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0,
next_retry_at_ms = 0, last_error = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
deliveryStatusFailed, retryCount, truncateError(lastErr), nowMs,
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
)
return err
}
func truncateError(message string) string {
message = strings.TrimSpace(message)
if len(message) <= 512 {
return message
}
return message[:512]
}