From d8ff290392bdd3d209e31f5f9cd8d7489bc95837 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 15 Jul 2026 13:09:53 +0800 Subject: [PATCH] feat(finance): verify Binance off-chain receipts --- api/proto/wallet/v1/wallet.pb.go | 19 +++- api/proto/wallet/v1/wallet.proto | 3 + .../internal/modules/financeorder/handler.go | 16 ++++ .../internal/modules/financeorder/routes.go | 1 + .../internal/modules/financeorder/service.go | 22 ++++- .../modules/financeorder/service_test.go | 17 ++++ .../modules/financeorder/usdt_address.go | 86 +++++++++++++++++++ .../modules/financeorder/usdt_address_test.go | 34 ++++++++ .../repository/role_permission_matrix.go | 1 + server/admin/internal/repository/seed.go | 1 + .../migrations/093_role_permission_matrix.sql | 4 +- ...finance_usdt_address_update_permission.sql | 19 ++++ .../internal/client/binance/client.go | 25 +++++- .../internal/client/binance/client_test.go | 49 +++++++++++ .../internal/domain/ledger/constants.go | 3 + .../internal/domain/ledger/recharge.go | 4 + .../wallet/external_recharge_receipt.go | 17 +++- .../wallet/external_recharge_receipt_test.go | 62 +++++++++++++ .../transport/grpc/external_recharge.go | 1 + 19 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 server/admin/migrations/097_finance_usdt_address_update_permission.sql diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 1560ccff..67d1ffa2 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -16146,8 +16146,11 @@ type VerifyCoinSellerRechargeReceiptRequest struct { ProviderCurrencyCode string `protobuf:"bytes,9,opt,name=provider_currency_code,json=providerCurrencyCode,proto3" json:"provider_currency_code,omitempty"` ProviderPayType string `protobuf:"bytes,10,opt,name=provider_pay_type,json=providerPayType,proto3" json:"provider_pay_type,omitempty"` ProviderAmountMinor int64 `protobuf:"varint,11,opt,name=provider_amount_minor,json=providerAmountMinor,proto3" json:"provider_amount_minor,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // receive_address 由可信后台从 app-scoped SQL 配置读取;非空时覆盖 wallet YAML, + // 让运营修改收款地址后立即按新地址校验,同时避免 wallet-service 反向直连 admin 数据库。 + ReceiveAddress string `protobuf:"bytes,12,opt,name=receive_address,json=receiveAddress,proto3" json:"receive_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *VerifyCoinSellerRechargeReceiptRequest) Reset() { @@ -16257,6 +16260,13 @@ func (x *VerifyCoinSellerRechargeReceiptRequest) GetProviderAmountMinor() int64 return 0 } +func (x *VerifyCoinSellerRechargeReceiptRequest) GetReceiveAddress() string { + if x != nil { + return x.ReceiveAddress + } + return "" +} + type VerifyCoinSellerRechargeReceiptResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Verified bool `protobuf:"varint,1,opt,name=verified,proto3" json:"verified,omitempty"` @@ -27435,7 +27445,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\border_id\x18\x03 \x01(\tR\aorderId\x12$\n" + "\x0etarget_user_id\x18\x04 \x01(\x03R\ftargetUserId\"W\n" + "\x17H5RechargeOrderResponse\x12<\n" + - "\x05order\x18\x01 \x01(\v2&.hyapp.wallet.v1.ExternalRechargeOrderR\x05order\"\xe1\x03\n" + + "\x05order\x18\x01 \x01(\v2&.hyapp.wallet.v1.ExternalRechargeOrderR\x05order\"\x8a\x04\n" + "&VerifyCoinSellerRechargeReceiptRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + @@ -27449,7 +27459,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x16provider_currency_code\x18\t \x01(\tR\x14providerCurrencyCode\x12*\n" + "\x11provider_pay_type\x18\n" + " \x01(\tR\x0fproviderPayType\x122\n" + - "\x15provider_amount_minor\x18\v \x01(\x03R\x13providerAmountMinor\"\xda\x03\n" + + "\x15provider_amount_minor\x18\v \x01(\x03R\x13providerAmountMinor\x12'\n" + + "\x0freceive_address\x18\f \x01(\tR\x0ereceiveAddress\"\xda\x03\n" + "'VerifyCoinSellerRechargeReceiptResponse\x12\x1a\n" + "\bverified\x18\x01 \x01(\bR\bverified\x12#\n" + "\rprovider_code\x18\x02 \x01(\tR\fproviderCode\x12*\n" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index ee2b6a6e..0cf62e7a 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -1861,6 +1861,9 @@ message VerifyCoinSellerRechargeReceiptRequest { string provider_currency_code = 9; string provider_pay_type = 10; int64 provider_amount_minor = 11; + // receive_address 由可信后台从 app-scoped SQL 配置读取;非空时覆盖 wallet YAML, + // 让运营修改收款地址后立即按新地址校验,同时避免 wallet-service 反向直连 admin 数据库。 + string receive_address = 12; } message VerifyCoinSellerRechargeReceiptResponse { diff --git a/server/admin/internal/modules/financeorder/handler.go b/server/admin/internal/modules/financeorder/handler.go index a21d1988..66382562 100644 --- a/server/admin/internal/modules/financeorder/handler.go +++ b/server/admin/internal/modules/financeorder/handler.go @@ -110,6 +110,22 @@ func (h *Handler) GetUSDTAddresses(c *gin.Context) { response.OK(c, item) } +func (h *Handler) UpsertUSDTAddress(c *gin.Context) { + var req financeUSDTAddressUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "USDT 收款地址参数不正确") + return + } + item, err := h.service.UpsertUSDTAddress(shared.ActorFromContext(c), c.Param("app_code"), c.Param("chain"), req) + if err != nil { + writeServiceError(c, err, "保存 USDT 收款地址失败") + return + } + // 收款地址决定真实资金凭证归属,审计资源 ID 同时包含 App 与链,便于后续定位误改来源。 + shared.OperationLogWithResourceID(c, h.audit, "upsert-finance-usdt-address", "admin_app_configs", item.AppCode+":"+item.Chain, "success", "") + response.OK(c, item) +} + func (h *Handler) ReplaceCoinSellerRechargeExchangeRate(c *gin.Context) { var req coinSellerRechargeExchangeRatePolicyRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/server/admin/internal/modules/financeorder/routes.go b/server/admin/internal/modules/financeorder/routes.go index bc5e0225..0a353999 100644 --- a/server/admin/internal/modules/financeorder/routes.go +++ b/server/admin/internal/modules/financeorder/routes.go @@ -18,6 +18,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.POST("/admin/finance/orders/coin-seller-recharges/:order_id/verify", middleware.RequirePermission(permissionCoinSellerRechargeVerify), h.VerifyCoinSellerRechargeOrder) protected.POST("/admin/finance/orders/coin-seller-recharges/:order_id/grant", middleware.RequirePermission(permissionCoinSellerRechargeGrant), h.GrantCoinSellerRechargeOrder) protected.GET("/admin/finance/usdt-addresses/:app_code", middleware.RequirePermission(permissionCoinSellerRechargeView), h.GetUSDTAddresses) + protected.PUT("/admin/finance/usdt-addresses/:app_code/:chain", middleware.RequirePermission(permissionUSDTAddressUpdate), h.UpsertUSDTAddress) protected.GET("/admin/finance/coin-seller-recharge-exchange-rates/:app_code", middleware.RequirePermission(permissionCoinSellerExchangeRate), h.GetCoinSellerRechargeExchangeRate) protected.PUT("/admin/finance/coin-seller-recharge-exchange-rates/:app_code", middleware.RequirePermission(permissionCoinSellerExchangeRate), h.ReplaceCoinSellerRechargeExchangeRate) } diff --git a/server/admin/internal/modules/financeorder/service.go b/server/admin/internal/modules/financeorder/service.go index ba821f72..6b917019 100644 --- a/server/admin/internal/modules/financeorder/service.go +++ b/server/admin/internal/modules/financeorder/service.go @@ -29,6 +29,7 @@ const ( permissionCoinSellerRechargeCreate = "finance-order:coin-seller-recharge:create" permissionCoinSellerRechargeVerify = "finance-order:coin-seller-recharge:verify" permissionCoinSellerRechargeGrant = "finance-order:coin-seller-recharge:grant" + permissionUSDTAddressUpdate = "finance-order:usdt-address:update" permissionCoinSellerExchangeRate = "coin-seller:exchange-rate" coinSellerRechargeAssetType = "COIN_SELLER_COIN" @@ -261,6 +262,10 @@ func (s *Service) VerifyOrder(ctx context.Context, actor shared.Actor, id uint, if order.GrantStatus == model.CoinSellerRechargeGrantStatusGranted { return nil, repository.ErrCoinSellerRechargeOrderGranted } + receiveAddress, err := s.financeUSDTReceiveAddress(order.AppCode, order.Chain) + if err != nil { + return nil, err + } resp, err := s.wallet.VerifyCoinSellerRechargeReceipt(appctx.WithContext(ctx, order.AppCode), &walletv1.VerifyCoinSellerRechargeReceiptRequest{ RequestId: strings.TrimSpace(requestID), AppCode: order.AppCode, @@ -273,6 +278,7 @@ func (s *Service) VerifyOrder(ctx context.Context, actor shared.Actor, id uint, ProviderCurrencyCode: order.ProviderCurrencyCode, ProviderAmountMinor: order.ProviderAmountMinor, ProviderPayType: order.ProviderPayType, + ReceiveAddress: receiveAddress, }) if err != nil { return nil, err @@ -543,7 +549,12 @@ func (s *Service) verifyReceiptInput(ctx context.Context, input normalizedReceip if s == nil || s.wallet == nil { return coinSellerRechargeReceiptVerificationDTO{}, errors.New("finance order wallet verifier is not configured") } - // admin-server 不直连三方支付和链节点;wallet-service 统一持有 provider 配置、收款地址和查单适配器。 + receiveAddress, err := s.financeUSDTReceiveAddress(input.AppCode, input.Chain) + if err != nil { + return coinSellerRechargeReceiptVerificationDTO{}, err + } + // admin-server 不直连三方支付和链节点;它只把当前 App 的 SQL 地址作为可信配置快照传给 wallet-service, + // wallet-service 继续统一持有 provider 凭证和查单适配器,避免任一服务反向读取对方数据库。 resp, err := s.wallet.VerifyCoinSellerRechargeReceipt(appctx.WithContext(ctx, input.AppCode), &walletv1.VerifyCoinSellerRechargeReceiptRequest{ RequestId: strings.TrimSpace(requestID), AppCode: input.AppCode, @@ -556,6 +567,7 @@ func (s *Service) verifyReceiptInput(ctx context.Context, input normalizedReceip ProviderCurrencyCode: input.ProviderCurrencyCode, ProviderAmountMinor: input.ProviderAmountMinor, ProviderPayType: input.ProviderPayType, + ReceiveAddress: receiveAddress, }) if err != nil { return coinSellerRechargeReceiptVerificationDTO{}, err @@ -889,6 +901,8 @@ func normalizeRechargeProvider(providerCode string, chain string) (string, strin return "usdt", "BSC", nil case "usdt_c2c", "c2c": return "usdt", "C2C", nil + case "usdt_offchain", "offchain", "binance_offchain": + return "usdt", "OFFCHAIN", nil default: return "", "", errors.New("充值方式不正确") } @@ -906,6 +920,8 @@ func normalizeRechargeChain(chain string) string { return "BSC" case "c2c", "usdt_c2c": return "C2C" + case "offchain", "off_chain", "usdt_offchain", "binance_offchain": + return "OFFCHAIN" default: return "" } @@ -922,6 +938,8 @@ func receiptProviderCode(order model.CoinSellerRechargeOrder) string { return "usdt_bep20" case "C2C": return "usdt_c2c" + case "OFFCHAIN": + return "usdt_offchain" default: return "usdt" } @@ -948,6 +966,8 @@ func receiptChain(order model.CoinSellerRechargeOrder) string { return "bep20" case "C2C": return "c2c" + case "OFFCHAIN": + return "offchain" default: return "" } diff --git a/server/admin/internal/modules/financeorder/service_test.go b/server/admin/internal/modules/financeorder/service_test.go index ac8b8120..47af9538 100644 --- a/server/admin/internal/modules/financeorder/service_test.go +++ b/server/admin/internal/modules/financeorder/service_test.go @@ -38,6 +38,23 @@ func TestNormalizeCreateOrderRequiresExplicitUSDTChain(t *testing.T) { } } +func TestNormalizeCreateOrderSupportsExplicitBinanceOffchain(t *testing.T) { + input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{ + AppCode: "aslan", + TargetUserID: "4337389", + USDAmount: 300.14, + ProviderCode: "usdt", + Chain: "binance_offchain", + ExternalOrderNo: "391252658053", + }) + if err != nil { + t.Fatalf("normalize explicit Binance Off-chain failed: %v", err) + } + if input.ProviderCode != "usdt" || input.Chain != "OFFCHAIN" || input.USDMinorAmount != 30014 || receiptVerificationProviderCode(normalizedReceiptVerificationInput{ProviderCode: input.ProviderCode, Chain: input.Chain}) != "usdt_offchain" { + t.Fatalf("explicit Binance Off-chain mapping mismatch: %+v", input) + } +} + func TestNormalizeCreateOrderRequiresProviderCurrencyAmount(t *testing.T) { for _, providerCode := range []string{"mifapay", "v5pay"} { _, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{ diff --git a/server/admin/internal/modules/financeorder/usdt_address.go b/server/admin/internal/modules/financeorder/usdt_address.go index f2edd9cd..6641bf0b 100644 --- a/server/admin/internal/modules/financeorder/usdt_address.go +++ b/server/admin/internal/modules/financeorder/usdt_address.go @@ -9,6 +9,8 @@ import ( "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" + + "gorm.io/gorm" ) const financeUSDTAddressGroup = "finance-usdt-address" @@ -29,6 +31,17 @@ type financeUSDTAddressesDTO struct { Items []financeUSDTAddressDTO `json:"items"` } +type financeUSDTAddressUpsertRequest struct { + Address string `json:"address"` +} + +type financeUSDTAddressUpsertDTO struct { + AppCode string `json:"appCode"` + Chain string `json:"chain"` + Address string `json:"address"` + UpdatedAtMS int64 `json:"updatedAtMs"` +} + // GetUSDTAddresses 只读取当前 App 自己持有的配置行,不继承空 app_code 的历史基线。 // 收款地址一旦跨 App 回退,运营会拿到另一账套的地址并让链上订单校验必然失败。 func (s *Service) GetUSDTAddresses(actor shared.Actor, appCode string) (*financeUSDTAddressesDTO, error) { @@ -61,6 +74,79 @@ func (s *Service) GetUSDTAddresses(actor shared.Actor, appCode string) (*finance return &financeUSDTAddressesDTO{AppCode: appCode, Items: items}, nil } +// UpsertUSDTAddress 把收款地址写入现有 app-scoped 配置表;唯一键确保更新是单行 upsert, +// 不新增地址历史副本,也不会覆盖其他 App。生产查单会在每次校验前重新读取该行。 +func (s *Service) UpsertUSDTAddress(actor shared.Actor, appCode string, chain string, req financeUSDTAddressUpsertRequest) (*financeUSDTAddressUpsertDTO, error) { + if s == nil || s.store == nil { + return nil, errors.New("admin store is not configured") + } + if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionUSDTAddressUpdate) { + return nil, errCoinSellerRechargeForbidden + } + if strings.TrimSpace(appCode) == "" { + return nil, errInvalidCoinSellerRechargeOrderInput + } + appCode = appctx.Normalize(appCode) + chain = strings.ToUpper(strings.TrimSpace(chain)) + address := strings.TrimSpace(req.Address) + if _, supported := financeUSDTChainOrder[chain]; !supported { + return nil, errors.New("USDT 链不正确") + } + if !validFinanceUSDTAddress(chain, address) { + return nil, errors.New("USDT 收款地址不正确") + } + access, err := s.store.AppAccessForUser(actor.UserID) + if err != nil { + return nil, err + } + if !access.Allows(appCode) { + return nil, errCoinSellerRechargeForbidden + } + if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{{ + Group: financeUSDTAddressGroup, + Key: chain, + Value: address, + Description: "财务 USDT 收款地址", + }}); err != nil { + return nil, err + } + item, err := s.store.GetOwnedAppConfig(appCode, financeUSDTAddressGroup, chain) + if err != nil { + return nil, err + } + return &financeUSDTAddressUpsertDTO{AppCode: appCode, Chain: chain, Address: item.Value, UpdatedAtMS: item.UpdatedAtMS}, nil +} + +// financeUSDTReceiveAddress 把订单校验链映射到当前 SQL 地址。Off-chain 虽没有公链 hash, +// 但 Binance Deposit History 仍记录实际 TRX 收款地址,因此必须复用 TRON 配置;C2C Pay 则没有地址。 +func (s *Service) financeUSDTReceiveAddress(appCode string, receiptChain string) (string, error) { + addressChain := "" + switch strings.ToUpper(strings.TrimSpace(receiptChain)) { + case "TRON": + addressChain = "TRON" + case "BSC": + addressChain = "BSC" + case "OFFCHAIN": + addressChain = "TRON" + case "", "C2C": + return "", nil + default: + return "", errors.New("USDT 链不正确") + } + item, err := s.store.GetOwnedAppConfig(appctx.Normalize(appCode), financeUSDTAddressGroup, addressChain) + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", fmt.Errorf("当前 APP 未配置 %s USDT 收款地址", addressChain) + } + if err != nil { + return "", err + } + address := strings.TrimSpace(item.Value) + if !validFinanceUSDTAddress(addressChain, address) { + return "", fmt.Errorf("%s USDT 收款地址配置不正确", addressChain) + } + return address, nil +} + func financeUSDTAddressesFromConfigs(configs []model.AppConfig) ([]financeUSDTAddressDTO, error) { items := make([]financeUSDTAddressDTO, 0, len(configs)) for _, config := range configs { diff --git a/server/admin/internal/modules/financeorder/usdt_address_test.go b/server/admin/internal/modules/financeorder/usdt_address_test.go index 17c3cda9..da8ef3d5 100644 --- a/server/admin/internal/modules/financeorder/usdt_address_test.go +++ b/server/admin/internal/modules/financeorder/usdt_address_test.go @@ -4,6 +4,11 @@ import ( "testing" "hyapp-admin-server/internal/model" + "hyapp-admin-server/internal/repository" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" ) func TestFinanceUSDTAddressesFromConfigsKeepsSupportedValidChainsInBusinessOrder(t *testing.T) { @@ -26,3 +31,32 @@ func TestFinanceUSDTAddressesFromConfigsRejectsMalformedStoredAddress(t *testing t.Fatal("malformed SQL address must fail closed instead of being copied into a recharge order") } } + +func TestFinanceUSDTReceiveAddressMapsOffchainToCurrentTRONSQLRow(t *testing.T) { + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sql mock failed: %v", err) + } + defer func() { _ = sqlDB.Close() }() + gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + t.Fatalf("create gorm db failed: %v", err) + } + const address = "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J" + mock.ExpectQuery("SELECT \\* FROM `admin_app_configs` WHERE app_code = \\? AND `group` = \\? AND `key` = \\? AND is_deleted = FALSE"). + WithArgs("aslan", financeUSDTAddressGroup, "TRON", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "app_code", "group", "key", "value", "description", "is_deleted", "created_at_ms", "updated_at_ms"}). + AddRow(9, "aslan", financeUSDTAddressGroup, "TRON", address, "财务 USDT 收款地址", false, int64(100), int64(200))) + + service := NewService(repository.New(gormDB), nil, nil) + got, err := service.financeUSDTReceiveAddress(" ASLAN ", "OFFCHAIN") + if err != nil { + t.Fatalf("load current Off-chain TRON address failed: %v", err) + } + if got != address { + t.Fatalf("Off-chain must use current app-scoped TRON SQL address, got %q", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} diff --git a/server/admin/internal/repository/role_permission_matrix.go b/server/admin/internal/repository/role_permission_matrix.go index 74ff4332..8b7f8e02 100644 --- a/server/admin/internal/repository/role_permission_matrix.go +++ b/server/admin/internal/repository/role_permission_matrix.go @@ -31,6 +31,7 @@ var ( "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", + "finance-order:usdt-address:update", "finance-withdrawal:view", "finance-withdrawal:audit", } diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 9733077a..8c1385ab 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -142,6 +142,7 @@ var defaultPermissions = []model.Permission{ {Name: "币商充值订单创建", Code: "finance-order:coin-seller-recharge:create", Kind: "button"}, {Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"}, {Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"}, + {Name: "USDT 收款地址编辑", Code: "finance-order:usdt-address:update", Kind: "button", Description: "允许按 APP 新增或修改 USDT 收款地址"}, {Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"}, {Name: "用户提现申请审核", Code: "finance-withdrawal:audit", Kind: "button"}, {Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"}, diff --git a/server/admin/migrations/093_role_permission_matrix.sql b/server/admin/migrations/093_role_permission_matrix.sql index 5e9bd527..276f0e5d 100644 --- a/server/admin/migrations/093_role_permission_matrix.sql +++ b/server/admin/migrations/093_role_permission_matrix.sql @@ -95,7 +95,7 @@ JOIN admin_permissions permission WHERE role.code = 'ops-admin' AND permission.code IN ( 'overview:view', - 'finance-order:coin-seller-recharge:view', 'finance-order:coin-seller-recharge:create', 'finance-order:coin-seller-recharge:verify', 'finance-order:coin-seller-recharge:grant', + 'finance-order:coin-seller-recharge:view', 'finance-order:coin-seller-recharge:create', 'finance-order:coin-seller-recharge:verify', 'finance-order:coin-seller-recharge:grant', 'finance-order:usdt-address:update', 'finance-withdrawal:view', 'finance-withdrawal:audit', 'app-user:view', 'app-user:export', 'app-user:update', 'app-user:level', 'app-user:status', 'app-user:password', 'level-config:view', 'level-config:update', 'pretty-id:view', 'pretty-id:update', 'pretty-id:grant', @@ -146,7 +146,7 @@ JOIN admin_permissions permission WHERE role.code = 'operations-specialist' AND permission.code IN ( 'overview:view', - 'finance-order:coin-seller-recharge:view', 'finance-order:coin-seller-recharge:create', 'finance-order:coin-seller-recharge:verify', 'finance-order:coin-seller-recharge:grant', + 'finance-order:coin-seller-recharge:view', 'finance-order:coin-seller-recharge:create', 'finance-order:coin-seller-recharge:verify', 'finance-order:coin-seller-recharge:grant', 'finance-order:usdt-address:update', 'finance-withdrawal:view', 'finance-withdrawal:audit', 'app-user:view', 'app-user:export', 'app-user:update', 'app-user:level', 'app-user:status', 'app-user:password', 'level-config:view', 'level-config:update', 'pretty-id:view', 'pretty-id:update', 'pretty-id:grant', diff --git a/server/admin/migrations/097_finance_usdt_address_update_permission.sql b/server/admin/migrations/097_finance_usdt_address_update_permission.sql new file mode 100644 index 00000000..bad2f2ee --- /dev/null +++ b/server/admin/migrations/097_finance_usdt_address_update_permission.sql @@ -0,0 +1,19 @@ +-- 收款地址是按 APP 隔离的财务敏感配置;独立写权限避免仅有订单查看权限的账号修改校验口径。 +-- 迁移只通过角色 code、权限 code 唯一索引写少量元数据,不扫描订单或配置业务表。 +SET @now_ms = CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) +VALUES ('USDT 收款地址编辑', 'finance-order:usdt-address:update', 'button', '允许按 APP 新增或修改 USDT 收款地址', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +-- 平台管理员、运营负责人和运营专员原本都能创建并校验币商充值订单;地址维护沿用同一岗位边界。 +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT role.id, permission.id +FROM admin_roles role +JOIN admin_permissions permission +WHERE role.code IN ('platform-admin', 'ops-admin', 'operations-specialist') + AND permission.code = 'finance-order:usdt-address:update'; diff --git a/services/wallet-service/internal/client/binance/client.go b/services/wallet-service/internal/client/binance/client.go index df578f09..d87ef570 100644 --- a/services/wallet-service/internal/client/binance/client.go +++ b/services/wallet-service/internal/client/binance/client.go @@ -67,8 +67,8 @@ func newAccount(cfg config.BinanceAccountConfig) account { } } -// FindUSDTTransfer 先查链上 Deposit History,再在未命中时查 Funding Account 的 Pay C2C History。 -// 两个事实源的成功语义不同:链上入金必须校验网络、地址和状态;Pay C2C 必须校验正金额,证明是当前 API Key 账户收款而不是付款。 +// FindUSDTTransfer 按显式来源查 Binance 收款事实:C2C 只查 Pay,OFFCHAIN 只查 Deposit History; +// 历史 TRX/BSC 调用仍保留 Deposit 未命中后的 Pay 兼容路径。链上/站内入金必须校验网络、地址和状态,Pay C2C 必须证明是当前 API Key 账户正向收款。 func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransferLookupRequest) (ports.BinanceTransferRecord, error) { if c == nil || c.apiBaseURL == "" { return ports.BinanceTransferRecord{}, xerr.New(xerr.Unavailable, "binance client is not configured") @@ -104,6 +104,11 @@ func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransfer if err != nil { return ports.BinanceTransferRecord{}, err } + if req.Network == "OFFCHAIN" { + // 显式 Off-chain 只接受 Deposit History 中带该前缀的 TRX 站内转账;不能回退到 Pay, + // 否则同一个纯数字编号可能跨事实源误绑定另一笔 C2C 收款。 + return matchOffchainDepositRecord(req, records) + } matched, err := matchDepositRecord(req, records) if err == nil { return matched, nil @@ -120,6 +125,22 @@ func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransfer return matchPayRecord(req, payRecords) } +func matchOffchainDepositRecord(req ports.BinanceTransferLookupRequest, records []depositRecord) (ports.BinanceTransferRecord, error) { + for _, record := range records { + if !depositTxIDMatches(record.TxID, req.ExternalID) { + continue + } + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(record.TxID)), "off-chain transfer") { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit is not off-chain transfer") + } + // Binance 仍把站内入账记录标成实际充值网络 TRX;复用强校验时必须恢复真实网络, + // 地址、成功状态和金额校验均与普通 Deposit History 保持一致。 + req.Network = "TRX" + return matchDepositRecord(req, []depositRecord{record}) + } + return ports.BinanceTransferRecord{}, xerr.New(xerr.NotFound, "binance off-chain deposit not found") +} + func matchExplicitC2CPayRecord(req ports.BinanceTransferLookupRequest, records []payRecord) (ports.BinanceTransferRecord, error) { for _, record := range records { if !payExternalIDMatches(record, req.ExternalID) { diff --git a/services/wallet-service/internal/client/binance/client_test.go b/services/wallet-service/internal/client/binance/client_test.go index 5570422c..7a3ef5f0 100644 --- a/services/wallet-service/internal/client/binance/client_test.go +++ b/services/wallet-service/internal/client/binance/client_test.go @@ -88,6 +88,55 @@ func TestFindUSDTTransferMatchesDepositAndSignedRequest(t *testing.T) { } } +func TestFindUSDTTransferOffchainUsesDepositHistoryOnly(t *testing.T) { + const address = "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J" + var depositCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/time": + _, _ = w.Write([]byte(`{"serverTime":1784034000000}`)) + case "/sapi/v1/capital/deposit/hisrec": + depositCalls++ + _, _ = w.Write([]byte(`[{ + "id":"deposit-aslan-offchain", + "amount":"300.14000000", + "coin":"USDT", + "network":"TRX", + "status":1, + "address":"` + address + `", + "txId":"Off-chain transfer 391252658053", + "insertTime":1784033439000 + }]`)) + case "/sapi/v1/pay/transactions": + t.Fatalf("explicit Off-chain verification must not query Pay History") + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := New(config.BinanceConfig{ + Enabled: true, + APIBaseURL: server.URL, + HTTPTimeout: time.Second, + Aslan: config.BinanceAccountConfig{APIKey: "api-aslan", APISecretKey: "secret-aslan"}, + }) + record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ + AppCode: "aslan", + ExternalID: "391252658053", + Coin: "USDT", + Network: "OFFCHAIN", + ToAddress: address, + AmountMinor: 30014, + }) + if err != nil { + t.Fatalf("explicit Off-chain lookup failed: %v", err) + } + if depositCalls != 1 || record.Source != "deposit_history" || record.TxID != "Off-chain transfer 391252658053" || record.Network != "TRX" || record.AmountMinor != 30014 || record.Address != address { + t.Fatalf("Off-chain deposit mismatch: calls=%d record=%+v", depositCalls, record) + } +} + func TestFindUSDTTransferRejectsDepositMismatches(t *testing.T) { baseReq := ports.BinanceTransferLookupRequest{ AppCode: "lalu", diff --git a/services/wallet-service/internal/domain/ledger/constants.go b/services/wallet-service/internal/domain/ledger/constants.go index 8dc1c2e8..dce2bcb1 100644 --- a/services/wallet-service/internal/domain/ledger/constants.go +++ b/services/wallet-service/internal/domain/ledger/constants.go @@ -70,6 +70,9 @@ const ( PaymentProviderUSDTBEP20 = "usdt_bep20" // PaymentProviderUSDTC2C 是 Binance Pay C2C 收款订单;它不绑定公链,也不要求链上收款地址。 PaymentProviderUSDTC2C = "usdt_c2c" + // PaymentProviderUSDTOffchain 是 Binance Deposit History 中的站内 Off-chain transfer; + // 它使用 TRX 收款地址核对入账事实,但没有可提交给 TronGrid 的链上交易哈希。 + PaymentProviderUSDTOffchain = "usdt_offchain" // ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。 ThirdPartyPaymentStatusActive = "active" // ThirdPartyPaymentStatusDisabled 表示渠道或支付方式已被后台关闭。 diff --git a/services/wallet-service/internal/domain/ledger/recharge.go b/services/wallet-service/internal/domain/ledger/recharge.go index a3e055f5..ce9e5e6f 100644 --- a/services/wallet-service/internal/domain/ledger/recharge.go +++ b/services/wallet-service/internal/domain/ledger/recharge.go @@ -238,6 +238,8 @@ type VerifyCoinSellerRechargeReceiptCommand struct { ProviderCurrencyCode string ProviderAmountMinor int64 ProviderPayType string + // ReceiveAddress 只接受内部 admin-server 传入的当前 SQL 配置;为空时保留 H5/YAML 兼容路径。 + ReceiveAddress string } // CoinSellerRechargeReceiptVerification 是三方或链上返回的只读凭证快照。 @@ -418,6 +420,8 @@ func NormalizePaymentProvider(provider string) string { return PaymentProviderUSDTBEP20 case PaymentProviderUSDTC2C: return PaymentProviderUSDTC2C + case PaymentProviderUSDTOffchain: + return PaymentProviderUSDTOffchain case PaymentProviderGooglePlay: return PaymentProviderGooglePlay default: diff --git a/services/wallet-service/internal/service/wallet/external_recharge_receipt.go b/services/wallet-service/internal/service/wallet/external_recharge_receipt.go index 4db689d5..b76d6a4d 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_receipt.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_receipt.go @@ -30,12 +30,16 @@ func (s *Service) VerifyCoinSellerRechargeReceipt(ctx context.Context, command l case ledger.PaymentProviderV5Pay: return s.verifyV5PayRechargeReceipt(ctx, command) case ledger.PaymentProviderUSDTTRC20: - return s.verifyUSDTRechargeReceipt(ctx, command, "trc20", s.externalRecharge.USDTTRC20Enabled, s.externalRecharge.usdtTRC20AddressForApp(command.AppCode), s.tronUSDT) + return s.verifyUSDTRechargeReceipt(ctx, command, "trc20", s.externalRecharge.USDTTRC20Enabled, firstNonEmpty(command.ReceiveAddress, s.externalRecharge.usdtTRC20AddressForApp(command.AppCode)), s.tronUSDT) case ledger.PaymentProviderUSDTBEP20: - return s.verifyUSDTRechargeReceipt(ctx, command, "bep20", s.externalRecharge.USDTBEP20Enabled, s.externalRecharge.usdtBEP20AddressForApp(command.AppCode), s.bscUSDT) + return s.verifyUSDTRechargeReceipt(ctx, command, "bep20", s.externalRecharge.USDTBEP20Enabled, firstNonEmpty(command.ReceiveAddress, s.externalRecharge.usdtBEP20AddressForApp(command.AppCode)), s.bscUSDT) case ledger.PaymentProviderUSDTC2C: // C2C 是 Binance 账户内收款事实,不属于 TRON/BSC;空地址和关闭的链上开关不影响只读查单。 return s.verifyUSDTRechargeReceipt(ctx, command, "c2c", false, "", nil) + case ledger.PaymentProviderUSDTOffchain: + // Binance Off-chain 是 Deposit History 中的 TRX 入账事实;后台 SQL 地址优先于 YAML, + // 但仍要求 TRC20 功能开启,避免已停用的收款链被人工入口绕过。 + return s.verifyUSDTRechargeReceipt(ctx, command, "offchain", s.externalRecharge.USDTTRC20Enabled, firstNonEmpty(command.ReceiveAddress, s.externalRecharge.usdtTRC20AddressForApp(command.AppCode)), nil) default: return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.InvalidArgument, "receipt provider is invalid") } @@ -50,6 +54,7 @@ func normalizeVerifyCoinSellerRechargeReceiptCommand(command ledger.VerifyCoinSe command.ProviderCountryCode = strings.ToUpper(strings.TrimSpace(command.ProviderCountryCode)) command.ProviderCurrencyCode = strings.ToUpper(strings.TrimSpace(command.ProviderCurrencyCode)) command.ProviderPayType = strings.TrimSpace(command.ProviderPayType) + command.ReceiveAddress = strings.TrimSpace(command.ReceiveAddress) return command } @@ -61,7 +66,7 @@ func validateVerifyCoinSellerRechargeReceiptCommand(command ledger.VerifyCoinSel return xerr.New(xerr.InvalidArgument, "external_order_no is too long") } switch command.ProviderCode { - case ledger.PaymentProviderUSDTTRC20, ledger.PaymentProviderUSDTBEP20: + case ledger.PaymentProviderUSDTTRC20, ledger.PaymentProviderUSDTBEP20, ledger.PaymentProviderUSDTOffchain: if command.USDMinorAmount <= 0 { return xerr.New(xerr.InvalidArgument, "usdt receipt amount is invalid") } @@ -349,6 +354,10 @@ func (s *Service) verifyUSDTRechargeReceipt(ctx context.Context, command ledger. if !enabled || snapshot.ReceiveAddress == "" { return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "usdt "+chain+" receipt verification is not configured") } + if chain == "offchain" { + // Off-chain 编号不是公链 hash,只能走 Binance Deposit History;地址和金额仍必须与当前 App 配置精确一致。 + return s.verifyBinanceTransferReceipt(ctx, command, snapshot, binanceNetworkForReceiptChain(chain)) + } if !isLikelyChainTransactionHash(command.ExternalOrderNo) { // TRON/BSC 只接受标准交易哈希;非链上编号必须由运营明确改选 C2C,避免跨事实源误校验。 return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.InvalidArgument, "usdt "+chain+" receipt requires transaction hash") @@ -412,6 +421,8 @@ func binanceNetworkForReceiptChain(chain string) string { return "BSC" case "c2c": return "C2C" + case "offchain": + return "OFFCHAIN" default: return "TRX" } diff --git a/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go b/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go index a3a7b72a..bcbc8ddc 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go @@ -568,6 +568,30 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20HashUsesTronGrid(t *testing.T) } } +func TestVerifyCoinSellerRechargeReceiptUSDTTRC20PrefersSQLAddress(t *testing.T) { + tron := &fakeTronUSDTClient{rawJSON: `{"chain":true}`} + svc := walletservice.New(nil) + svc.SetTronUSDTClient(tron) + svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{ + USDTTRC20Enabled: true, + USDTTRC20Address: "TYAMLAddressMustNotWin", + }) + txHash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ + AppCode: "aslan", + ProviderCode: ledger.PaymentProviderUSDTTRC20, + ExternalOrderNo: txHash, + USDMinorAmount: 30014, + ReceiveAddress: "TSQLAddressMustWin", + }) + if err != nil { + t.Fatalf("TRC20 SQL address override failed: %v", err) + } + if !receipt.Verified || receipt.ReceiveAddress != "TSQLAddressMustWin" || tron.lastToAddress != "TSQLAddressMustWin" { + t.Fatalf("current SQL address must drive verification immediately: receipt=%+v client=%+v", receipt, tron) + } +} + func TestVerifyCoinSellerRechargeReceiptUSDTTRC20RejectsC2COrderNumber(t *testing.T) { svc := walletservice.New(nil) svc.SetTronUSDTClient(&fakeTronUSDTClient{}) @@ -629,6 +653,44 @@ func TestVerifyCoinSellerRechargeReceiptUSDTC2CUsesBinance(t *testing.T) { } } +func TestVerifyCoinSellerRechargeReceiptUSDTOffchainUsesSQLTRONAddress(t *testing.T) { + const address = "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J" + binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{ + Source: "deposit_history", + TxID: "Off-chain transfer 391252658053", + Coin: ledger.PaidCurrencyUSDT, + Network: "TRX", + Status: 1, + Address: address, + Amount: "300.14000000", + AmountMinor: 30014, + RawJSON: `{"txId":"Off-chain transfer 391252658053","status":1}`, + }} + svc := walletservice.New(nil) + svc.SetBinanceClient(binance) + svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{ + USDTTRC20Enabled: true, + USDTTRC20Address: "TYAMLAddressMustNotWin", + }) + receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ + AppCode: "aslan", + ProviderCode: ledger.PaymentProviderUSDTOffchain, + ExternalOrderNo: "391252658053", + USDMinorAmount: 30014, + OrderDateMS: 1784033439000, + ReceiveAddress: address, + }) + if err != nil { + t.Fatalf("explicit Off-chain receipt failed: %v", err) + } + if !receipt.Verified || receipt.Chain != "offchain" || receipt.ReceiveAddress != address || receipt.ProviderOrderID != "Off-chain transfer 391252658053" { + t.Fatalf("Off-chain receipt mismatch: %+v", receipt) + } + if binance.calls != 1 || binance.req.Network != "OFFCHAIN" || binance.req.ToAddress != address || binance.req.AmountMinor != 30014 { + t.Fatalf("Off-chain Binance request mismatch: calls=%d req=%+v", binance.calls, binance.req) + } +} + func TestVerifyCoinSellerRechargeReceiptUSDTC2CPayKeepsNoChainAddress(t *testing.T) { binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{ Source: "pay_history", diff --git a/services/wallet-service/internal/transport/grpc/external_recharge.go b/services/wallet-service/internal/transport/grpc/external_recharge.go index 18556c4f..022a5be1 100644 --- a/services/wallet-service/internal/transport/grpc/external_recharge.go +++ b/services/wallet-service/internal/transport/grpc/external_recharge.go @@ -235,6 +235,7 @@ func (s *Server) VerifyCoinSellerRechargeReceipt(ctx context.Context, req *walle ProviderCurrencyCode: req.GetProviderCurrencyCode(), ProviderAmountMinor: req.GetProviderAmountMinor(), ProviderPayType: req.GetProviderPayType(), + ReceiveAddress: req.GetReceiveAddress(), }) if err != nil { return nil, xerr.ToGRPCError(err)