diff --git a/server/admin/internal/modules/financeorder/handler.go b/server/admin/internal/modules/financeorder/handler.go index 7ce85aae..a21d1988 100644 --- a/server/admin/internal/modules/financeorder/handler.go +++ b/server/admin/internal/modules/financeorder/handler.go @@ -101,6 +101,15 @@ func (h *Handler) GetCoinSellerRechargeExchangeRate(c *gin.Context) { response.OK(c, item) } +func (h *Handler) GetUSDTAddresses(c *gin.Context) { + item, err := h.service.GetUSDTAddresses(shared.ActorFromContext(c), c.Param("app_code")) + if err != nil { + writeServiceError(c, err, "获取 USDT 收款地址失败") + return + } + 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 36e0d825..bc5e0225 100644 --- a/server/admin/internal/modules/financeorder/routes.go +++ b/server/admin/internal/modules/financeorder/routes.go @@ -17,6 +17,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.POST("/admin/finance/orders/coin-seller-recharges", middleware.RequirePermission(permissionCoinSellerRechargeCreate), h.CreateCoinSellerRechargeOrder) 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.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/usdt_address.go b/server/admin/internal/modules/financeorder/usdt_address.go new file mode 100644 index 00000000..f2edd9cd --- /dev/null +++ b/server/admin/internal/modules/financeorder/usdt_address.go @@ -0,0 +1,112 @@ +package financeorder + +import ( + "errors" + "fmt" + "sort" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/model" + "hyapp-admin-server/internal/modules/shared" +) + +const financeUSDTAddressGroup = "finance-usdt-address" + +var financeUSDTChainOrder = map[string]int{ + "TRON": 0, + "BSC": 1, +} + +type financeUSDTAddressDTO struct { + Chain string `json:"chain"` + Address string `json:"address"` + UpdatedAtMS int64 `json:"updatedAtMs"` +} + +type financeUSDTAddressesDTO struct { + AppCode string `json:"appCode"` + Items []financeUSDTAddressDTO `json:"items"` +} + +// GetUSDTAddresses 只读取当前 App 自己持有的配置行,不继承空 app_code 的历史基线。 +// 收款地址一旦跨 App 回退,运营会拿到另一账套的地址并让链上订单校验必然失败。 +func (s *Service) GetUSDTAddresses(actor shared.Actor, appCode string) (*financeUSDTAddressesDTO, error) { + if s == nil || s.store == nil { + return nil, errors.New("admin store is not configured") + } + if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeView) { + return nil, errCoinSellerRechargeForbidden + } + if strings.TrimSpace(appCode) == "" { + return nil, errInvalidCoinSellerRechargeOrderInput + } + appCode = appctx.Normalize(appCode) + // App 范围是后台用户的数据边界;即使调用方绕过页面手写 app_code,也不能读取未授权账套地址。 + access, err := s.store.AppAccessForUser(actor.UserID) + if err != nil { + return nil, err + } + if !access.Allows(appCode) { + return nil, errCoinSellerRechargeForbidden + } + configs, err := s.store.ListOwnedAppConfigs(appCode, financeUSDTAddressGroup) + if err != nil { + return nil, err + } + items, err := financeUSDTAddressesFromConfigs(configs) + if err != nil { + return nil, err + } + return &financeUSDTAddressesDTO{AppCode: appCode, Items: items}, nil +} + +func financeUSDTAddressesFromConfigs(configs []model.AppConfig) ([]financeUSDTAddressDTO, error) { + items := make([]financeUSDTAddressDTO, 0, len(configs)) + for _, config := range configs { + chain := strings.ToUpper(strings.TrimSpace(config.Key)) + if _, supported := financeUSDTChainOrder[chain]; !supported { + // 同组未来若新增其他链,旧版本必须安全忽略,不能把未知地址误标成 TRON/BSC。 + continue + } + address := strings.TrimSpace(config.Value) + if address == "" { + continue + } + if !validFinanceUSDTAddress(chain, address) { + return nil, fmt.Errorf("%s USDT 收款地址配置不正确", chain) + } + items = append(items, financeUSDTAddressDTO{Chain: chain, Address: address, UpdatedAtMS: config.UpdatedAtMS}) + } + sort.Slice(items, func(i, j int) bool { + return financeUSDTChainOrder[items[i].Chain] < financeUSDTChainOrder[items[j].Chain] + }) + return items, nil +} + +func validFinanceUSDTAddress(chain string, address string) bool { + switch chain { + case "TRON": + if len(address) != 34 || address[0] != 'T' { + return false + } + for _, char := range address { + if !strings.ContainsRune("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", char) { + return false + } + } + return true + case "BSC": + if len(address) != 42 || !strings.HasPrefix(address, "0x") { + return false + } + for _, char := range address[2:] { + if !strings.ContainsRune("0123456789abcdefABCDEF", char) { + return false + } + } + return true + default: + return false + } +} diff --git a/server/admin/internal/modules/financeorder/usdt_address_test.go b/server/admin/internal/modules/financeorder/usdt_address_test.go new file mode 100644 index 00000000..17c3cda9 --- /dev/null +++ b/server/admin/internal/modules/financeorder/usdt_address_test.go @@ -0,0 +1,28 @@ +package financeorder + +import ( + "testing" + + "hyapp-admin-server/internal/model" +) + +func TestFinanceUSDTAddressesFromConfigsKeepsSupportedValidChainsInBusinessOrder(t *testing.T) { + items, err := financeUSDTAddressesFromConfigs([]model.AppConfig{ + {Key: "BSC", Value: "0x55d398326f99059fF775485246999027B3197955", UpdatedAtMS: 200}, + {Key: "future-chain", Value: "not-exposed", UpdatedAtMS: 300}, + {Key: "TRON", Value: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", UpdatedAtMS: 100}, + }) + if err != nil { + t.Fatalf("normalize USDT address configs failed: %v", err) + } + if len(items) != 2 || items[0].Chain != "TRON" || items[1].Chain != "BSC" { + t.Fatalf("USDT addresses must expose only supported chains in business order: %+v", items) + } +} + +func TestFinanceUSDTAddressesFromConfigsRejectsMalformedStoredAddress(t *testing.T) { + _, err := financeUSDTAddressesFromConfigs([]model.AppConfig{{Key: "TRON", Value: "wrong-app-address"}}) + if err == nil { + t.Fatal("malformed SQL address must fail closed instead of being copied into a recharge order") + } +}