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