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]
}