2026-06-04 22:57:31 +08:00

472 lines
15 KiB
Go

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, 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, 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))
return query
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}