package roomnotice import ( "context" "database/sql" "errors" "fmt" "strings" "time" "unicode" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" ) const ( sourceRoomOutbox = "room_outbox" channelTencentIMC2C = "tencent_im_c2c" eventRoomUserKicked = "RoomUserKicked" deliveryStatusDelivering = "delivering" deliveryStatusDelivered = "delivered" deliveryStatusRetryable = "retryable" deliveryStatusFailed = "failed" ) // MySQLRepository 只读取 room_outbox 事实,并把 notice 自己的消费位点写入 notice_delivery_events。 type MySQLRepository struct { db *sql.DB roomOutboxTable string } // RoomKickEvent 是 notice-service 从 room_outbox 认领的一条踢人私有通知事实。 type RoomKickEvent struct { AppCode string EventID string EventType string RoomID string RoomVersion int64 ActorUserID int64 TargetUserID int64 OccurredAtMS int64 CreatedAtMS int64 RetryCount int } // NewMySQLRepository 创建房间私有通知仓储;room_outbox 被视作 append-only 事实源。 func NewMySQLRepository(db *sql.DB, roomDatabase string) (*MySQLRepository, error) { if db == nil { return nil, xerr.New(xerr.Unavailable, "mysql db is not configured") } roomDatabase = normalizeDatabaseName(roomDatabase) if roomDatabase == "" { return nil, xerr.New(xerr.InvalidArgument, "room_database is invalid") } return &MySQLRepository{ db: db, roomOutboxTable: fmt.Sprintf("`%s`.`room_outbox`", roomDatabase), }, nil } // ClaimRoomKickEvents 抢占待投递给被踢用户的私有 C2C 通知。 // room_outbox 的 pending/delivered 状态属于房间群消息 bridge;这里不能修改它,避免多消费者互相覆盖。 func (r *MySQLRepository) ClaimRoomKickEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]RoomKickEvent, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "notice repository is not configured") } workerID = strings.TrimSpace(workerID) if workerID == "" { return nil, xerr.New(xerr.InvalidArgument, "worker_id is required") } if limit <= 0 { limit = 100 } if limit > 500 { limit = 500 } if lockTTL <= 0 { lockTTL = 30 * time.Second } candidates, err := r.listRoomKickCandidates(ctx, limit, time.Now().UnixMilli()) if err != nil { return nil, err } claimed := make([]RoomKickEvent, 0, len(candidates)) for _, candidate := range candidates { event, ok, err := r.claimRoomKickCandidate(ctx, candidate, workerID, lockTTL) if err != nil { return claimed, err } if ok { claimed = append(claimed, event) } } return claimed, nil } type roomKickCandidate struct { AppCode string EventID string } func (r *MySQLRepository) listRoomKickCandidates(ctx context.Context, limit int, nowMs int64) ([]roomKickCandidate, error) { appCode := appcode.FromContext(ctx) candidates := make([]roomKickCandidate, 0, limit) branches := []struct { query string args []any }{ { query: fmt.Sprintf(` SELECT ro.app_code, ro.event_id FROM %s ro FORCE INDEX (idx_room_outbox_event_created) LEFT JOIN notice_delivery_events nde ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ? WHERE ro.app_code = ? AND ro.event_type = ? AND nde.source_event_id IS NULL ORDER BY ro.created_at_ms ASC, ro.event_id ASC LIMIT ?`, r.roomOutboxTable), args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked}, }, { query: fmt.Sprintf(` SELECT ro.app_code, ro.event_id FROM %s ro FORCE INDEX (idx_room_outbox_event_created) JOIN notice_delivery_events nde ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ? WHERE ro.app_code = ? AND ro.event_type = ? AND nde.status = ? AND nde.next_retry_at_ms <= ? ORDER BY ro.created_at_ms ASC, ro.event_id ASC LIMIT ?`, r.roomOutboxTable), args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked, deliveryStatusRetryable, nowMs}, }, { query: fmt.Sprintf(` SELECT ro.app_code, ro.event_id FROM %s ro FORCE INDEX (idx_room_outbox_event_created) JOIN notice_delivery_events nde ON nde.source_name = ? AND nde.app_code = ro.app_code AND nde.source_event_id = ro.event_id AND nde.channel = ? WHERE ro.app_code = ? AND ro.event_type = ? AND nde.status = ? AND nde.lock_until_ms <= ? ORDER BY ro.created_at_ms ASC, ro.event_id ASC LIMIT ?`, r.roomOutboxTable), args: []any{sourceRoomOutbox, channelTencentIMC2C, appCode, eventRoomUserKicked, deliveryStatusDelivering, nowMs}, }, } for _, branch := range branches { remaining := limit - len(candidates) if remaining <= 0 { break } args := append(append([]any{}, branch.args...), remaining) branchCandidates, err := queryRoomKickCandidates(ctx, r.db, branch.query, args...) if err != nil { return nil, err } candidates = append(candidates, branchCandidates...) } return candidates, nil } func queryRoomKickCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]roomKickCandidate, error) { rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() candidates := make([]roomKickCandidate, 0) for rows.Next() { var candidate roomKickCandidate if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil { return nil, err } candidates = append(candidates, candidate) } return candidates, rows.Err() } func (r *MySQLRepository) claimRoomKickCandidate(ctx context.Context, candidate roomKickCandidate, workerID string, lockTTL time.Duration) (RoomKickEvent, bool, error) { nowMs := time.Now().UnixMilli() lockUntilMS := time.Now().Add(lockTTL).UnixMilli() ctx = appcode.WithContext(ctx, candidate.AppCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return RoomKickEvent{}, false, err } defer func() { _ = tx.Rollback() }() event, err := r.lockRoomKickEvent(ctx, tx, candidate) if err != nil { if errors.Is(err, sql.ErrNoRows) { // 候选扫描和事务锁定之间可能被清理或事件类型变化,跳过该行即可。 return RoomKickEvent{}, false, nil } return RoomKickEvent{}, false, err } retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) if err != nil || !claimed { return RoomKickEvent{}, false, err } event.RetryCount = retryCount if err := tx.Commit(); err != nil { return RoomKickEvent{}, false, err } return event, true, nil } // ClaimRoomKickEnvelope 把 MQ 收到的 room_outbox 信封认领到 notice_delivery_events。 func (r *MySQLRepository) ClaimRoomKickEnvelope(ctx context.Context, workerID string, envelope *roomeventsv1.EventEnvelope, lockTTL time.Duration) (RoomKickEvent, bool, error) { if r == nil || r.db == nil { return RoomKickEvent{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured") } workerID = strings.TrimSpace(workerID) if workerID == "" { return RoomKickEvent{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required") } if lockTTL <= 0 { lockTTL = 30 * time.Second } createdAtMS := int64(0) if envelope != nil { createdAtMS = envelope.GetOccurredAtMs() } event, err := roomKickEventFromEnvelope(envelope, createdAtMS) if err != nil { return RoomKickEvent{}, false, err } now := time.Now() lockUntilMS := now.Add(lockTTL).UnixMilli() nowMs := now.UnixMilli() ctx = appcode.WithContext(ctx, event.AppCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return RoomKickEvent{}, false, err } defer func() { _ = tx.Rollback() }() retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) if err != nil || !claimed { return RoomKickEvent{}, false, err } event.RetryCount = retryCount if err := tx.Commit(); err != nil { return RoomKickEvent{}, false, err } return event, true, nil } func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, candidate roomKickCandidate) (RoomKickEvent, error) { var ( event RoomKickEvent envelopeBytes []byte ) query := fmt.Sprintf(` SELECT app_code, event_id, event_type, room_id, envelope, created_at_ms FROM %s WHERE app_code = ? AND event_id = ? AND event_type = ? FOR UPDATE`, r.roomOutboxTable) err := tx.QueryRowContext(ctx, query, appcode.Normalize(candidate.AppCode), candidate.EventID, eventRoomUserKicked, ).Scan( &event.AppCode, &event.EventID, &event.EventType, &event.RoomID, &envelopeBytes, &event.CreatedAtMS, ) if err != nil { return RoomKickEvent{}, err } var envelope roomeventsv1.EventEnvelope if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil { return RoomKickEvent{}, err } return roomKickEventFromEnvelope(&envelope, event.CreatedAtMS) } func roomKickEventFromEnvelope(envelope *roomeventsv1.EventEnvelope, createdAtMS int64) (RoomKickEvent, error) { if envelope == nil { return RoomKickEvent{}, fmt.Errorf("room event envelope is required") } if envelope.GetEventType() != eventRoomUserKicked { return RoomKickEvent{}, fmt.Errorf("unexpected room event type %q", envelope.GetEventType()) } var body roomeventsv1.RoomUserKicked if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return RoomKickEvent{}, err } event := RoomKickEvent{ AppCode: appcode.Normalize(envelope.GetAppCode()), EventID: envelope.GetEventId(), EventType: envelope.GetEventType(), RoomID: envelope.GetRoomId(), RoomVersion: envelope.GetRoomVersion(), ActorUserID: body.GetActorUserId(), TargetUserID: body.GetTargetUserId(), OccurredAtMS: envelope.GetOccurredAtMs(), CreatedAtMS: createdAtMS, } if event.AppCode == "" || event.EventID == "" || event.RoomID == "" { return RoomKickEvent{}, fmt.Errorf("room kick event envelope is incomplete") } if event.TargetUserID <= 0 { return RoomKickEvent{}, fmt.Errorf("room kick event target_user_id is invalid") } return event, nil } func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, event RoomKickEvent, workerID string, lockUntilMS int64, nowMs int64) (int, bool, error) { payload, err := roomKickNoticePayload(event) if err != nil { return 0, false, err } _, 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, ?, '', 0, ?, ?)`, sourceRoomOutbox, event.AppCode, event.EventID, channelTencentIMC2C, event.EventType, event.TargetUserID, deliveryStatusDelivering, workerID, lockUntilMS, string(payload), nowMs, nowMs, ) if err == nil { return 0, true, nil } if !isDuplicateKey(err) { return 0, false, err } result, err := tx.ExecContext(ctx, ` UPDATE notice_delivery_events SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? AND status = ? AND next_retry_at_ms <= ?`, deliveryStatusDelivering, workerID, lockUntilMS, string(payload), nowMs, sourceRoomOutbox, event.AppCode, event.EventID, channelTencentIMC2C, deliveryStatusRetryable, nowMs, ) if err != nil { return 0, false, err } affected, err := result.RowsAffected() if err != nil { return 0, false, err } if affected == 0 { result, err = tx.ExecContext(ctx, ` UPDATE notice_delivery_events SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? AND status = ? AND lock_until_ms <= ?`, deliveryStatusDelivering, workerID, lockUntilMS, string(payload), nowMs, sourceRoomOutbox, event.AppCode, event.EventID, channelTencentIMC2C, deliveryStatusDelivering, nowMs, ) if err != nil { return 0, false, err } affected, err = result.RowsAffected() if err != nil || affected == 0 { return 0, false, err } } var retryCount int err = tx.QueryRowContext(ctx, ` SELECT retry_count FROM notice_delivery_events WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? FOR UPDATE`, sourceRoomOutbox, event.AppCode, event.EventID, channelTencentIMC2C, ).Scan(&retryCount) return retryCount, err == nil, err } // MarkRoomKickDelivered 标记被踢私有通知已送达腾讯云 IM C2C 通道。 func (r *MySQLRepository) MarkRoomKickDelivered(ctx context.Context, event RoomKickEvent, deliveredPayload []byte, nowMs int64) error { _, err := r.db.ExecContext(ctx, ` UPDATE notice_delivery_events SET status = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0, payload_json = ?, last_error = '', delivered_at_ms = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`, deliveryStatusDelivered, string(deliveredPayload), nowMs, nowMs, sourceRoomOutbox, event.AppCode, event.EventID, channelTencentIMC2C, ) return err } // MarkRoomKickRetryable 释放当前锁并进入退避重试;源 room_outbox 不受影响。 func (r *MySQLRepository) MarkRoomKickRetryable(ctx context.Context, event RoomKickEvent, 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, sourceRoomOutbox, event.AppCode, event.EventID, channelTencentIMC2C, ) return err } // MarkRoomKickFailed 把无法投递的私有通知移入 notice 死信,等待后台人工重放。 func (r *MySQLRepository) MarkRoomKickFailed(ctx context.Context, event RoomKickEvent, 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, sourceRoomOutbox, event.AppCode, event.EventID, channelTencentIMC2C, ) return err } func normalizeDatabaseName(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } for _, r := range value { if r == '_' || unicode.IsDigit(r) || unicode.IsLetter(r) { continue } return "" } return value } func isDuplicateKey(err error) bool { if err == nil { return false } return strings.Contains(err.Error(), "Error 1062") || strings.Contains(strings.ToLower(err.Error()), "duplicate") } func truncateError(value string) string { value = strings.TrimSpace(value) if len(value) <= 1000 { return value } return value[:1000] }