固化机器人钱包通知抑制快照

This commit is contained in:
zhx 2026-07-13 23:47:18 +08:00
parent 5db48e1bcc
commit 175ddbea7e
4 changed files with 183 additions and 17 deletions

View File

@ -59,6 +59,15 @@ func (s *Service) ProcessWalletBalanceNotices(ctx context.Context, options Walle
} }
processed := 0 processed := 0
for _, event := range events { for _, event := range events {
if walletPrivateNoticeSuppressed(event.AssetType, event.PayloadJSON) {
// owner 事件已经固化“不投私信”时,把本地 delivery 位点收敛为 delivered
// 这条终态既阻止 pull fallback 反复认领,也不需要 notice 反查 user-service 猜账号类型。
if err := s.repository.MarkWalletBalanceDelivered(ctx, event, json.RawMessage(event.PayloadJSON), time.Now().UnixMilli()); err != nil {
return processed, err
}
processed++
continue
}
if err := s.publishWalletBalanceEvent(ctx, event, options); err != nil { if err := s.publishWalletBalanceEvent(ctx, event, options); err != nil {
return processed, err return processed, err
} }
@ -73,8 +82,9 @@ func (s *Service) ProcessWalletOutboxMessage(ctx context.Context, message wallet
if !isWalletPrivateNoticeEvent(message.EventType) { if !isWalletPrivateNoticeEvent(message.EventType) {
return false, nil return false, nil
} }
if isRobotCoinWalletNotice(message.AssetType) { if walletPrivateNoticeSuppressed(message.AssetType, message.PayloadJSON) {
// ROBOT_COIN 只服务机器人房间展示扣费,机器人账号不需要也不能接收用户 C2C 余额通知。 // MQ 主链路在写 notice delivery 前确认 owner 快照ROBOT_COIN 是历史兼容条件,
// suppress_private_notice 才是不依赖资产类型的显式契约。
return true, nil return true, nil
} }
options = normalizeWalletNoticeWorkerOptions(options, s.cfg.NodeID) options = normalizeWalletNoticeWorkerOptions(options, s.cfg.NodeID)
@ -170,6 +180,21 @@ func isRobotCoinWalletNotice(assetType string) bool {
return assetType == robotCoinAssetType return assetType == robotCoinAssetType
} }
func walletPrivateNoticeSuppressed(assetType string, payloadJSON string) bool {
if isRobotCoinWalletNotice(assetType) {
return true
}
var control struct {
SuppressPrivateNotice bool `json:"suppress_private_notice"`
}
// 旧事件没有控制字段时保持原投递语义;非法 payload 也不能在这里被静默确认,
// 后续正常 payload 校验会把它收敛到 retry/failed保留可观测性。
if err := json.Unmarshal([]byte(payloadJSON), &control); err != nil {
return false
}
return control.SuppressPrivateNotice
}
func walletBalanceNoticePayload(event WalletBalanceEvent) (json.RawMessage, error) { func walletBalanceNoticePayload(event WalletBalanceEvent) (json.RawMessage, error) {
var payload map[string]any var payload map[string]any
if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil { if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil {

View File

@ -140,6 +140,56 @@ func TestProcessWalletOutboxMessageSkipsRobotCoinPrivateIM(t *testing.T) {
} }
} }
func TestProcessWalletOutboxMessageSkipsOwnerSuppressedPrivateIM(t *testing.T) {
repo := &fakeRepository{}
publisher := &fakePublisher{}
service := New(Config{NodeID: "node-a"}, repo, publisher)
handled, err := service.ProcessWalletOutboxMessage(context.Background(), walletmq.WalletOutboxMessage{
AppCode: "huwaa",
EventID: "suppressed-wallet-1",
EventType: "WalletBalanceChanged",
TransactionID: "suppressed-tx-1",
CommandID: "suppressed-cmd-1",
UserID: 100002,
AssetType: "COIN",
AvailableDelta: -1,
PayloadJSON: `{"available_after":999,"suppress_private_notice":true}`,
OccurredAtMS: 1710000000000,
}, WalletNoticeWorkerOptions{MaxRetryCount: 3})
if err != nil {
t.Fatalf("ProcessWalletOutboxMessage failed: %v", err)
}
if !handled || len(repo.claimedMessages) != 0 || len(repo.delivered) != 0 || len(publisher.messages) != 0 {
t.Fatalf("owner-suppressed notice must be acked before delivery claim: handled=%v claimed=%d delivered=%d messages=%d", handled, len(repo.claimedMessages), len(repo.delivered), len(publisher.messages))
}
}
func TestProcessWalletBalanceNoticesTerminatesOwnerSuppressedFallback(t *testing.T) {
repo := &fakeRepository{events: []WalletBalanceEvent{{
AppCode: "huwaa",
EventID: "suppressed-wallet-fallback-1",
EventType: "WalletBalanceChanged",
TransactionID: "suppressed-tx-fallback-1",
CommandID: "suppressed-cmd-fallback-1",
UserID: 100003,
AssetType: "COIN",
AvailableDelta: -1,
PayloadJSON: `{"available_after":999,"suppress_private_notice":true}`,
CreatedAtMS: 1710000000000,
}}}
publisher := &fakePublisher{}
service := New(Config{NodeID: "node-a"}, repo, publisher)
processed, err := service.ProcessWalletBalanceNotices(context.Background(), WalletNoticeWorkerOptions{MaxRetryCount: 3})
if err != nil {
t.Fatalf("ProcessWalletBalanceNotices failed: %v", err)
}
if processed != 1 || len(repo.delivered) != 1 || len(publisher.messages) != 0 {
t.Fatalf("fallback suppression must terminate locally without C2C: processed=%d delivered=%d messages=%d", processed, len(repo.delivered), len(publisher.messages))
}
}
func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) { func TestProcessWalletBalanceNoticesMarksRetryable(t *testing.T) {
repo := &fakeRepository{ repo := &fakeRepository{
events: []WalletBalanceEvent{{ events: []WalletBalanceEvent{{

View File

@ -373,16 +373,7 @@ func balanceChangedEvent(transactionID string, commandID string, userID int64, a
if len(bizTypes) > 0 { if len(bizTypes) > 0 {
bizType = strings.TrimSpace(bizTypes[0]) bizType = strings.TrimSpace(bizTypes[0])
} }
return walletOutboxEvent{ eventPayload := map[string]any{
EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType),
EventType: "WalletBalanceChanged",
TransactionID: transactionID,
CommandID: commandID,
UserID: userID,
AssetType: assetType,
AvailableDelta: availableDelta,
FrozenDelta: frozenDelta,
Payload: map[string]any{
"transaction_id": transactionID, "transaction_id": transactionID,
"command_id": commandID, "command_id": commandID,
"user_id": userID, "user_id": userID,
@ -395,11 +386,42 @@ func balanceChangedEvent(transactionID string, commandID string, userID int64, a
"balance_version": balanceVersion, "balance_version": balanceVersion,
"metadata": payload, "metadata": payload,
"created_at_ms": nowMs, "created_at_ms": nowMs,
}, }
if suppressWalletPrivateNotice(assetType, payload) {
// 抑制决策必须和账务事实一起固化notice-service 只能消费这份不可变快照,
// 不能跨库读取用户 source 或根据当前资料反推交易发生时的账号语义。
eventPayload["suppress_private_notice"] = true
}
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType),
EventType: "WalletBalanceChanged",
TransactionID: transactionID,
CommandID: commandID,
UserID: userID,
AssetType: assetType,
AvailableDelta: availableDelta,
FrozenDelta: frozenDelta,
Payload: eventPayload,
CreatedAtMS: nowMs, CreatedAtMS: nowMs,
} }
} }
func suppressWalletPrivateNotice(assetType string, payload any) bool {
if strings.EqualFold(strings.TrimSpace(assetType), ledger.AssetRobotCoin) {
return true
}
// giftMetadata.RobotGift 是 wallet 扣费时的 owner 快照;只认可强类型业务元数据,
// 不在通用 JSON/map 里猎猜 source、command_id 或 biz_type。
switch metadata := payload.(type) {
case giftMetadata:
return metadata.RobotGift
case *giftMetadata:
return metadata != nil && metadata.RobotGift
default:
return false
}
}
func coinSellerTransferredEvent(transactionID string, commandID string, sellerUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent { func coinSellerTransferredEvent(transactionID string, commandID string, sellerUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{ return walletOutboxEvent{
EventID: eventID(transactionID, "WalletCoinSellerTransferred", sellerUserID, ledger.AssetCoinSellerCoin), EventID: eventID(transactionID, "WalletCoinSellerTransferred", sellerUserID, ledger.AssetCoinSellerCoin),

View File

@ -0,0 +1,69 @@
package mysql
import (
"testing"
"hyapp/services/wallet-service/internal/domain/ledger"
)
func TestBalanceChangedEventPersistsPrivateNoticeSuppressionSnapshot(t *testing.T) {
event := balanceChangedEvent(
"tx-robot-1",
"cmd-robot-1",
100001,
// COIN 刻意不依赖 ROBOT_COIN 兼容分支,证明强类型 giftMetadata.RobotGift 本身能固化决策。
ledger.AssetCoin,
-10,
0,
90,
0,
2,
giftMetadata{RobotGift: true},
1710000000000,
bizTypeRobotGiftDebit,
)
payload, ok := event.Payload.(map[string]any)
if !ok {
t.Fatalf("payload type = %T, want map[string]any", event.Payload)
}
if suppressed, ok := payload["suppress_private_notice"].(bool); !ok || !suppressed {
t.Fatalf("suppress_private_notice = %#v, want true", payload["suppress_private_notice"])
}
legacyRobotAsset := balanceChangedEvent(
"tx-robot-asset-1",
"cmd-robot-asset-1",
100001,
ledger.AssetRobotCoin,
-10,
0,
90,
0,
2,
struct{}{},
1710000000000,
bizTypeRobotGiftDebit,
)
legacyRobotPayload := legacyRobotAsset.Payload.(map[string]any)
if suppressed, ok := legacyRobotPayload["suppress_private_notice"].(bool); !ok || !suppressed {
t.Fatalf("ROBOT_COIN compatibility suppression = %#v, want true", legacyRobotPayload["suppress_private_notice"])
}
human := balanceChangedEvent(
"tx-human-1",
"cmd-human-1",
100002,
ledger.AssetCoin,
-10,
0,
90,
0,
2,
giftMetadata{},
1710000000000,
bizTypeGiftDebit,
)
humanPayload := human.Payload.(map[string]any)
if _, exists := humanPayload["suppress_private_notice"]; exists {
t.Fatalf("human wallet event must not persist suppression control: %#v", humanPayload)
}
}