package financeorder import ( "errors" "fmt" "sort" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" "gorm.io/gorm" ) 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"` } 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) { 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 } // 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 { 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 } }