2026-06-10 11:08:46 +08:00

566 lines
19 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"
sortByDiamond = "diamond"
sortByMerchantBalance = "merchant_balance"
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) 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
}
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)
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, 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)
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")
}
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 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) 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,
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, 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 ""
}