package mysql import ( "context" "database/sql" "errors" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast" ) // SaveBroadcastOutbox 插入一条持久化播报 outbox。 // 主键是 (app_code,event_id),重复写入会返回已有记录,保证 room outbox 重放、gRPC 重试和 cron 重跑不会重复发播报。 func (r *Repository) SaveBroadcastOutbox(ctx context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) { if r == nil || r.db == nil { return false, broadcastdomain.OutboxRecord{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } record.AppCode = appcode.FromContext(ctx) _, err := r.db.ExecContext(ctx, ` INSERT INTO im_broadcast_outbox ( app_code, event_id, scope, group_id, broadcast_type, payload_json, status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, '', 0, ?, ?)`, record.AppCode, record.EventID, record.Scope, record.GroupID, record.BroadcastType, record.PayloadJSON, record.Status, record.AttemptCount, record.NextRetryAtMS, record.LastError, record.CreatedAtMS, record.UpdatedAtMS, ) if err == nil { return true, record, nil } if !isMySQLDuplicate(err) { return false, broadcastdomain.OutboxRecord{}, err } // duplicate 不能直接当成功丢弃;返回已有记录让上层知道当前状态和目标 group_id。 existing, exists, findErr := r.GetBroadcastOutbox(ctx, record.EventID) if findErr != nil { return false, broadcastdomain.OutboxRecord{}, findErr } if !exists { return false, broadcastdomain.OutboxRecord{}, xerr.New(xerr.Conflict, "broadcast outbox event_id already exists") } return false, existing, nil } // GetBroadcastOutbox 按幂等键读取一条播报 outbox,测试和后台排障都会用到。 func (r *Repository) GetBroadcastOutbox(ctx context.Context, eventID string) (broadcastdomain.OutboxRecord, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR), status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms, created_at_ms, updated_at_ms FROM im_broadcast_outbox WHERE app_code = ? AND event_id = ?`, appcode.FromContext(ctx), eventID, ) record, err := scanBroadcastOutbox(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return broadcastdomain.OutboxRecord{}, false, nil } return broadcastdomain.OutboxRecord{}, false, err } return record, true, nil } // ClaimPendingBroadcastOutbox 抢占一页可投递播报消息。 // FOR UPDATE SKIP LOCKED 让多个 worker 可以并行拉取不同记录;locked_until_ms 处理 worker 崩溃后的接管。 func (r *Repository) ClaimPendingBroadcastOutbox(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, maxRetry int) ([]broadcastdomain.OutboxRecord, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } if strings.TrimSpace(workerID) == "" { workerID = "activity-broadcast" } if limit <= 0 { limit = 100 } if maxRetry <= 0 { maxRetry = 8 } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } appCode := appcode.FromContext(ctx) records := make([]broadcastdomain.OutboxRecord, 0, limit) claimBranches := []struct { query string args []any }{ { query: ` SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR), status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms, created_at_ms, updated_at_ms FROM im_broadcast_outbox FORCE INDEX (idx_im_broadcast_outbox_pending) WHERE app_code = ? AND attempt_count < ? AND status IN (?, ?) AND next_retry_at_ms <= ? ORDER BY created_at_ms ASC LIMIT ? FOR UPDATE SKIP LOCKED`, args: []any{appCode, maxRetry, broadcastdomain.StatusPending, broadcastdomain.StatusRetryable, nowMS}, }, { query: ` SELECT app_code, event_id, scope, group_id, broadcast_type, CAST(payload_json AS CHAR), status, attempt_count, next_retry_at_ms, last_error, locked_by, locked_until_ms, created_at_ms, updated_at_ms FROM im_broadcast_outbox FORCE INDEX (idx_im_broadcast_outbox_lock) WHERE app_code = ? AND attempt_count < ? AND status = ? AND locked_until_ms <= ? ORDER BY created_at_ms ASC LIMIT ? FOR UPDATE SKIP LOCKED`, args: []any{appCode, maxRetry, broadcastdomain.StatusDelivering, nowMS}, }, } for _, branch := range claimBranches { remaining := limit - len(records) if remaining <= 0 { break } args := append(append([]any{}, branch.args...), remaining) branchRecords, err := queryBroadcastOutboxRecords(ctx, tx, branch.query, args...) if err != nil { _ = tx.Rollback() return nil, err } records = append(records, branchRecords...) } for _, record := range records { // 先在同一事务里改成 delivering,再提交给调用方发送,避免两个 worker 同时发送同一 event_id。 if _, err := tx.ExecContext(ctx, ` UPDATE im_broadcast_outbox SET status = ?, locked_by = ?, locked_until_ms = ?, updated_at_ms = ? WHERE app_code = ? AND event_id = ?`, broadcastdomain.StatusDelivering, workerID, lockUntilMS, nowMS, record.AppCode, record.EventID, ); err != nil { _ = tx.Rollback() return nil, err } } if err := tx.Commit(); err != nil { return nil, err } for i := range records { records[i].Status = broadcastdomain.StatusDelivering records[i].LockedBy = workerID records[i].LockedUntilMS = lockUntilMS } return records, nil } func queryBroadcastOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]broadcastdomain.OutboxRecord, error) { rows, err := tx.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() records := make([]broadcastdomain.OutboxRecord, 0) for rows.Next() { record, err := scanBroadcastOutbox(rows) if err != nil { return nil, err } records = append(records, record) } if err := rows.Err(); err != nil { return nil, err } return records, nil } // MarkBroadcastOutboxDelivered 在腾讯 REST 接受消息后标记 delivered。 // delivered 只代表服务端投递成功,不代表所有客户端已在线收到或已展示。 func (r *Repository) MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error { _, err := r.db.ExecContext(ctx, ` UPDATE im_broadcast_outbox SET status = ?, locked_by = '', locked_until_ms = 0, last_error = '', updated_at_ms = ? WHERE app_code = ? AND event_id = ?`, broadcastdomain.StatusDelivered, nowMS, appcode.FromContext(ctx), eventID, ) return err } // MarkBroadcastOutboxFailed 释放失败记录,未达上限时进入 retryable,达到上限时进入 failed。 // WHERE locked_by 限定当前 worker,防止锁过期后旧 worker 把新 worker 的处理结果覆盖掉。 func (r *Repository) MarkBroadcastOutboxFailed(ctx context.Context, eventID string, workerID string, nowMS int64, nextRetryAtMS int64, maxRetry int, lastError string) error { if maxRetry <= 0 { maxRetry = 8 } _, err := r.db.ExecContext(ctx, ` UPDATE im_broadcast_outbox SET status = CASE WHEN attempt_count + 1 >= ? THEN ? ELSE ? END, attempt_count = attempt_count + 1, next_retry_at_ms = CASE WHEN attempt_count + 1 >= ? THEN 0 ELSE ? END, last_error = ?, locked_by = '', locked_until_ms = 0, updated_at_ms = ? WHERE app_code = ? AND event_id = ? AND locked_by = ?`, maxRetry, broadcastdomain.StatusFailed, broadcastdomain.StatusRetryable, maxRetry, nextRetryAtMS, lastError, nowMS, appcode.FromContext(ctx), eventID, workerID, ) return err } func scanBroadcastOutbox(scanner interface{ Scan(dest ...any) error }) (broadcastdomain.OutboxRecord, error) { var record broadcastdomain.OutboxRecord err := scanner.Scan( &record.AppCode, &record.EventID, &record.Scope, &record.GroupID, &record.BroadcastType, &record.PayloadJSON, &record.Status, &record.AttemptCount, &record.NextRetryAtMS, &record.LastError, &record.LockedBy, &record.LockedUntilMS, &record.CreatedAtMS, &record.UpdatedAtMS, ) return record, err }