feat(finance): verify Binance off-chain receipts
This commit is contained in:
parent
51b1ee7481
commit
d8ff290392
@ -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" +
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 表示渠道或支付方式已被后台关闭。
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user