fix: hide restored google recharge products

This commit is contained in:
zhx 2026-07-09 14:23:41 +08:00
parent a13fcd61c7
commit ce9a8c2d22
16 changed files with 2548 additions and 2313 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1441,6 +1441,8 @@ message RechargeProduct {
string status = 21; string status = 21;
// audience_type H5 App normal // audience_type H5 App normal
string audience_type = 22; string audience_type = 22;
// app_visible App active Google SKU
bool app_visible = 23;
} }
message ListRechargeProductsRequest { message ListRechargeProductsRequest {
@ -1518,6 +1520,8 @@ message CreateRechargeProductRequest {
bool enabled = 9; bool enabled = 9;
int64 operator_user_id = 10; int64 operator_user_id = 10;
string audience_type = 11; string audience_type = 11;
// false Google App
optional bool app_visible = 12;
} }
message UpdateRechargeProductRequest { message UpdateRechargeProductRequest {
@ -1533,6 +1537,8 @@ message UpdateRechargeProductRequest {
bool enabled = 10; bool enabled = 10;
int64 operator_user_id = 11; int64 operator_user_id = 11;
string audience_type = 12; string audience_type = 12;
// SKU
optional bool app_visible = 13;
} }
message DeleteRechargeProductRequest { message DeleteRechargeProductRequest {

View File

@ -0,0 +1,29 @@
-- Split recharge product availability into two axes:
-- status=active keeps a Google SKU usable for server-side purchase verification,
-- app_visible=false hides it from the App's ordinary recharge product list.
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_recharge_products' AND COLUMN_NAME = 'app_visible') = 0,
'ALTER TABLE wallet_recharge_products ADD COLUMN app_visible BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否在 App 普通充值列表展示;不影响 Google Play 验单'' AFTER status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_recharge_products' AND INDEX_NAME = 'idx_wallet_recharge_products_scope') > 0,
'ALTER TABLE wallet_recharge_products DROP INDEX idx_wallet_recharge_products_scope',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_recharge_products' AND INDEX_NAME = 'idx_wallet_recharge_products_scope') = 0,
'ALTER TABLE wallet_recharge_products ADD INDEX idx_wallet_recharge_products_scope (app_code, platform, audience_type, status, app_visible, amount_micro, product_id)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -69,6 +69,7 @@ type rechargeProductDTO struct {
ResourceAssetType string `json:"resourceAssetType"` ResourceAssetType string `json:"resourceAssetType"`
Status string `json:"status"` Status string `json:"status"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
AppVisible bool `json:"appVisible"`
SortOrder int32 `json:"sortOrder"` SortOrder int32 `json:"sortOrder"`
CreatedByUserID int64 `json:"createdByUserId"` CreatedByUserID int64 `json:"createdByUserId"`
UpdatedByUserID int64 `json:"updatedByUserId"` UpdatedByUserID int64 `json:"updatedByUserId"`
@ -260,6 +261,7 @@ func rechargeProductFromProto(item *walletv1.RechargeProduct) rechargeProductDTO
ResourceAssetType: item.GetResourceAssetType(), ResourceAssetType: item.GetResourceAssetType(),
Status: item.GetStatus(), Status: item.GetStatus(),
Enabled: item.GetEnabled(), Enabled: item.GetEnabled(),
AppVisible: item.GetAppVisible(),
SortOrder: item.GetSortOrder(), SortOrder: item.GetSortOrder(),
CreatedByUserID: item.GetCreatedByUserId(), CreatedByUserID: item.GetCreatedByUserId(),
UpdatedByUserID: item.GetUpdatedByUserId(), UpdatedByUserID: item.GetUpdatedByUserId(),

View File

@ -248,7 +248,7 @@ func (h *Handler) CreateRechargeProduct(c *gin.Context) {
response.BadRequest(c, "金额USDT不正确") response.BadRequest(c, "金额USDT不正确")
return return
} }
resp, err := h.wallet.CreateRechargeProduct(c.Request.Context(), &walletv1.CreateRechargeProductRequest{ req := &walletv1.CreateRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
AmountMicro: amountMicro, AmountMicro: amountMicro,
@ -260,7 +260,11 @@ func (h *Handler) CreateRechargeProduct(c *gin.Context) {
RegionIds: request.RegionIDs, RegionIds: request.RegionIDs,
Enabled: request.Enabled, Enabled: request.Enabled,
OperatorUserId: actorID(c), OperatorUserId: actorID(c),
}) }
if request.AppVisible != nil {
req.AppVisible = request.AppVisible
}
resp, err := h.wallet.CreateRechargeProduct(c.Request.Context(), req)
if err != nil { if err != nil {
writeWalletError(c, err, "创建内购配置失败") writeWalletError(c, err, "创建内购配置失败")
return return
@ -285,7 +289,7 @@ func (h *Handler) UpdateRechargeProduct(c *gin.Context) {
response.BadRequest(c, "金额USDT不正确") response.BadRequest(c, "金额USDT不正确")
return return
} }
resp, err := h.wallet.UpdateRechargeProduct(c.Request.Context(), &walletv1.UpdateRechargeProductRequest{ req := &walletv1.UpdateRechargeProductRequest{
RequestId: middleware.CurrentRequestID(c), RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()), AppCode: appctx.FromContext(c.Request.Context()),
ProductId: productID, ProductId: productID,
@ -298,7 +302,11 @@ func (h *Handler) UpdateRechargeProduct(c *gin.Context) {
RegionIds: request.RegionIDs, RegionIds: request.RegionIDs,
Enabled: request.Enabled, Enabled: request.Enabled,
OperatorUserId: actorID(c), OperatorUserId: actorID(c),
}) }
if request.AppVisible != nil {
req.AppVisible = request.AppVisible
}
resp, err := h.wallet.UpdateRechargeProduct(c.Request.Context(), req)
if err != nil { if err != nil {
writeWalletError(c, err, "修改内购配置失败") writeWalletError(c, err, "修改内购配置失败")
return return
@ -585,6 +593,7 @@ type rechargeProductRequest struct {
Platform string `json:"platform"` Platform string `json:"platform"`
RegionIDs []int64 `json:"regionIds"` RegionIDs []int64 `json:"regionIds"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
AppVisible *bool `json:"appVisible"`
} }
type thirdPartyPaymentMethodStatusRequest struct { type thirdPartyPaymentMethodStatusRequest struct {

View File

@ -572,7 +572,7 @@ func TestSyncThirdPartyPaymentRatesRejectsInvalidMarkupPercent(t *testing.T) {
func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) { func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) {
wallet := &mockPaymentWallet{} wallet := &mockPaymentWallet{}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil)) router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
body := `{"amountUsdt":"10.000000","coinAmount":880000,"productName":"Seller 10 USD","description":"coin seller tier","audienceType":"coin_seller","platform":"web","regionIds":[7100],"enabled":true}` body := `{"amountUsdt":"10.000000","coinAmount":880000,"productName":"Seller 10 USD","description":"coin seller tier","audienceType":"coin_seller","platform":"web","regionIds":[7100],"enabled":true,"appVisible":false}`
request := httptest.NewRequest(http.MethodPost, "/admin/payment/recharge-products", bytes.NewBufferString(body)) request := httptest.NewRequest(http.MethodPost, "/admin/payment/recharge-products", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
@ -590,6 +590,8 @@ func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) {
len(wallet.lastCreateProduct.GetRegionIds()) != 1 || len(wallet.lastCreateProduct.GetRegionIds()) != 1 ||
wallet.lastCreateProduct.GetRegionIds()[0] != 7100 || wallet.lastCreateProduct.GetRegionIds()[0] != 7100 ||
!wallet.lastCreateProduct.GetEnabled() || !wallet.lastCreateProduct.GetEnabled() ||
wallet.lastCreateProduct.AppVisible == nil ||
wallet.lastCreateProduct.GetAppVisible() ||
wallet.lastCreateProduct.GetOperatorUserId() != 7 { wallet.lastCreateProduct.GetOperatorUserId() != 7 {
t.Fatalf("create recharge product request mismatch: %+v", wallet.lastCreateProduct) t.Fatalf("create recharge product request mismatch: %+v", wallet.lastCreateProduct)
} }
@ -883,6 +885,7 @@ func (m *mockPaymentWallet) CreateRechargeProduct(_ context.Context, req *wallet
CoinAmount: req.GetCoinAmount(), CoinAmount: req.GetCoinAmount(),
RegionIds: append([]int64(nil), req.GetRegionIds()...), RegionIds: append([]int64(nil), req.GetRegionIds()...),
Enabled: req.GetEnabled(), Enabled: req.GetEnabled(),
AppVisible: req.GetAppVisible(),
Status: "active", Status: "active",
}}, nil }}, nil
} }

View File

@ -325,12 +325,14 @@ func validateRechargeEvent(event domain.RechargeEvent) error {
if event.EventType != "" && event.EventType != domain.RechargeEventWalletRecorded { if event.EventType != "" && event.EventType != domain.RechargeEventWalletRecorded {
return xerr.New(xerr.InvalidArgument, "first recharge event type is invalid") return xerr.New(xerr.InvalidArgument, "first recharge event type is invalid")
} }
if event.RechargeCoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "recharge_coin_amount must be greater than zero")
}
if event.GoogleProductID == "" { if event.GoogleProductID == "" {
return xerr.New(xerr.InvalidArgument, "google_product_id is required") return xerr.New(xerr.InvalidArgument, "google_product_id is required")
} }
// Google 首充档位按 google_product_id 命中wallet 商品金币可为 0
// 这让后台能保留 Google 验单壳 SKU同时避免重复给普通金币。
if event.RechargeCoinAmount < 0 {
return xerr.New(xerr.InvalidArgument, "recharge_coin_amount must not be negative")
}
if event.OccurredAtMS <= 0 { if event.OccurredAtMS <= 0 {
return xerr.New(xerr.InvalidArgument, "occurred_at_ms is required") return xerr.New(xerr.InvalidArgument, "occurred_at_ms is required")
} }

View File

@ -54,7 +54,7 @@ func TestFirstRechargeRewardConsumesRechargeFactWithMySQL(t *testing.T) {
TransactionID: "wtx-recharge-1", TransactionID: "wtx-recharge-1",
CommandID: "seller-transfer-1", CommandID: "seller-transfer-1",
UserID: 42001, UserID: 42001,
RechargeCoinAmount: 500, RechargeCoinAmount: 0,
RechargeSequence: 2, RechargeSequence: 2,
RechargeType: "google", RechargeType: "google",
RechargeUSDMinor: 99, RechargeUSDMinor: 99,
@ -69,7 +69,7 @@ func TestFirstRechargeRewardConsumesRechargeFactWithMySQL(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetFirstRechargeRewardClaimByUser failed: %v", err) t.Fatalf("GetFirstRechargeRewardClaimByUser failed: %v", err)
} }
if !exists || claim.Status != domain.StatusGranted || claim.ResourceGroupID != 88001 || claim.GoogleProductID != "first_recharge_google_099" { if !exists || claim.Status != domain.StatusGranted || claim.ResourceGroupID != 88001 || claim.GoogleProductID != "first_recharge_google_099" || claim.RechargeCoinAmount != 0 {
t.Fatalf("consume result mismatch: exists=%v claim=%+v", exists, claim) t.Fatalf("consume result mismatch: exists=%v claim=%+v", exists, claim)
} }
if len(wallet.grants) != 1 || wallet.grants[0].GroupId != 88001 || wallet.grants[0].TargetUserId != 42001 { if len(wallet.grants) != 1 || wallet.grants[0].GroupId != 88001 || wallet.grants[0].TargetUserId != 42001 {

View File

@ -28,6 +28,7 @@ type rechargeProductData struct {
ResourceAssetType string `json:"resource_asset_type"` ResourceAssetType string `json:"resource_asset_type"`
Status string `json:"status"` Status string `json:"status"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
AppVisible bool `json:"app_visible"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
} }
@ -452,6 +453,7 @@ func rechargeProductFromProto(product *walletv1.RechargeProduct) rechargeProduct
ResourceAssetType: product.GetResourceAssetType(), ResourceAssetType: product.GetResourceAssetType(),
Status: product.GetStatus(), Status: product.GetStatus(),
Enabled: product.GetEnabled(), Enabled: product.GetEnabled(),
AppVisible: product.GetAppVisible(),
SortOrder: product.GetSortOrder(), SortOrder: product.GetSortOrder(),
} }
} }

View File

@ -579,13 +579,14 @@ CREATE TABLE IF NOT EXISTS wallet_recharge_products (
coin_amount BIGINT NOT NULL COMMENT '金币数量', coin_amount BIGINT NOT NULL COMMENT '金币数量',
resource_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '资源资产类型', resource_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '资源资产类型',
status VARCHAR(32) NOT NULL COMMENT '业务状态', status VARCHAR(32) NOT NULL COMMENT '业务状态',
app_visible BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否在 App 普通充值列表展示;不影响 Google Play 验单',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
created_by_user_id BIGINT NULL COMMENT '创建人用户 ID', created_by_user_id BIGINT NULL COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NULL COMMENT '更新人用户 ID', updated_by_user_id BIGINT NULL COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_wallet_recharge_products_code (app_code, product_code), UNIQUE KEY uk_wallet_recharge_products_code (app_code, product_code),
KEY idx_wallet_recharge_products_scope (app_code, platform, audience_type, status, amount_micro, product_id) KEY idx_wallet_recharge_products_scope (app_code, platform, audience_type, status, app_visible, amount_micro, product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包充值商品表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包充值商品表';
SET @ddl := IF( SET @ddl := IF(
@ -597,6 +598,15 @@ PREPARE stmt FROM @ddl;
EXECUTE stmt; EXECUTE stmt;
DEALLOCATE PREPARE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_recharge_products' AND COLUMN_NAME = 'app_visible') = 0,
'ALTER TABLE wallet_recharge_products ADD COLUMN app_visible BOOLEAN NOT NULL DEFAULT TRUE COMMENT ''是否在 App 普通充值列表展示;不影响 Google Play 验单'' AFTER status',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS wallet_recharge_product_regions ( CREATE TABLE IF NOT EXISTS wallet_recharge_product_regions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
product_id BIGINT NOT NULL COMMENT '商品 ID', product_id BIGINT NOT NULL COMMENT '商品 ID',

View File

@ -21,6 +21,7 @@ type RechargeProduct struct {
PolicyVersion string PolicyVersion string
Enabled bool Enabled bool
Status string Status string
AppVisible bool
SortOrder int32 SortOrder int32
RegionIDs []int64 RegionIDs []int64
ResourceAssetType string ResourceAssetType string
@ -54,6 +55,7 @@ type RechargeProductCommand struct {
Platform string Platform string
RegionIDs []int64 RegionIDs []int64
Enabled bool Enabled bool
AppVisible *bool
OperatorUserID int64 OperatorUserID int64
} }

View File

@ -330,7 +330,9 @@ func validateRechargeProductCommand(command ledger.RechargeProductCommand, requi
if requireProductID && command.ProductID <= 0 { if requireProductID && command.ProductID <= 0 {
return xerr.New(xerr.InvalidArgument, "product_id is required") return xerr.New(xerr.InvalidArgument, "product_id is required")
} }
if command.AmountMicro <= 0 || command.CoinAmount <= 0 || command.OperatorUserID <= 0 { // 隐藏的 Google 首充壳商品可以只承载支付金额和 SKU不直接给普通金币
// 是否发首充资源组由 activity-service 根据 WalletRechargeRecorded 的 google_product_id 决定。
if command.AmountMicro <= 0 || command.CoinAmount < 0 || command.OperatorUserID <= 0 {
return xerr.New(xerr.InvalidArgument, "recharge product amount and operator are required") return xerr.New(xerr.InvalidArgument, "recharge product amount and operator are required")
} }
if strings.TrimSpace(command.ProductName) == "" || len([]rune(strings.TrimSpace(command.ProductName))) > 128 { if strings.TrimSpace(command.ProductName) == "" || len([]rune(strings.TrimSpace(command.ProductName))) > 128 {

View File

@ -6446,6 +6446,9 @@ func TestRechargeProductsAreConfiguredByPlatformRegionAndStatus(t *testing.T) {
if product.ProductID <= 0 || product.ProductCode != product.ProductName || product.Channel != ledger.RechargeChannelGoogle { if product.ProductID <= 0 || product.ProductCode != product.ProductName || product.Channel != ledger.RechargeChannelGoogle {
t.Fatalf("created product identifiers mismatch: %+v", product) t.Fatalf("created product identifiers mismatch: %+v", product)
} }
if !product.AppVisible {
t.Fatalf("new recharge product should be visible in app by default: %+v", product)
}
if len(product.RegionIDs) != 2 || product.RegionIDs[0] != 1001 || product.RegionIDs[1] != 1002 { if len(product.RegionIDs) != 2 || product.RegionIDs[0] != 1001 || product.RegionIDs[1] != 1002 {
t.Fatalf("created product regions mismatch: %+v", product.RegionIDs) t.Fatalf("created product regions mismatch: %+v", product.RegionIDs)
} }
@ -6510,6 +6513,47 @@ func TestRechargeProductsAreConfiguredByPlatformRegionAndStatus(t *testing.T) {
t.Fatalf("admin disabled product mismatch: total=%d products=%+v", total, adminProducts) t.Fatalf("admin disabled product mismatch: total=%d products=%+v", total, adminProducts)
} }
hidden := false
hiddenProduct, err := svc.UpdateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
AppCode: "lalu",
ProductID: product.ProductID,
AmountMicro: 2500000,
CoinAmount: 0,
ProductName: "2600 Coins",
Description: "hidden google shell",
Platform: ledger.RechargeProductPlatformAndroid,
RegionIDs: []int64{1002},
Enabled: true,
AppVisible: &hidden,
OperatorUserID: 90003,
})
if err != nil {
t.Fatalf("UpdateRechargeProduct hidden failed: %v", err)
}
if !hiddenProduct.Enabled || hiddenProduct.AppVisible || hiddenProduct.CoinAmount != 0 {
t.Fatalf("hidden product state mismatch: %+v", hiddenProduct)
}
hiddenProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1002, ledger.RechargeProductPlatformAndroid, ledger.RechargeAudienceNormal)
if err != nil {
t.Fatalf("ListRechargeProducts hidden failed: %v", err)
}
if len(hiddenProducts) != 0 {
t.Fatalf("hidden active product must not be exposed to app: %+v", hiddenProducts)
}
adminProducts, total, err = svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{
AppCode: "lalu",
Status: ledger.RechargeProductStatusActive,
RegionID: 1002,
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListAdminRechargeProducts hidden failed: %v", err)
}
if total != 1 || len(adminProducts) != 1 || adminProducts[0].AppVisible || adminProducts[0].CoinAmount != 0 {
t.Fatalf("admin hidden product mismatch: total=%d products=%+v", total, adminProducts)
}
if err := svc.DeleteRechargeProduct(context.Background(), "lalu", product.ProductID); err != nil { if err := svc.DeleteRechargeProduct(context.Background(), "lalu", product.ProductID); err != nil {
t.Fatalf("DeleteRechargeProduct failed: %v", err) t.Fatalf("DeleteRechargeProduct failed: %v", err)
} }
@ -6669,6 +6713,74 @@ func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
} }
} }
func TestConfirmGooglePaymentAcceptsHiddenZeroCoinProduct(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
const googleProductID = "coins_hidden_first_recharge"
hidden := false
product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
AppCode: "lalu",
AmountMicro: 3990000,
CoinAmount: 0,
ProductName: googleProductID,
Description: "hidden first recharge google shell",
Platform: ledger.RechargeProductPlatformAndroid,
RegionIDs: []int64{1001},
Enabled: true,
AppVisible: &hidden,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("CreateRechargeProduct hidden failed: %v", err)
}
products, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformAndroid, ledger.RechargeAudienceNormal)
if err != nil {
t.Fatalf("ListRechargeProducts hidden failed: %v", err)
}
if len(products) != 0 {
t.Fatalf("hidden google shell must not be listed in app products: %+v", products)
}
svc.SetGooglePlayClient(&fakeGooglePlayClient{
purchase: ledger.GooglePlayPurchase{
ProductID: googleProductID,
OrderID: "GPA.hidden",
PurchaseState: ledger.GooglePurchaseStatePurchased,
ConsumptionState: "CONSUMPTION_STATE_YET_TO_BE_CONSUMED",
AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
},
})
receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{
AppCode: "lalu",
CommandID: "google-pay-hidden-zero",
UserID: 53001,
RegionID: 1001,
CountryID: 199,
ProductCode: googleProductID,
PackageName: "com.org.laluparty",
PurchaseToken: "purchase-token-hidden-zero",
OrderID: "GPA.hidden",
})
if err != nil {
t.Fatalf("ConfirmGooglePayment hidden failed: %v", err)
}
if receipt.Status != ledger.PaymentStatusCredited || receipt.ProductID != product.ProductID || receipt.CoinAmount != 0 || receipt.Balance.AvailableAmount != 0 {
t.Fatalf("hidden google receipt mismatch: %+v", receipt)
}
if got := repository.CountRows("payment_orders", "provider_order_id = ? AND product_code = ? AND coin_amount = ?", "GPA.hidden", googleProductID, int64(0)); got != 1 {
t.Fatalf("hidden google payment should write one zero-coin payment order, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(0)); got != 1 {
t.Fatalf("hidden google payment should write one zero-delta wallet entry, got %d", got)
}
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ?", receipt.TransactionID, int64(0), int64(399)); got != 1 {
t.Fatalf("hidden google payment should keep paid USD recharge fact, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND available_delta = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.google_product_id')) = ?", receipt.TransactionID, "WalletRechargeRecorded", int64(0), googleProductID); got != 1 {
t.Fatalf("hidden google payment should publish zero-coin recharge fact for activity, got %d", got)
}
}
func TestConfirmGooglePaymentAcceptsLegacyProductCodeInput(t *testing.T) { func TestConfirmGooglePaymentAcceptsLegacyProductCodeInput(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository) svc := walletservice.New(repository)

View File

@ -24,7 +24,7 @@ func (r *Repository) ListRechargeProducts(ctx context.Context, _ int64, regionID
platform = ledger.NormalizeRechargeProductPlatform(platform) platform = ledger.NormalizeRechargeProductPlatform(platform)
audienceType = ledger.NormalizeRechargeAudienceType(audienceType) audienceType = ledger.NormalizeRechargeAudienceType(audienceType)
where := ` where := `
WHERE p.app_code = ? AND p.status = ? AND p.audience_type = ? AND pr.region_id = ?` WHERE p.app_code = ? AND p.status = ? AND p.app_visible = TRUE AND p.audience_type = ? AND pr.region_id = ?`
args := []any{app, ledger.RechargeProductStatusActive, audienceType, regionID} args := []any{app, ledger.RechargeProductStatusActive, audienceType, regionID}
if platform != "" { if platform != "" {
where += ` AND p.platform = ?` where += ` AND p.platform = ?`
@ -120,12 +120,12 @@ func (r *Repository) CreateRechargeProduct(ctx context.Context, command ledger.R
result, err := tx.ExecContext(ctx, ` result, err := tx.ExecContext(ctx, `
INSERT INTO wallet_recharge_products ( INSERT INTO wallet_recharge_products (
app_code, audience_type, product_name, description, platform, channel, currency_code, amount_micro, app_code, audience_type, product_name, description, platform, channel, currency_code, amount_micro,
coin_amount, resource_asset_type, status, sort_order, created_by_user_id, updated_by_user_id, coin_amount, resource_asset_type, status, app_visible, sort_order, created_by_user_id, updated_by_user_id,
created_at_ms, updated_at_ms created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.AppCode, command.AudienceType, command.ProductName, command.Description, command.Platform, command.AppCode, command.AudienceType, command.ProductName, command.Description, command.Platform,
ledger.RechargeChannelForPlatform(command.Platform), ledger.PaidCurrencyUSDT, command.AmountMicro, ledger.RechargeChannelForPlatform(command.Platform), ledger.PaidCurrencyUSDT, command.AmountMicro,
command.CoinAmount, rechargeProductAssetType(command.AudienceType), productStatus(command.Enabled), 0, command.CoinAmount, rechargeProductAssetType(command.AudienceType), productStatus(command.Enabled), rechargeProductAppVisible(command, true), 0,
command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
) )
if err != nil { if err != nil {
@ -173,10 +173,12 @@ func (r *Repository) UpdateRechargeProduct(ctx context.Context, command ledger.R
UPDATE wallet_recharge_products UPDATE wallet_recharge_products
SET audience_type = ?, product_code = ?, product_name = ?, description = ?, platform = ?, channel = ?, currency_code = ?, SET audience_type = ?, product_code = ?, product_name = ?, description = ?, platform = ?, channel = ?, currency_code = ?,
amount_micro = ?, coin_amount = ?, resource_asset_type = ?, status = ?, amount_micro = ?, coin_amount = ?, resource_asset_type = ?, status = ?,
app_visible = CASE WHEN ? THEN ? ELSE app_visible END,
updated_by_user_id = ?, updated_at_ms = ? updated_by_user_id = ?, updated_at_ms = ?
WHERE app_code = ? AND product_id = ?`, WHERE app_code = ? AND product_id = ?`,
command.AudienceType, command.ProductName, command.ProductName, command.Description, command.Platform, ledger.RechargeChannelForPlatform(command.Platform), command.AudienceType, command.ProductName, command.ProductName, command.Description, command.Platform, ledger.RechargeChannelForPlatform(command.Platform),
ledger.PaidCurrencyUSDT, command.AmountMicro, command.CoinAmount, rechargeProductAssetType(command.AudienceType), productStatus(command.Enabled), ledger.PaidCurrencyUSDT, command.AmountMicro, command.CoinAmount, rechargeProductAssetType(command.AudienceType), productStatus(command.Enabled),
command.AppVisible != nil, rechargeProductAppVisible(command, true),
command.OperatorUserID, nowMs, command.AppCode, command.ProductID, command.OperatorUserID, nowMs, command.AppCode, command.ProductID,
) )
if err != nil { if err != nil {
@ -303,7 +305,7 @@ func rechargeProductSelectSQL() string {
return ` return `
SELECT p.app_code, p.product_id, COALESCE(p.product_code, ''), p.product_name, SELECT p.app_code, p.product_id, COALESCE(p.product_code, ''), p.product_name,
p.description, p.platform, p.channel, p.currency_code, p.amount_micro, p.description, p.platform, p.channel, p.currency_code, p.amount_micro,
p.coin_amount, p.resource_asset_type, p.status, p.sort_order, p.coin_amount, p.resource_asset_type, p.status, COALESCE(p.app_visible, TRUE), p.sort_order,
p.created_by_user_id, p.updated_by_user_id, p.created_at_ms, p.updated_at_ms, p.created_by_user_id, p.updated_by_user_id, p.created_at_ms, p.updated_at_ms,
COALESCE(p.audience_type, 'normal') COALESCE(p.audience_type, 'normal')
FROM wallet_recharge_products p FROM wallet_recharge_products p
@ -327,6 +329,7 @@ func scanRechargeProduct(scanner scanTarget) (ledger.RechargeProduct, error) {
&product.CoinAmount, &product.CoinAmount,
&product.ResourceAssetType, &product.ResourceAssetType,
&product.Status, &product.Status,
&product.AppVisible,
&product.SortOrder, &product.SortOrder,
&createdBy, &createdBy,
&updatedBy, &updatedBy,
@ -472,6 +475,13 @@ func normalizeRechargeProductCommand(command ledger.RechargeProductCommand) ledg
return command return command
} }
func rechargeProductAppVisible(command ledger.RechargeProductCommand, defaultValue bool) bool {
if command.AppVisible == nil {
return defaultValue
}
return *command.AppVisible
}
func normalizeRechargeProductRegions(regionIDs []int64) []int64 { func normalizeRechargeProductRegions(regionIDs []int64) []int64 {
seen := make(map[int64]struct{}, len(regionIDs)) seen := make(map[int64]struct{}, len(regionIDs))
out := make([]int64, 0, len(regionIDs)) out := make([]int64, 0, len(regionIDs))

View File

@ -71,6 +71,11 @@ func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error {
ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '充值档位适用用户类型normal/coin_seller' AFTER app_code`); err != nil && !isDuplicateColumnError(err) { ADD COLUMN audience_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '充值档位适用用户类型normal/coin_seller' AFTER app_code`); err != nil && !isDuplicateColumnError(err) {
return err return err
} }
if _, err := db.ExecContext(ctx, `
ALTER TABLE wallet_recharge_products
ADD COLUMN app_visible BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否在 App 普通充值列表展示不影响 Google Play 验单' AFTER status`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, ` if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS third_party_payment_channels ( CREATE TABLE IF NOT EXISTS third_party_payment_channels (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码用于多租户隔离',

View File

@ -90,6 +90,7 @@ func (s *Server) CreateRechargeProduct(ctx context.Context, req *walletv1.Create
Platform: req.GetPlatform(), Platform: req.GetPlatform(),
RegionIDs: req.GetRegionIds(), RegionIDs: req.GetRegionIds(),
Enabled: req.GetEnabled(), Enabled: req.GetEnabled(),
AppVisible: req.AppVisible,
OperatorUserID: req.GetOperatorUserId(), OperatorUserID: req.GetOperatorUserId(),
AudienceType: req.GetAudienceType(), AudienceType: req.GetAudienceType(),
}) })
@ -112,6 +113,7 @@ func (s *Server) UpdateRechargeProduct(ctx context.Context, req *walletv1.Update
Platform: req.GetPlatform(), Platform: req.GetPlatform(),
RegionIDs: req.GetRegionIds(), RegionIDs: req.GetRegionIds(),
Enabled: req.GetEnabled(), Enabled: req.GetEnabled(),
AppVisible: req.AppVisible,
OperatorUserID: req.GetOperatorUserId(), OperatorUserID: req.GetOperatorUserId(),
AudienceType: req.GetAudienceType(), AudienceType: req.GetAudienceType(),
}) })
@ -147,6 +149,7 @@ func rechargeProductToProto(product ledger.RechargeProduct) *walletv1.RechargePr
PolicyVersion: product.PolicyVersion, PolicyVersion: product.PolicyVersion,
Status: product.Status, Status: product.Status,
Enabled: product.Enabled, Enabled: product.Enabled,
AppVisible: product.AppVisible,
SortOrder: product.SortOrder, SortOrder: product.SortOrder,
RegionIds: product.RegionIDs, RegionIds: product.RegionIDs,
ResourceAssetType: product.ResourceAssetType, ResourceAssetType: product.ResourceAssetType,