package hostorg import ( "context" "fmt" "strconv" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/walletclient" walletv1 "hyapp.local/api/proto/wallet/v1" ) const ( coinSellerStatusActive = "active" coinSellerMerchantAssetType = "COIN_SELLER_COIN" coinSellerStockTypePurchase = "usdt_purchase" coinSellerStockTypeCompensate = "coin_compensation" paidCurrencyUSDT = "USDT" sortByDiamond = "diamond" sortByMerchantBalance = "merchant_balance" sortBySalaryWallet = "salary_wallet" sortByTotalRechargeUSDT = "total_recharge_usdt" bdLeaderPositionAliasMaxRunes = 64 ) type Service struct { userClient userclient.Client walletClient walletclient.Client reader *Reader } func NewService(userClient userclient.Client, walletClient walletclient.Client, reader *Reader) *Service { return &Service{userClient: userClient, walletClient: walletClient, reader: reader} } func (s *Service) ListBDLeaders(ctx context.Context, query listQuery) ([]*userclient.BDProfile, int64, error) { query = normalizeListQuery(query) return s.reader.ListBDProfiles(ctx, query, "bd_leader") } func (s *Service) ListBDs(ctx context.Context, query listQuery) ([]*userclient.BDProfile, int64, error) { query = normalizeListQuery(query) return s.reader.ListBDProfiles(ctx, query, "bd") } func (s *Service) ListAgencies(ctx context.Context, query listQuery) ([]*userclient.Agency, int64, error) { query = normalizeListQuery(query) return s.reader.ListAgencies(ctx, query) } func (s *Service) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, error) { query = normalizeListQuery(query) return s.reader.ListManagers(ctx, query) } func (s *Service) CreateManager(ctx context.Context, actorID int64, requestID string, req createManagerRequest) (*ManagerListItem, error) { targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64()) if err != nil { return nil, err } // 经理身份只落 manager_profiles 表;联系方式作为后台运营资料保存,不参与经理中心授权。 return s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req.Contact) } func (s *Service) ListHosts(ctx context.Context, query listQuery) ([]*userclient.HostProfile, int64, error) { query = normalizeListQuery(query) return s.reader.ListHostProfiles(ctx, query) } func (s *Service) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinSellerListItem, int64, error) { query = normalizeListQuery(query) return s.reader.ListCoinSellers(ctx, query) } func (s *Service) GetCoinSellerSalaryRates(ctx context.Context, regionID int64) ([]CoinSellerSalaryRateTier, error) { return s.reader.ListCoinSellerSalaryRates(ctx, regionID) } func (s *Service) ReplaceCoinSellerSalaryRates(ctx context.Context, actorID int64, regionID int64, req replaceCoinSellerSalaryRatesRequest) ([]CoinSellerSalaryRateTier, error) { tiers := make([]CoinSellerSalaryRateTier, 0, len(req.Tiers)) for index, item := range req.Tiers { minUSDMinor, err := salaryRateUSDMinor(item.MinUSDMinor, item.MinUSD) if err != nil { return nil, fmt.Errorf("min_usd is invalid") } maxUSDMinor, err := salaryRateUSDMinor(item.MaxUSDMinor, item.MaxUSD) if err != nil { return nil, fmt.Errorf("max_usd is invalid") } enabled := item.Status == "active" if item.Enabled != nil { enabled = *item.Enabled } status := strings.ToLower(strings.TrimSpace(item.Status)) if status == "" { if enabled { status = "active" } else { status = "disabled" } } if status != "active" && status != "disabled" { return nil, fmt.Errorf("status is invalid") } if minUSDMinor <= 0 || maxUSDMinor < minUSDMinor { return nil, fmt.Errorf("usd range is invalid") } if item.CoinPerUSD <= 0 || item.CoinPerUSD%100 != 0 { return nil, fmt.Errorf("coin_per_usd must be a positive multiple of 100") } sortOrder := item.SortOrder if sortOrder == 0 { sortOrder = (index + 1) * 10 } tiers = append(tiers, CoinSellerSalaryRateTier{ RegionID: regionID, MinUSDMinor: minUSDMinor, MaxUSDMinor: maxUSDMinor, MinUSD: formatUSDMinor(minUSDMinor), MaxUSD: formatUSDMinor(maxUSDMinor), CoinPerUSD: item.CoinPerUSD, Status: status, Enabled: status == "active", SortOrder: sortOrder, }) } if err := validateCoinSellerSalaryRateTiers(tiers); err != nil { return nil, err } return s.reader.ReplaceCoinSellerSalaryRates(ctx, regionID, actorID, tiers) } func (s *Service) CreateBDLeader(ctx context.Context, actorID int64, requestID string, req createBDLeaderRequest) (*userclient.BDProfile, error) { targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64()) if err != nil { return nil, err } positionAlias, err := normalizeBDLeaderPositionAlias(req.PositionAlias) if err != nil { return nil, err } // BD Leader 区域由目标用户当前 users.region_id 派生;后台不再传区域,避免创建角色时顺手迁移用户国家/区域。 profile, err := s.userClient.CreateBDLeader(ctx, userclient.CreateBDLeaderRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, TargetUserID: targetUserID, Reason: strings.TrimSpace(req.Reason), }) if err != nil { return nil, err } // 职位别名只影响 Flutter H5 配置里 admin 入口的展示名,不参与 user-service 的身份创建事务,避免把后台展示字段扩散到 App 身份主协议。 if err := s.reader.SaveBDLeaderPositionAlias(ctx, targetUserID, actorID, positionAlias); err != nil { return nil, err } profile.PositionAlias = positionAlias return profile, nil } func (s *Service) UpdateBDLeaderPositionAlias(ctx context.Context, actorID int64, targetUserID int64, req bdLeaderPositionAliasRequest) (*userclient.BDProfile, error) { if targetUserID <= 0 { return nil, fmt.Errorf("target_user_id is required") } positionAlias, err := normalizeBDLeaderPositionAlias(req.PositionAlias) if err != nil { return nil, err } profile, err := s.reader.GetBDLeader(ctx, targetUserID) if err != nil { return nil, err } // 行内编辑只修改后台维护的展示别名;先确认 BD Leader 身份存在,再写覆盖表,避免产生无法在列表里追踪的孤立别名。 if err := s.reader.SaveBDLeaderPositionAlias(ctx, targetUserID, actorID, positionAlias); err != nil { return nil, err } profile.PositionAlias = positionAlias return profile, nil } func (s *Service) CreateBD(ctx context.Context, actorID int64, requestID string, req createBDRequest) (*userclient.BDProfile, error) { targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64()) if err != nil { return nil, err } parentLeaderUserID := int64(0) if req.ParentLeaderUserID.Int64() < 0 { return nil, fmt.Errorf("parent_leader_user_id must not be negative") } if req.ParentLeaderUserID.Int64() > 0 { // 父级 Leader 是可选关系;传入短 ID 时才解析成内部 user_id,未传时保留 0 表示独立 BD。 parentLeaderUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentLeaderUserID.Int64()) if err != nil { return nil, err } } return s.userClient.CreateBD(ctx, userclient.CreateBDRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, TargetUserID: targetUserID, ParentLeaderUserID: parentLeaderUserID, Reason: strings.TrimSpace(req.Reason), }) } func (s *Service) SetBDStatus(ctx context.Context, actorID int64, targetUserID int64, requestID string, role string, req bdStatusRequest) (*userclient.BDProfile, error) { return s.userClient.SetBDStatus(ctx, userclient.SetBDStatusRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, TargetUserID: targetUserID, Role: role, Status: strings.TrimSpace(req.Status), Reason: strings.TrimSpace(req.Reason), }) } func (s *Service) DeleteBDLeader(ctx context.Context, targetUserID int64) (*userclient.BDProfile, error) { if targetUserID <= 0 { return nil, fmt.Errorf("target_user_id is required") } return s.reader.DeleteBDLeader(ctx, targetUserID) } func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID string, req createCoinSellerRequest) (*userclient.CoinSellerProfile, error) { targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64()) if err != nil { return nil, err } profile, err := s.userClient.CreateCoinSeller(ctx, userclient.CreateCoinSellerRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, TargetUserID: targetUserID, Reason: strings.TrimSpace(req.Reason), }) if err != nil { return nil, err } contact := firstNonBlank(req.Contact, req.Reason) if contact != "" { if err := s.reader.UpdateCoinSellerContact(ctx, targetUserID, contact); err != nil { return nil, err } } return profile, nil } func (s *Service) SetCoinSellerStatus(ctx context.Context, actorID int64, targetUserID int64, requestID string, req coinSellerStatusRequest) (*userclient.CoinSellerProfile, error) { profile, err := s.userClient.SetCoinSellerStatus(ctx, userclient.SetCoinSellerStatusRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, TargetUserID: targetUserID, Status: strings.TrimSpace(req.Status), Reason: strings.TrimSpace(req.Reason), }) if err != nil { return nil, err } if req.Contact != nil { if err := s.reader.UpdateCoinSellerContact(ctx, targetUserID, *req.Contact); err != nil { return nil, err } } return profile, nil } func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sellerUserID int64, requestID string, req coinSellerStockCreditRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) { if s.walletClient == nil { return nil, fmt.Errorf("wallet client is not configured") } if sellerUserID <= 0 || actorID <= 0 { return nil, fmt.Errorf("seller_user_id and operator_user_id are required") } commandID := strings.TrimSpace(req.CommandID) stockType := strings.ToLower(strings.TrimSpace(req.Type)) evidenceRef := strings.TrimSpace(req.EvidenceRef) reason := strings.TrimSpace(req.Reason) if commandID == "" || req.CoinAmount <= 0 { return nil, fmt.Errorf("command_id and coin_amount are required") } paidCurrencyCode := "" paidAmountMicro := int64(0) paymentRef := strings.TrimSpace(req.PaymentRef) switch stockType { case coinSellerStockTypePurchase: paidCurrencyCode = paidCurrencyUSDT parsed, err := parseUSDTAmountMicro(req.RechargeAmount) if err != nil { return nil, err } paidAmountMicro = parsed case coinSellerStockTypeCompensate: if strings.TrimSpace(req.RechargeAmount) != "" || paymentRef != "" { return nil, fmt.Errorf("coin compensation must not include recharge_amount or payment_ref") } default: return nil, fmt.Errorf("stock type is invalid") } seller, err := s.activeCoinSellerUser(ctx, sellerUserID, requestID) if err != nil { return nil, err } return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{ CommandId: commandID, SellerUserId: sellerUserID, SellerCountryId: seller.CountryID, SellerRegionId: seller.RegionID, StockType: stockType, CoinAmount: req.CoinAmount, PaidCurrencyCode: paidCurrencyCode, PaidAmountMicro: paidAmountMicro, PaymentRef: paymentRef, EvidenceRef: evidenceRef, OperatorUserId: actorID, Reason: reason, AppCode: appctx.FromContext(ctx), }) } func (s *Service) DebitCoinSellerStock(ctx context.Context, actorID int64, sellerUserID int64, requestID string, req coinSellerStockDebitRequest) (*walletv1.AdminCreditAssetResponse, error) { if s.walletClient == nil { return nil, fmt.Errorf("wallet client is not configured") } if sellerUserID <= 0 || actorID <= 0 { return nil, fmt.Errorf("seller_user_id and operator_user_id are required") } commandID := strings.TrimSpace(req.CommandID) reason := strings.TrimSpace(req.Reason) evidenceRef := strings.TrimSpace(req.EvidenceRef) if commandID == "" || req.CoinAmount <= 0 { return nil, fmt.Errorf("command_id and coin_amount are required") } if reason == "" { return nil, fmt.Errorf("reason is required") } if len(reason) > 512 || len(commandID) > 128 || len(evidenceRef) > 256 { return nil, fmt.Errorf("coin seller debit text fields are too long") } if _, err := s.activeCoinSellerUser(ctx, sellerUserID, requestID); err != nil { return nil, err } // 币商扣除是运营对币商专用库存的负向调账:只扣 COIN_SELLER_COIN,不写普通 COIN,也不写进货记录。 // wallet-service 的 AdminCreditAsset 负责余额不足校验、幂等冲突检测、wallet_entries 和 outbox 原子落账。 return s.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{ CommandId: commandID, TargetUserId: sellerUserID, AssetType: coinSellerMerchantAssetType, Amount: -req.CoinAmount, OperatorUserId: actorID, Reason: reason, EvidenceRef: evidenceRef, AppCode: appctx.FromContext(ctx), }) } func (s *Service) activeCoinSellerUser(ctx context.Context, sellerUserID int64, requestID string) (*userclient.User, error) { if s == nil || s.userClient == nil { return nil, fmt.Errorf("user client is not configured") } profile, err := s.userClient.GetCoinSellerProfile(ctx, userclient.GetCoinSellerProfileRequest{ RequestID: requestID, Caller: "hyapp-admin-server", UserID: sellerUserID, }) if err != nil { return nil, err } if profile == nil || profile.Status != coinSellerStatusActive || profile.MerchantAssetType != coinSellerMerchantAssetType { return nil, fmt.Errorf("target user is not an active coin seller") } seller, err := s.userClient.GetUser(ctx, userclient.GetUserRequest{ RequestID: requestID, Caller: "hyapp-admin-server", UserID: sellerUserID, }) if err != nil { return nil, err } if seller == nil || seller.CountryID <= 0 || seller.RegionID <= 0 { return nil, fmt.Errorf("coin seller country and region are required") } return seller, nil } func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID string, req createAgencyRequest) (*userclient.CreateAgencyResult, error) { ownerUserID, err := s.resolveDisplayUserID(ctx, requestID, req.OwnerUserID.Int64()) if err != nil { return nil, err } parentBDUserID := int64(0) if req.ParentBDUserID.Int64() < 0 { return nil, fmt.Errorf("parent_bd_user_id must not be negative") } if req.ParentBDUserID.Int64() > 0 { // 父级 BD 是可选关系;传入短 ID 时才解析成内部 user_id,未传时保留 0 表示独立 Agency。 parentBDUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentBDUserID.Int64()) if err != nil { return nil, err } } joinEnabled := false if req.JoinEnabled != nil { joinEnabled = *req.JoinEnabled } return s.userClient.CreateAgency(ctx, userclient.CreateAgencyRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, OwnerUserID: ownerUserID, ParentBDUserID: parentBDUserID, Name: strings.TrimSpace(req.Name), JoinEnabled: joinEnabled, Reason: strings.TrimSpace(req.Reason), }) } func (s *Service) CloseAgency(ctx context.Context, actorID int64, agencyID int64, requestID string, req agencyCloseRequest) (*userclient.Agency, error) { return s.userClient.CloseAgency(ctx, userclient.CloseAgencyRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, AgencyID: agencyID, Reason: strings.TrimSpace(req.Reason), }) } func (s *Service) DeleteAgency(ctx context.Context, actorID int64, agencyID int64, requestID string, req agencyDeleteRequest) (*userclient.Agency, error) { return s.userClient.DeleteAgency(ctx, userclient.DeleteAgencyRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, AgencyID: agencyID, Reason: strings.TrimSpace(req.Reason), }) } func (s *Service) resolveDisplayUserID(ctx context.Context, requestID string, displayUserID int64) (int64, error) { if displayUserID <= 0 { return 0, fmt.Errorf("display_user_id is required") } identity, err := s.userClient.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{ RequestID: requestID, Caller: "hyapp-admin-server", DisplayUserID: strconv.FormatInt(displayUserID, 10), }) if err != nil { return 0, err } if identity == nil || identity.UserID <= 0 { return 0, fmt.Errorf("display_user_id %d not found", displayUserID) } return identity.UserID, nil } func normalizeBDLeaderPositionAlias(value string) (string, error) { positionAlias := strings.TrimSpace(value) if len([]rune(positionAlias)) > bdLeaderPositionAliasMaxRunes { return "", fmt.Errorf("position_alias must not exceed %d characters", bdLeaderPositionAliasMaxRunes) } return positionAlias, nil } func (s *Service) SetAgencyJoinEnabled(ctx context.Context, actorID int64, agencyID int64, requestID string, req agencyJoinEnabledRequest) (*userclient.Agency, error) { joinEnabled := false if req.JoinEnabled != nil { joinEnabled = *req.JoinEnabled } return s.userClient.SetAgencyJoinEnabled(ctx, userclient.SetAgencyJoinEnabledRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, AgencyID: agencyID, JoinEnabled: joinEnabled, Reason: strings.TrimSpace(req.Reason), }) } func parseUSDTAmountMicro(raw string) (int64, error) { raw = strings.TrimSpace(raw) if raw == "" { return 0, fmt.Errorf("recharge_amount is required for usdt purchase") } parts := strings.Split(raw, ".") if len(parts) > 2 || parts[0] == "" { return 0, fmt.Errorf("recharge_amount is invalid") } whole := parts[0] frac := "" if len(parts) == 2 { frac = parts[1] if frac == "" || len(frac) > 6 { return 0, fmt.Errorf("recharge_amount supports up to 6 decimals") } } digits := whole + frac + strings.Repeat("0", 6-len(frac)) var amount int64 const maxInt64 = int64(^uint64(0) >> 1) for _, char := range digits { if char < '0' || char > '9' { return 0, fmt.Errorf("recharge_amount is invalid") } next := int64(char - '0') if amount > (maxInt64-next)/10 { return 0, fmt.Errorf("recharge_amount is too large") } amount = amount*10 + next } if amount <= 0 { return 0, fmt.Errorf("recharge_amount must be positive") } return amount, nil } func salaryRateUSDMinor(minor int64, raw string) (int64, error) { if minor > 0 { return minor, nil } raw = strings.TrimSpace(raw) if raw == "" { return 0, fmt.Errorf("usd amount is required") } parts := strings.Split(raw, ".") if len(parts) > 2 || parts[0] == "" { return 0, fmt.Errorf("usd amount is invalid") } digits := parts[0] fraction := "" if len(parts) == 2 { fraction = parts[1] if len(fraction) > 2 { return 0, fmt.Errorf("usd amount supports up to 2 decimals") } } digits += fraction + strings.Repeat("0", 2-len(fraction)) var amount int64 const maxInt64 = int64(^uint64(0) >> 1) for _, char := range digits { if char < '0' || char > '9' { return 0, fmt.Errorf("usd amount is invalid") } next := int64(char - '0') if amount > (maxInt64-next)/10 { return 0, fmt.Errorf("usd amount is too large") } amount = amount*10 + next } return amount, nil } func validateCoinSellerSalaryRateTiers(tiers []CoinSellerSalaryRateTier) error { active := make([]CoinSellerSalaryRateTier, 0, len(tiers)) for _, tier := range tiers { if tier.Status == "active" { active = append(active, tier) } } for i := 0; i < len(active); i++ { for j := i + 1; j < len(active); j++ { if active[i].MinUSDMinor <= active[j].MaxUSDMinor && active[j].MinUSDMinor <= active[i].MaxUSDMinor { return fmt.Errorf("active usd ranges must not overlap") } } } return nil } func formatUSDMinor(value int64) string { sign := "" if value < 0 { sign = "-" value = -value } return fmt.Sprintf("%s%d.%02d", sign, value/100, value%100) } func normalizeListQuery(query listQuery) listQuery { if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } query.Keyword = strings.TrimSpace(query.Keyword) query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.SortBy = strings.ToLower(strings.TrimSpace(query.SortBy)) switch query.SortBy { case sortByDiamond, sortByMerchantBalance, sortBySalaryWallet, sortByTotalRechargeUSDT: default: query.SortBy = "" query.SortDirection = "" return query } query.SortDirection = strings.ToLower(strings.TrimSpace(query.SortDirection)) if query.SortDirection != "asc" { query.SortDirection = "desc" } return query } func firstNonBlank(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" }