package cpnotice import ( "context" "database/sql" "errors" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" ) const ( sourceUserOutbox = "user_outbox" deliveryStatusDelivering = "delivering" deliveryStatusDelivered = "delivered" deliveryStatusRetryable = "retryable" deliveryStatusFailed = "failed" ) // CPDelivery is one concrete IM side effect derived from a CP user_outbox fact. type CPDelivery struct { AppCode string EventID string EventType string Channel string NoticeType string SenderUserID int64 TargetUserID int64 GroupID string PayloadJSON []byte RetryCount int CreatedAtMS int64 } // MySQLRepository stores CP delivery state in notice_delivery_events only; it never mutates user_outbox. type MySQLRepository struct { db *sql.DB } // NewMySQLRepository creates CP notice repository. func NewMySQLRepository(db *sql.DB) *MySQLRepository { return &MySQLRepository{db: db} } // ClaimCPDelivery claims one per-channel CP delivery row. Delivered rows are acknowledged as already handled. func (r *MySQLRepository) ClaimCPDelivery(ctx context.Context, workerID string, delivery CPDelivery, lockTTL time.Duration) (CPDelivery, bool, error) { if r == nil || r.db == nil { return CPDelivery{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured") } workerID = strings.TrimSpace(workerID) if workerID == "" { return CPDelivery{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required") } if lockTTL <= 0 { lockTTL = 30 * time.Second } if err := validateDelivery(delivery); err != nil { return CPDelivery{}, false, err } delivery.AppCode = appcode.Normalize(delivery.AppCode) now := time.Now() lockUntilMS := now.Add(lockTTL).UnixMilli() nowMS := now.UnixMilli() tx, err := r.db.BeginTx(ctx, nil) if err != nil { return CPDelivery{}, false, err } defer func() { _ = tx.Rollback() }() status, retryCount, lockUntil, nextRetry, err := r.lockDeliveryRow(ctx, tx, delivery) if errors.Is(err, sql.ErrNoRows) { if _, 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, ?, ?)`, sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel, delivery.NoticeType, delivery.TargetUserID, deliveryStatusDelivering, workerID, lockUntilMS, string(delivery.PayloadJSON), nowMS, nowMS, ); err != nil { return CPDelivery{}, false, err } delivery.RetryCount = 0 if err := tx.Commit(); err != nil { return CPDelivery{}, false, err } return delivery, true, nil } if err != nil { return CPDelivery{}, false, err } if status == deliveryStatusDelivered || status == deliveryStatusFailed { return CPDelivery{}, false, tx.Commit() } if status == deliveryStatusDelivering && lockUntil > nowMS { return CPDelivery{}, false, tx.Commit() } if status == deliveryStatusRetryable && nextRetry > nowMS { return CPDelivery{}, false, tx.Commit() } if _, 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, sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel, ); err != nil { return CPDelivery{}, false, err } delivery.RetryCount = retryCount if err := tx.Commit(); err != nil { return CPDelivery{}, false, err } return delivery, true, nil } func (r *MySQLRepository) lockDeliveryRow(ctx context.Context, tx *sql.Tx, delivery CPDelivery) (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`, sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel, ).Scan(&status, &retryCount, &lockUntil, &nextRetry) return status, retryCount, lockUntil, nextRetry, err } // MarkCPDelivered records a successful Tencent IM response for the specific channel. func (r *MySQLRepository) MarkCPDelivered(ctx context.Context, delivery CPDelivery, deliveredPayload []byte, nowMs int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "notice repository is not configured") } _, 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, sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel, ) return err } // MarkCPRetryable releases the current lock and schedules a channel-level retry. func (r *MySQLRepository) MarkCPRetryable(ctx context.Context, delivery CPDelivery, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "notice repository is not configured") } _, 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, sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel, ) return err } // MarkCPFailed dead-letters a channel-level delivery after retry budget is exhausted. func (r *MySQLRepository) MarkCPFailed(ctx context.Context, delivery CPDelivery, retryCount int, lastErr string, nowMs int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "notice repository is not configured") } _, 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, sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel, ) return err } func validateDelivery(delivery CPDelivery) error { if appcode.Normalize(delivery.AppCode) == "" || strings.TrimSpace(delivery.EventID) == "" || strings.TrimSpace(delivery.Channel) == "" || strings.TrimSpace(delivery.NoticeType) == "" || len(delivery.PayloadJSON) == 0 { return xerr.New(xerr.InvalidArgument, "cp delivery is incomplete") } if delivery.Channel == channelTencentIMC2C && delivery.TargetUserID <= 0 { return xerr.New(xerr.InvalidArgument, "cp c2c delivery target_user_id is required") } if delivery.Channel == channelTencentIMC2C && delivery.SenderUserID <= 0 { return xerr.New(xerr.InvalidArgument, "cp c2c delivery sender_user_id is required") } if (delivery.Channel == channelTencentIMRoom || delivery.Channel == channelTencentIMRegion) && strings.TrimSpace(delivery.GroupID) == "" { return xerr.New(xerr.InvalidArgument, "cp group delivery group_id is required") } return nil } func truncateError(value string) string { value = strings.TrimSpace(value) if len(value) <= 512 { return value } return value[:512] }