diff --git a/services/activity-service/internal/app/wallet_events.go b/services/activity-service/internal/app/wallet_events.go index cc982131..7781b3a4 100644 --- a/services/activity-service/internal/app/wallet_events.go +++ b/services/activity-service/internal/app/wallet_events.go @@ -22,6 +22,11 @@ func firstRechargeEventFromWalletMessage(body []byte) (firstrechargedomain.Recha return firstrechargedomain.RechargeEvent{}, false, nil } fields := firstRechargeFactFromWalletPayload(message.PayloadJSON) + // 首充奖励按 Google 商品 ID 精确匹配运营档位;币商转账、H5 或其他充值事实没有 Google 商品维度, + // 这里必须在进入业务校验前确认并跳过,避免合法钱包事实被首充 consumer 当成 poison message 反复重投。 + if !isCumulativeGoogleRecharge(fields.rechargeType) { + return firstrechargedomain.RechargeEvent{}, false, nil + } return firstrechargedomain.RechargeEvent{ AppCode: appcode.Normalize(message.AppCode), EventID: message.EventID, diff --git a/services/activity-service/internal/app/wallet_events_first_recharge_test.go b/services/activity-service/internal/app/wallet_events_first_recharge_test.go new file mode 100644 index 00000000..bd4633ce --- /dev/null +++ b/services/activity-service/internal/app/wallet_events_first_recharge_test.go @@ -0,0 +1,62 @@ +package app + +import ( + "testing" + + "hyapp/pkg/walletmq" + firstrechargedomain "hyapp/services/activity-service/internal/domain/firstrecharge" +) + +func TestFirstRechargeEventFromWalletMessageSkipsNonGoogleRecharge(t *testing.T) { + // 币商充值是合法钱包事实,但它没有 Google 商品 ID;首充 consumer 必须确认后跳过,避免 MQ 持续重投。 + body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{ + AppCode: "lalu", + EventID: "wev-coin-seller", + EventType: firstrechargedomain.RechargeEventWalletRecorded, + TransactionID: "wtx-coin-seller", + CommandID: "coin-seller-transfer-1", + UserID: 42001, + AssetType: "COIN", + AvailableDelta: 10000, + PayloadJSON: `{"recharge_type":"coin_seller_transfer","amount":10000,"recharge_sequence":1}`, + OccurredAtMS: 1710000000000, + }) + if err != nil { + t.Fatalf("EncodeWalletOutboxMessage failed: %v", err) + } + + event, ok, err := firstRechargeEventFromWalletMessage(body) + if err != nil { + t.Fatalf("firstRechargeEventFromWalletMessage returned error for non-google recharge: %v", err) + } + if ok || event.EventID != "" { + t.Fatalf("non-google recharge should be acknowledged without first-recharge handling, ok=%v event=%+v", ok, event) + } +} + +func TestFirstRechargeEventFromWalletMessageKeepsGoogleRecharge(t *testing.T) { + // Google 充值仍按商品 ID 进入首充奖励链路,缺商品字段应由 wallet outbox 事实修正,而不是泛化吞掉。 + body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{ + AppCode: "lalu", + EventID: "wev-google", + EventType: firstrechargedomain.RechargeEventWalletRecorded, + TransactionID: "wtx-google", + CommandID: "google-pay-1", + UserID: 42001, + AssetType: "COIN", + AvailableDelta: 160000, + PayloadJSON: `{"recharge_type":"google","google_product_id":"coins_160000","recharge_usd_minor":199,"recharge_sequence":1}`, + OccurredAtMS: 1710000000000, + }) + if err != nil { + t.Fatalf("EncodeWalletOutboxMessage failed: %v", err) + } + + event, ok, err := firstRechargeEventFromWalletMessage(body) + if err != nil { + t.Fatalf("firstRechargeEventFromWalletMessage returned error: %v", err) + } + if !ok || event.RechargeType != "google" || event.GoogleProductID != "coins_160000" { + t.Fatalf("google recharge should be routed to first-recharge handling, ok=%v event=%+v", ok, event) + } +} diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index ab65e6b4..f99df4c0 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -4098,6 +4098,29 @@ func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) { if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ?", int64(42001), ledger.AssetCoin); got != 1 { t.Fatalf("group grant should write one coin wallet entry, got %d", got) } + if got := repository.CountRows("wallet_transactions", "biz_type = ? AND command_id LIKE 'rgg:%' AND CHAR_LENGTH(command_id) <= 128", "resource_grant"); got != 1 { + t.Fatalf("group wallet asset grant should use one short internal wallet command, got %d", got) + } + longCommand := resourcedomain.GrantResourceGroupCommand{ + CommandID: "cp_weekly_rank:1781481600000:1:cp_rel:327091463391285248:327104359357747200:327091463391285248", + TargetUserID: 42002, + GroupID: group.GroupID, + Reason: "test grant long command", + OperatorUserID: 90001, + GrantSource: resourcedomain.GrantSourceAdmin, + } + if _, err := svc.GrantResourceGroup(ctx, longCommand); err != nil { + t.Fatalf("GrantResourceGroup should accept long upstream command by shortening internal item commands: %v", err) + } + if got := repository.CountRows("resource_grants", "command_id = ?", longCommand.CommandID); got != 1 { + t.Fatalf("long upstream command should still be preserved on resource grant, got %d", got) + } + if got := repository.CountRows("wallet_transactions", "biz_type = ? AND command_id LIKE 'rgg:%' AND CHAR_LENGTH(command_id) <= 128", "resource_grant"); got != 2 { + t.Fatalf("long upstream group wallet asset grant should use a second short internal wallet command, got %d", got) + } + if got := repository.CountRows("wallet_transactions", "biz_type = ? AND CHAR_LENGTH(command_id) > 128", "resource_grant"); got != 0 { + t.Fatalf("resource grant wallet transactions must not exceed command_id storage length, got %d", got) + } } // TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement 锁定撤销只按原发放明细反向处理,不受资源组后续配置变化影响。 @@ -5769,8 +5792,8 @@ func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) { if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ? AND total_coin_amount = ? AND total_usd_minor_amount = ?", int64(53001), int64(1500), int64(150)); got != 1 { t.Fatalf("google payment should aggregate recharge USD minor from micro amount, got %d", got) } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')) = ?", receipt.TransactionID, "WalletRechargeRecorded", int64(199), int64(199), int64(1001), ledger.RechargeChannelGoogle); got != 1 { - t.Fatalf("google recharge fact should carry target country/region for BI, got %d", got) + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')) = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.google_product_id')) = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.product_name')) = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.product_code')) = ?", receipt.TransactionID, "WalletRechargeRecorded", int64(199), int64(199), int64(1001), ledger.RechargeChannelGoogle, googleProductID, googleProductID, googleProductID); got != 1 { + t.Fatalf("google recharge fact should carry target country/region and product identity for BI/activity, got %d", got) } } diff --git a/services/wallet-service/internal/storage/mysql/google_payment_repository.go b/services/wallet-service/internal/storage/mysql/google_payment_repository.go index 7024d70e..b70bd57a 100644 --- a/services/wallet-service/internal/storage/mysql/google_payment_repository.go +++ b/services/wallet-service/internal/storage/mysql/google_payment_repository.go @@ -98,6 +98,9 @@ func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.Go Amount: product.CoinAmount, TargetAssetType: ledger.AssetCoin, TargetBalanceAfter: balanceAfter, + GoogleProductID: product.ProductName, + ProductCode: product.ProductCode, + ProductName: product.ProductName, RechargeSequence: rechargeSequence, RechargeUSDMinor: rechargeUSDMinor, RechargeCurrencyCode: product.CurrencyCode, diff --git a/services/wallet-service/internal/storage/mysql/metadata.go b/services/wallet-service/internal/storage/mysql/metadata.go index 1fcaab6c..939ed45f 100644 --- a/services/wallet-service/internal/storage/mysql/metadata.go +++ b/services/wallet-service/internal/storage/mysql/metadata.go @@ -198,6 +198,9 @@ type coinSellerTransferMetadata struct { SellerUserID int64 `json:"seller_user_id"` TargetUserID int64 `json:"target_user_id"` TargetCountryID int64 `json:"target_country_id"` + GoogleProductID string `json:"google_product_id,omitempty"` + ProductCode string `json:"product_code,omitempty"` + ProductName string `json:"product_name,omitempty"` SellerRegionID int64 `json:"seller_region_id"` TargetRegionID int64 `json:"target_region_id"` Amount int64 `json:"amount"` diff --git a/services/wallet-service/internal/storage/mysql/outbox.go b/services/wallet-service/internal/storage/mysql/outbox.go index cff81619..7caaeb24 100644 --- a/services/wallet-service/internal/storage/mysql/outbox.go +++ b/services/wallet-service/internal/storage/mysql/outbox.go @@ -407,6 +407,9 @@ func rechargeRecordedEvent(transactionID string, commandID string, targetUserID "target_user_id": metadata.TargetUserID, "target_country_id": metadata.TargetCountryID, "country_id": metadata.TargetCountryID, + "google_product_id": metadata.GoogleProductID, + "product_code": metadata.ProductCode, + "product_name": metadata.ProductName, "seller_region_id": metadata.SellerRegionID, "target_region_id": metadata.TargetRegionID, "region_id": metadata.TargetRegionID, diff --git a/services/wallet-service/internal/storage/mysql/resource_grant_repository.go b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go index db54c8f5..3e02b643 100644 --- a/services/wallet-service/internal/storage/mysql/resource_grant_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go @@ -409,19 +409,20 @@ func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx, if err != nil { return resourcedomain.ResourceGrantItem{}, err } - walletCommandID := fmt.Sprintf("resource_group_grant:%s:item:%d:asset:%s", commandID, groupItem.GroupItemID, assetType) + walletCommandID := resourceGroupWalletAssetCommandID(commandID, groupItem.GroupItemID, assetType) transactionID := transactionID(appcode.FromContext(ctx), walletCommandID) metadata := map[string]any{ - "app_code": appcode.FromContext(ctx), - "grant_id": grantID, - "command_id": commandID, - "target_user_id": targetUserID, - "group_id": groupItem.GroupID, - "group_item_id": groupItem.GroupItemID, - "item_type": resourcedomain.GroupItemTypeWalletAsset, - "wallet_amount": groupItem.WalletAssetAmount, - "wallet_asset": assetType, - "operator_reason": "resource group grant", + "app_code": appcode.FromContext(ctx), + "grant_id": grantID, + "command_id": commandID, + "wallet_command_id": walletCommandID, + "target_user_id": targetUserID, + "group_id": groupItem.GroupID, + "group_item_id": groupItem.GroupItemID, + "item_type": resourcedomain.GroupItemTypeWalletAsset, + "wallet_amount": groupItem.WalletAssetAmount, + "wallet_asset": assetType, + "operator_reason": "resource group grant", } if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, groupWalletAssetCreditHash(grantID, groupItem.GroupItemID, assetType, groupItem.WalletAssetAmount), grantID, metadata, nowMs); err != nil { return resourcedomain.ResourceGrantItem{}, err @@ -835,6 +836,13 @@ func groupWalletAssetCreditHash(grantID string, groupItemID int64, assetType str return stableHash(fmt.Sprintf("resource_group_wallet_asset_credit|%s|%d|%s|%d", grantID, groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount)) } +func resourceGroupWalletAssetCommandID(commandID string, groupItemID int64, assetType string) string { + // wallet_transactions.command_id 是对外幂等键字段,长度固定为 128;CP 周榜等上游命令本身可能很长, + // 组内钱包资产 item 使用稳定短 hash 生成内部交易命令,同时在 metadata.command_id 保留完整上游命令用于审计。 + hash := stableHash(fmt.Sprintf("resource_group_grant_item|%s|%d|%s", strings.TrimSpace(commandID), groupItemID, resourcedomain.NormalizeWalletAssetType(assetType))) + return fmt.Sprintf("rgg:%d:%s:%s", groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), hash[:24]) +} + func resourceGrantWalletRevokeHash(grantID string, grantItemID int64, assetType string, amount int64) string { return stableHash(fmt.Sprintf("resource_grant_wallet_revoke|%s|%d|%s|%d", grantID, grantItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount)) }