514 lines
16 KiB
Go
514 lines
16 KiB
Go
package walletnotice
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"hyapp/pkg/appcode"
|
|
"hyapp/pkg/walletmq"
|
|
"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()
|
|
appCode := appcode.FromContext(ctx)
|
|
candidates := make([]walletBalanceCandidate, 0)
|
|
branches := []struct {
|
|
query string
|
|
args []any
|
|
}{
|
|
{
|
|
query: fmt.Sprintf(`
|
|
SELECT wo.app_code, wo.event_id, wo.event_type
|
|
FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
|
|
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.app_code = ? AND wo.event_type IN (%s)
|
|
AND nde.source_event_id IS NULL
|
|
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
|
|
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
|
|
args: append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...),
|
|
},
|
|
{
|
|
query: fmt.Sprintf(`
|
|
SELECT wo.app_code, wo.event_id, wo.event_type
|
|
FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
|
|
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.app_code = ? AND wo.event_type IN (%s)
|
|
AND nde.status = ? AND nde.next_retry_at_ms <= ?
|
|
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
|
|
LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))),
|
|
args: append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), deliveryStatusRetryable, nowMs),
|
|
},
|
|
{
|
|
query: fmt.Sprintf(`
|
|
SELECT wo.app_code, wo.event_id, wo.event_type
|
|
FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created)
|
|
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.app_code = ? AND wo.event_type IN (%s)
|
|
AND 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: append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), deliveryStatusDelivering, nowMs),
|
|
},
|
|
}
|
|
for _, branch := range branches {
|
|
remaining := limit - len(candidates)
|
|
if remaining <= 0 {
|
|
break
|
|
}
|
|
args := append(append([]any{}, branch.args...), remaining)
|
|
branchCandidates, err := queryWalletBalanceCandidates(ctx, r.db, branch.query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
candidates = append(candidates, branchCandidates...)
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func queryWalletBalanceCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]walletBalanceCandidate, error) {
|
|
rows, err := db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
candidates := make([]walletBalanceCandidate, 0)
|
|
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
|
|
}
|
|
|
|
// ClaimWalletBalanceMessage records a wallet_outbox MQ message in notice_delivery_events.
|
|
func (r *MySQLRepository) ClaimWalletBalanceMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (WalletBalanceEvent, bool, error) {
|
|
if r == nil || r.db == nil {
|
|
return WalletBalanceEvent{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured")
|
|
}
|
|
workerID = strings.TrimSpace(workerID)
|
|
if workerID == "" {
|
|
return WalletBalanceEvent{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required")
|
|
}
|
|
if lockTTL <= 0 {
|
|
lockTTL = 30 * time.Second
|
|
}
|
|
event, err := walletBalanceEventFromMessage(message)
|
|
if err != nil {
|
|
return WalletBalanceEvent{}, 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 WalletBalanceEvent{}, false, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
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 walletBalanceEventFromMessage(message walletmq.WalletOutboxMessage) (WalletBalanceEvent, error) {
|
|
event := WalletBalanceEvent{
|
|
AppCode: appcode.Normalize(message.AppCode),
|
|
EventID: message.EventID,
|
|
EventType: message.EventType,
|
|
TransactionID: message.TransactionID,
|
|
CommandID: message.CommandID,
|
|
UserID: message.UserID,
|
|
AssetType: message.AssetType,
|
|
AvailableDelta: message.AvailableDelta,
|
|
FrozenDelta: message.FrozenDelta,
|
|
PayloadJSON: message.PayloadJSON,
|
|
CreatedAtMS: message.OccurredAtMS,
|
|
}
|
|
if event.PayloadJSON == "" {
|
|
event.PayloadJSON = "{}"
|
|
}
|
|
if event.AppCode == "" || event.EventID == "" || event.TransactionID == "" || event.CommandID == "" {
|
|
return WalletBalanceEvent{}, fmt.Errorf("wallet outbox message is incomplete")
|
|
}
|
|
if !isWalletPrivateNoticeEvent(event.EventType) {
|
|
return WalletBalanceEvent{}, fmt.Errorf("unexpected wallet event type %q", event.EventType)
|
|
}
|
|
if event.UserID <= 0 {
|
|
return WalletBalanceEvent{}, fmt.Errorf("wallet notice user_id is invalid")
|
|
}
|
|
return event, 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 <= ?`,
|
|
deliveryStatusDelivering,
|
|
workerID,
|
|
lockUntilMS,
|
|
event.PayloadJSON,
|
|
nowMs,
|
|
sourceWalletOutbox,
|
|
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,
|
|
event.PayloadJSON,
|
|
nowMs,
|
|
sourceWalletOutbox,
|
|
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`,
|
|
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 stringSliceToAny(values []string) []any {
|
|
out := make([]any, 0, len(values))
|
|
for _, value := range values {
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
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]
|
|
}
|