386 lines
11 KiB
Go

package walletnotice
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
)
const (
sourceWalletOutbox = "wallet_outbox"
channelTencentIMC2C = "tencent_im_c2c"
eventWalletBalance = "WalletBalanceChanged"
eventVIPActivated = "VipActivated"
deliveryStatusDelivering = "delivering"
deliveryStatusDelivered = "delivered"
deliveryStatusRetryable = "retryable"
deliveryStatusFailed = "failed"
)
// MySQLRepository owns wallet notice delivery state and reads wallet_outbox as an append-only source.
type MySQLRepository struct {
db *sql.DB
walletOutboxTable string
}
// WalletBalanceEvent 是 notice worker 从 wallet_outbox 认领的一条私有余额通知事实。
type WalletBalanceEvent struct {
AppCode string
EventID string
EventType string
TransactionID string
CommandID string
UserID int64
AssetType string
AvailableDelta int64
FrozenDelta int64
PayloadJSON string
RetryCount int
CreatedAtMS int64
}
// NewMySQLRepository 创建钱包余额通知模块仓储。
// notice-service 自己的连接池由 platform/mysql 持有;模块只使用同一 DB 事务管理投递位点。
func NewMySQLRepository(db *sql.DB, walletDatabase string) (*MySQLRepository, error) {
if db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql db is not configured")
}
walletDatabase = normalizeDatabaseName(walletDatabase)
if walletDatabase == "" {
return nil, xerr.New(xerr.InvalidArgument, "wallet_database is invalid")
}
return &MySQLRepository{
db: db,
walletOutboxTable: fmt.Sprintf("`%s`.`wallet_outbox`", walletDatabase),
}, nil
}
// ClaimWalletBalanceEvents 抢占 wallet_outbox 中待投递的余额变更事件。
// wallet_outbox 自身不被 notice-service 修改;多实例互斥和重试状态保存在 notice_delivery_events。
func (r *MySQLRepository) ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, 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.listWalletBalanceCandidates(ctx, limit, time.Now().UnixMilli())
if err != nil {
return nil, err
}
claimed := make([]WalletBalanceEvent, 0, len(candidates))
for _, candidate := range candidates {
event, ok, err := r.claimWalletBalanceCandidate(ctx, candidate, workerID, lockTTL)
if err != nil {
return claimed, err
}
if ok {
claimed = append(claimed, event)
}
}
return claimed, nil
}
type walletBalanceCandidate struct {
AppCode string
EventID string
EventType string
}
func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) {
eventTypes := walletPrivateNoticeEventTypes()
query := fmt.Sprintf(`
SELECT wo.app_code, wo.event_id, wo.event_type
FROM %s wo
LEFT JOIN notice_delivery_events nde
ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ?
WHERE wo.event_type IN (%s)
AND (
nde.source_event_id IS NULL
OR (nde.status = ? AND nde.next_retry_at_ms <= ?)
OR (nde.status = ? AND nde.lock_until_ms <= ?)
)
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes)))
args := []any{
sourceWalletOutbox,
channelTencentIMC2C,
}
for _, eventType := range eventTypes {
args = append(args, eventType)
}
args = append(args,
deliveryStatusRetryable,
nowMs,
deliveryStatusDelivering,
nowMs,
limit,
)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
candidates := make([]walletBalanceCandidate, 0, limit)
for rows.Next() {
var candidate walletBalanceCandidate
if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.EventType); err != nil {
return nil, err
}
candidates = append(candidates, candidate)
}
return candidates, rows.Err()
}
func (r *MySQLRepository) claimWalletBalanceCandidate(ctx context.Context, candidate walletBalanceCandidate, workerID string, lockTTL time.Duration) (WalletBalanceEvent, 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 WalletBalanceEvent{}, false, err
}
defer func() { _ = tx.Rollback() }()
event, err := r.lockWalletBalanceEvent(ctx, tx, candidate)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 候选扫描到事务锁定之间可能被清理;这种竞争不应让整批失败。
return WalletBalanceEvent{}, false, nil
}
return WalletBalanceEvent{}, false, err
}
retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
if err != nil || !claimed {
return WalletBalanceEvent{}, false, err
}
event.RetryCount = retryCount
if err := tx.Commit(); err != nil {
return WalletBalanceEvent{}, false, err
}
return event, true, nil
}
func (r *MySQLRepository) lockWalletBalanceEvent(ctx context.Context, tx *sql.Tx, candidate walletBalanceCandidate) (WalletBalanceEvent, error) {
var event WalletBalanceEvent
query := fmt.Sprintf(`
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms
FROM %s
WHERE app_code = ? AND event_id = ? AND event_type = ?
FOR UPDATE`, r.walletOutboxTable)
err := tx.QueryRowContext(ctx, query,
appcode.Normalize(candidate.AppCode),
candidate.EventID,
candidate.EventType,
).Scan(
&event.AppCode,
&event.EventID,
&event.EventType,
&event.TransactionID,
&event.CommandID,
&event.UserID,
&event.AssetType,
&event.AvailableDelta,
&event.FrozenDelta,
&event.PayloadJSON,
&event.CreatedAtMS,
)
return event, err
}
func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, event WalletBalanceEvent, workerID string, lockUntilMS int64, nowMs int64) (int, bool, error) {
_, 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, ?, ?)`,
sourceWalletOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
event.EventType,
event.UserID,
deliveryStatusDelivering,
workerID,
lockUntilMS,
event.PayloadJSON,
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 <= ?)
OR (status = ? AND lock_until_ms <= ?)
)`,
deliveryStatusDelivering,
workerID,
lockUntilMS,
event.PayloadJSON,
nowMs,
sourceWalletOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
deliveryStatusRetryable,
nowMs,
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`,
sourceWalletOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
).Scan(&retryCount)
return retryCount, err == nil, err
}
// MarkWalletBalanceDelivered 标记一条私有余额通知已投递到腾讯云 IM。
// payload_json 更新成最终客户端负载,便于后台排查和死信重放使用同一消息形态。
func (r *MySQLRepository) MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, deliveredPayload json.RawMessage, 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,
sourceWalletOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
)
return err
}
// MarkWalletBalanceRetryable 释放当前投递锁,按 nextRetryAtMS 进入退避重试。
func (r *MySQLRepository) MarkWalletBalanceRetryable(ctx context.Context, event WalletBalanceEvent, 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,
sourceWalletOutbox,
event.AppCode,
event.EventID,
channelTencentIMC2C,
)
return err
}
// MarkWalletBalanceFailed 把 poison event 移入死信状态,不再自动抢占。
func (r *MySQLRepository) MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, 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,
sourceWalletOutbox,
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 walletPrivateNoticeEventTypes() []string {
return []string{eventWalletBalance, eventVIPActivated}
}
func sqlPlaceholders(count int) string {
if count <= 0 {
return "''"
}
parts := make([]string, count)
for i := range parts {
parts[i] = "?"
}
return strings.Join(parts, ",")
}
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]
}