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] }