修复钱包错误

This commit is contained in:
zhx 2026-06-26 22:14:11 +08:00
parent 2254097ea4
commit beab5f57c8
7 changed files with 120 additions and 13 deletions

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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"`

View File

@ -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,

View File

@ -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 是对外幂等键字段,长度固定为 128CP 周榜等上游命令本身可能很长,
// 组内钱包资产 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))
}