2026-05-11 01:40:29 +08:00

234 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
rows, err := tx.QueryContext(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 attempt_count < ?
AND (
(status IN (?, ?) AND next_retry_at_ms <= ?)
OR (status = ? AND locked_until_ms <= ?)
)
ORDER BY created_at_ms ASC
LIMIT ?
FOR UPDATE SKIP LOCKED`,
appcode.FromContext(ctx),
maxRetry,
broadcastdomain.StatusPending,
broadcastdomain.StatusRetryable,
nowMS,
broadcastdomain.StatusDelivering,
nowMS,
limit,
)
if err != nil {
_ = tx.Rollback()
return nil, err
}
records := make([]broadcastdomain.OutboxRecord, 0, limit)
for rows.Next() {
record, err := scanBroadcastOutbox(rows)
if err != nil {
_ = rows.Close()
_ = tx.Rollback()
return nil, err
}
records = append(records, record)
}
if err := rows.Close(); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := rows.Err(); err != nil {
_ = tx.Rollback()
return nil, err
}
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
}
// 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
}