2026-07-09 18:15:57 +08:00

756 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
coinSellerMerchantAssetType = "COIN_SELLER_COIN"
coinSellerStockTypePurchase = "usdt_purchase"
coinSellerStockTypeDeduction = "usdt_deduction"
coinSellerStockTypeCompensate = "coin_compensation"
agencyStatusDeleted = "deleted"
membershipStatusActive = "active"
paidCurrencyUSDT = "USDT"
sortByDiamond = "diamond"
sortByMerchantBalance = "merchant_balance"
sortBySalaryWallet = "salary_wallet"
sortByTotalRechargeUSDT = "total_recharge_usdt"
bdLeaderPositionAliasMaxRunes = 64
hostOrgInternalUserIDMinDigits = 15
)
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 表;权限开关同时写入 manager_profiles供 H5 写入口逐项校验。
return s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req)
}
func (s *Service) UpdateManager(ctx context.Context, actorID int64, userID int64, req updateManagerRequest) (*ManagerListItem, error) {
return s.reader.UpdateManagerProfile(ctx, userID, actorID, req)
}
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) ListCoinSellerSubApplications(ctx context.Context, query listQuery) ([]*CoinSellerSubApplicationListItem, int64, error) {
query = normalizeListQuery(query)
return s.reader.ListCoinSellerSubApplications(ctx, query)
}
func (s *Service) ListCoinSellerSubSellers(ctx context.Context, parentUserID int64, query listQuery) ([]*CoinSellerListItem, int64, error) {
query = normalizeListQuery(query)
return s.reader.ListCoinSellerSubSellers(ctx, parentUserID, query)
}
func (s *Service) ReviewCoinSellerSubApplication(ctx context.Context, actorID int64, applicationID int64, requestID string, decision string, req coinSellerSubApplicationReviewRequest) (*userclient.CoinSellerSubApplicationReviewResult, error) {
return s.userClient.ReviewCoinSellerSubApplication(ctx, userclient.ReviewCoinSellerSubApplicationRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID),
AdminUserID: actorID,
ApplicationID: applicationID,
Decision: strings.TrimSpace(decision),
Reason: strings.TrimSpace(req.Reason),
})
}
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),
CanManageSubCoinSellers: req.CanManageSubCoinSellers,
})
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),
CanManageSubCoinSellers: req.CanManageSubCoinSellers,
})
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.coinSellerUser(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.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)
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")
}
paidAmountMicro, err := parseUSDTAmountMicro(req.RechargeAmount)
if err != nil {
return nil, err
}
seller, err := s.coinSellerUser(ctx, sellerUserID, requestID)
if err != nil {
return nil, err
}
// 币商扣除按 USDT 进货冲回处理:钱包事务会同时写负向库存分录、负向进货记录和统计 outbox
// 这样后台列表、币商流水和充值聚合都使用同一条账务事实,避免只扣余额却不冲收入。
return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
CommandId: commandID,
SellerUserId: sellerUserID,
SellerCountryId: seller.CountryID,
SellerRegionId: seller.RegionID,
StockType: coinSellerStockTypeDeduction,
CoinAmount: req.CoinAmount,
PaidCurrencyCode: paidCurrencyUSDT,
PaidAmountMicro: paidAmountMicro,
EvidenceRef: evidenceRef,
OperatorUserId: actorID,
Reason: reason,
AppCode: appctx.FromContext(ctx),
})
}
func (s *Service) coinSellerUser(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.MerchantAssetType != coinSellerMerchantAssetType {
return nil, fmt.Errorf("target user is not a 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) AdminAddAgencyHost(ctx context.Context, actorID int64, agencyID int64, requestID string, req agencyHostAddRequest) (*userclient.CreateAgencyResult, error) {
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64())
if err != nil {
return nil, err
}
// admin-server 只负责短号解析和审计Agency 状态、区域、唯一有效归属等关系规则必须在 user-service 事务内判断。
return s.userClient.AdminAddAgencyHost(ctx, userclient.AdminAddAgencyHostRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID),
AdminUserID: actorID,
AgencyID: agencyID,
TargetUserID: targetUserID,
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) SetAgencyStatus(ctx context.Context, actorID int64, agencyID int64, requestID string, req agencyStatusRequest) (*userclient.Agency, error) {
return s.userClient.SetAgencyStatus(ctx, userclient.SetAgencyStatusRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID),
AdminUserID: actorID,
AgencyID: agencyID,
Status: strings.ToLower(strings.TrimSpace(req.Status)),
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")
}
rawID := strconv.FormatInt(displayUserID, 10)
userIDChecked := false
if len(strings.TrimLeft(rawID, "0")) >= hostOrgInternalUserIDMinDigits {
// 团队后台新合约的 targetUserId 可能是内部雪花 user_id长数字先按 user_id 确认,避免把内部 ID 误送到短号解析。
userIDChecked = true
found, err := s.userIDExists(ctx, requestID, displayUserID)
if err != nil {
return 0, err
}
if found {
return displayUserID, nil
}
}
identity, err := s.userClient.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
DisplayUserID: rawID,
})
if err != nil {
if !isGRPCNotFound(err) {
return 0, err
}
} else if identity != nil && identity.UserID > 0 {
return identity.UserID, nil
}
if !userIDChecked {
// 当前 active 短号解析失败后,短数字仍可能是本地/旧后台直接填写的真实 user_id 或 held 默认短号;只读 GetUser 做存在性确认,不改变 user-service 的 active 短号协议。
found, err := s.userIDExists(ctx, requestID, displayUserID)
if err != nil {
return 0, err
}
if found {
return displayUserID, nil
}
}
return 0, fmt.Errorf("display_user_id %d not found", displayUserID)
}
func (s *Service) userIDExists(ctx context.Context, requestID string, userID int64) (bool, error) {
user, err := s.userClient.GetUser(ctx, userclient.GetUserRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
UserID: userID,
})
if err != nil {
if isGRPCNotFound(err) {
return false, nil
}
return false, err
}
return user != nil && user.UserID > 0, nil
}
func isGRPCNotFound(err error) bool {
return status.Code(err) == codes.NotFound
}
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.UserFilter.UserID = strings.TrimSpace(query.UserFilter.UserID)
query.UserFilter.DisplayUserID = strings.TrimSpace(query.UserFilter.DisplayUserID)
query.UserFilter.Username = strings.TrimSpace(query.UserFilter.Username)
query.OwnerFilter.UserID = strings.TrimSpace(query.OwnerFilter.UserID)
query.OwnerFilter.DisplayUserID = strings.TrimSpace(query.OwnerFilter.DisplayUserID)
query.OwnerFilter.Username = strings.TrimSpace(query.OwnerFilter.Username)
query.ParentLeaderFilter.UserID = strings.TrimSpace(query.ParentLeaderFilter.UserID)
query.ParentLeaderFilter.DisplayUserID = strings.TrimSpace(query.ParentLeaderFilter.DisplayUserID)
query.ParentLeaderFilter.Username = strings.TrimSpace(query.ParentLeaderFilter.Username)
query.ParentBDFilter.UserID = strings.TrimSpace(query.ParentBDFilter.UserID)
query.ParentBDFilter.DisplayUserID = strings.TrimSpace(query.ParentBDFilter.DisplayUserID)
query.ParentBDFilter.Username = strings.TrimSpace(query.ParentBDFilter.Username)
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 ""
}