500 lines
15 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 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]
}