211 lines
7.9 KiB
Go
211 lines
7.9 KiB
Go
package cpnotice
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"hyapp/pkg/appcode"
|
|
"hyapp/pkg/xerr"
|
|
)
|
|
|
|
const (
|
|
sourceUserOutbox = "user_outbox"
|
|
deliveryStatusDelivering = "delivering"
|
|
deliveryStatusDelivered = "delivered"
|
|
deliveryStatusRetryable = "retryable"
|
|
deliveryStatusFailed = "failed"
|
|
)
|
|
|
|
// CPDelivery is one concrete IM side effect derived from a CP user_outbox fact.
|
|
type CPDelivery struct {
|
|
AppCode string
|
|
EventID string
|
|
EventType string
|
|
Channel string
|
|
NoticeType string
|
|
SenderUserID int64
|
|
TargetUserID int64
|
|
GroupID string
|
|
PayloadJSON []byte
|
|
RetryCount int
|
|
CreatedAtMS int64
|
|
}
|
|
|
|
// MySQLRepository stores CP delivery state in notice_delivery_events only; it never mutates user_outbox.
|
|
type MySQLRepository struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewMySQLRepository creates CP notice repository.
|
|
func NewMySQLRepository(db *sql.DB) *MySQLRepository {
|
|
return &MySQLRepository{db: db}
|
|
}
|
|
|
|
// ClaimCPDelivery claims one per-channel CP delivery row. Delivered rows are acknowledged as already handled.
|
|
func (r *MySQLRepository) ClaimCPDelivery(ctx context.Context, workerID string, delivery CPDelivery, lockTTL time.Duration) (CPDelivery, bool, error) {
|
|
if r == nil || r.db == nil {
|
|
return CPDelivery{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured")
|
|
}
|
|
workerID = strings.TrimSpace(workerID)
|
|
if workerID == "" {
|
|
return CPDelivery{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required")
|
|
}
|
|
if lockTTL <= 0 {
|
|
lockTTL = 30 * time.Second
|
|
}
|
|
if err := validateDelivery(delivery); err != nil {
|
|
return CPDelivery{}, false, err
|
|
}
|
|
delivery.AppCode = appcode.Normalize(delivery.AppCode)
|
|
now := time.Now()
|
|
lockUntilMS := now.Add(lockTTL).UnixMilli()
|
|
nowMS := now.UnixMilli()
|
|
|
|
tx, err := r.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return CPDelivery{}, false, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
status, retryCount, lockUntil, nextRetry, err := r.lockDeliveryRow(ctx, tx, delivery)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
if _, 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, CAST(? AS JSON), NULL, 0, ?, ?)`,
|
|
sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel, delivery.NoticeType, delivery.TargetUserID,
|
|
deliveryStatusDelivering, workerID, lockUntilMS, string(delivery.PayloadJSON), nowMS, nowMS,
|
|
); err != nil {
|
|
return CPDelivery{}, false, err
|
|
}
|
|
delivery.RetryCount = 0
|
|
if err := tx.Commit(); err != nil {
|
|
return CPDelivery{}, false, err
|
|
}
|
|
return delivery, true, nil
|
|
}
|
|
if err != nil {
|
|
return CPDelivery{}, false, err
|
|
}
|
|
if status == deliveryStatusDelivered || status == deliveryStatusFailed {
|
|
return CPDelivery{}, false, tx.Commit()
|
|
}
|
|
if status == deliveryStatusDelivering && lockUntil > nowMS {
|
|
return CPDelivery{}, false, tx.Commit()
|
|
}
|
|
if status == deliveryStatusRetryable && nextRetry > nowMS {
|
|
return CPDelivery{}, false, tx.Commit()
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE notice_delivery_events
|
|
SET status = ?, notice_type = ?, target_user_id = ?, retry_count = ?, locked_by = ?,
|
|
lock_until_ms = ?, next_retry_at_ms = 0, payload_json = CAST(? AS JSON),
|
|
last_error = NULL, updated_at_ms = ?
|
|
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
|
|
deliveryStatusDelivering, delivery.NoticeType, delivery.TargetUserID, retryCount, workerID,
|
|
lockUntilMS, string(delivery.PayloadJSON), nowMS,
|
|
sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
|
); err != nil {
|
|
return CPDelivery{}, false, err
|
|
}
|
|
delivery.RetryCount = retryCount
|
|
if err := tx.Commit(); err != nil {
|
|
return CPDelivery{}, false, err
|
|
}
|
|
return delivery, true, nil
|
|
}
|
|
|
|
func (r *MySQLRepository) lockDeliveryRow(ctx context.Context, tx *sql.Tx, delivery CPDelivery) (string, int, int64, int64, error) {
|
|
var status string
|
|
var retryCount int
|
|
var lockUntil int64
|
|
var nextRetry int64
|
|
err := tx.QueryRowContext(ctx, `
|
|
SELECT status, retry_count, lock_until_ms, next_retry_at_ms
|
|
FROM notice_delivery_events
|
|
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
|
FOR UPDATE`,
|
|
sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
|
).Scan(&status, &retryCount, &lockUntil, &nextRetry)
|
|
return status, retryCount, lockUntil, nextRetry, err
|
|
}
|
|
|
|
// MarkCPDelivered records a successful Tencent IM response for the specific channel.
|
|
func (r *MySQLRepository) MarkCPDelivered(ctx context.Context, delivery CPDelivery, deliveredPayload []byte, nowMs int64) error {
|
|
if r == nil || r.db == nil {
|
|
return xerr.New(xerr.Unavailable, "notice repository is not configured")
|
|
}
|
|
_, err := r.db.ExecContext(ctx, `
|
|
UPDATE notice_delivery_events
|
|
SET status = ?, payload_json = CAST(? AS JSON), last_error = NULL,
|
|
delivered_at_ms = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0, updated_at_ms = ?
|
|
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
|
|
deliveryStatusDelivered, string(deliveredPayload), nowMs, nowMs,
|
|
sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// MarkCPRetryable releases the current lock and schedules a channel-level retry.
|
|
func (r *MySQLRepository) MarkCPRetryable(ctx context.Context, delivery CPDelivery, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error {
|
|
if r == nil || r.db == nil {
|
|
return xerr.New(xerr.Unavailable, "notice repository is not configured")
|
|
}
|
|
_, 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,
|
|
sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// MarkCPFailed dead-letters a channel-level delivery after retry budget is exhausted.
|
|
func (r *MySQLRepository) MarkCPFailed(ctx context.Context, delivery CPDelivery, retryCount int, lastErr string, nowMs int64) error {
|
|
if r == nil || r.db == nil {
|
|
return xerr.New(xerr.Unavailable, "notice repository is not configured")
|
|
}
|
|
_, 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,
|
|
sourceUserOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func validateDelivery(delivery CPDelivery) error {
|
|
if appcode.Normalize(delivery.AppCode) == "" || strings.TrimSpace(delivery.EventID) == "" ||
|
|
strings.TrimSpace(delivery.Channel) == "" || strings.TrimSpace(delivery.NoticeType) == "" ||
|
|
len(delivery.PayloadJSON) == 0 {
|
|
return xerr.New(xerr.InvalidArgument, "cp delivery is incomplete")
|
|
}
|
|
if delivery.Channel == channelTencentIMC2C && delivery.TargetUserID <= 0 {
|
|
return xerr.New(xerr.InvalidArgument, "cp c2c delivery target_user_id is required")
|
|
}
|
|
if delivery.Channel == channelTencentIMC2C && delivery.SenderUserID <= 0 {
|
|
return xerr.New(xerr.InvalidArgument, "cp c2c delivery sender_user_id is required")
|
|
}
|
|
if (delivery.Channel == channelTencentIMRoom || delivery.Channel == channelTencentIMRegion) && strings.TrimSpace(delivery.GroupID) == "" {
|
|
return xerr.New(xerr.InvalidArgument, "cp group delivery group_id is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func truncateError(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) <= 512 {
|
|
return value
|
|
}
|
|
return value[:512]
|
|
}
|