496 lines
16 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"
)
const (
coinSellerStatusActive = "active"
coinSellerMerchantAssetType = "COIN_SELLER_COIN"
coinSellerStockTypePurchase = "usdt_purchase"
coinSellerStockTypeCompensate = "coin_compensation"
paidCurrencyUSDT = "USDT"
)
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) 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)
if err != nil {
return nil, err
}
// BD Leader 创建时后台选择的区域是显式运营归属,由 user-service 在同一事务里更新 users.region_id 和 bd_profiles.region_id。
return s.userClient.CreateBDLeader(ctx, userclient.CreateBDLeaderRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID),
AdminUserID: actorID,
RegionID: req.RegionID,
TargetUserID: targetUserID,
Reason: strings.TrimSpace(req.Reason),
})
}
func (s *Service) CreateBD(ctx context.Context, actorID int64, requestID string, req createBDRequest) (*userclient.BDProfile, error) {
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
if err != nil {
return nil, err
}
parentLeaderUserID := int64(0)
if req.ParentLeaderUserID < 0 {
return nil, fmt.Errorf("parent_leader_user_id must not be negative")
}
if req.ParentLeaderUserID > 0 {
// 父级 Leader 是可选关系;传入短 ID 时才解析成内部 user_id未传时保留 0 表示独立 BD。
parentLeaderUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentLeaderUserID)
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, 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,
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)
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")
}
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")
}
return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
CommandId: commandID,
SellerUserId: sellerUserID,
StockType: stockType,
CoinAmount: req.CoinAmount,
PaidCurrencyCode: paidCurrencyCode,
PaidAmountMicro: paidAmountMicro,
PaymentRef: paymentRef,
EvidenceRef: evidenceRef,
OperatorUserId: actorID,
Reason: reason,
AppCode: appctx.FromContext(ctx),
})
}
func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID string, req createAgencyRequest) (*userclient.CreateAgencyResult, error) {
ownerUserID, err := s.resolveDisplayUserID(ctx, requestID, req.OwnerUserID)
if err != nil {
return nil, err
}
parentBDUserID := int64(0)
if req.ParentBDUserID < 0 {
return nil, fmt.Errorf("parent_bd_user_id must not be negative")
}
if req.ParentBDUserID > 0 {
// 父级 BD 是可选关系;传入短 ID 时才解析成内部 user_id未传时保留 0 表示独立 Agency。
parentBDUserID, err = s.resolveDisplayUserID(ctx, requestID, req.ParentBDUserID)
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,
MaxHosts: req.MaxHosts,
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) 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 (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))
if query.SortBy != "diamond" {
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 ""
}