// Package grpc 将 user-service 认证、用户和短号用例适配成 protobuf gRPC 服务。
package grpc

import (
	"context"
	"strings"
	"time"

	userv1 "hyapp.local/api/proto/user/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	authdomain "hyapp/services/user-service/internal/domain/auth"
	userdomain "hyapp/services/user-service/internal/domain/user"
	authservice "hyapp/services/user-service/internal/service/auth"
	cpservice "hyapp/services/user-service/internal/service/cp"
	hostservice "hyapp/services/user-service/internal/service/host"
	mictimeservice "hyapp/services/user-service/internal/service/mictime"
	roomtimeservice "hyapp/services/user-service/internal/service/roomtime"
	userservice "hyapp/services/user-service/internal/service/user"
)

// Server 把 user-service 用例层适配为 protobuf gRPC 接口。
type Server struct {
	// UnimplementedAuthServiceServer 提供未实现 RPC 的默认返回。
	userv1.UnimplementedAuthServiceServer
	// UnimplementedUserServiceServer 提供未实现 RPC 的默认返回。
	userv1.UnimplementedUserServiceServer
	// UnimplementedUserSocialServiceServer 提供访问、关注、好友 RPC 的默认返回。
	userv1.UnimplementedUserSocialServiceServer
	// UnimplementedUserDeviceServiceServer 提供设备推送 token RPC 的默认返回。
	userv1.UnimplementedUserDeviceServiceServer
	// UnimplementedAppRegistryServiceServer 提供 App 注册表 RPC 的默认返回。
	userv1.UnimplementedAppRegistryServiceServer
	// UnimplementedUserIdentityServiceServer 提供未实现 RPC 的默认返回。
	userv1.UnimplementedUserIdentityServiceServer
	// UnimplementedUserPrettyDisplayIDAdminServiceServer 提供靓号后台管理 RPC 的默认返回。
	userv1.UnimplementedUserPrettyDisplayIDAdminServiceServer
	// UnimplementedCountryAdminServiceServer 提供未实现 RPC 的默认返回。
	userv1.UnimplementedCountryAdminServiceServer
	// UnimplementedCountryQueryServiceServer 提供注册页国家查询 RPC 的前向兼容默认返回。
	userv1.UnimplementedCountryQueryServiceServer
	// UnimplementedRegionAdminServiceServer 提供未实现 RPC 的默认返回。
	userv1.UnimplementedRegionAdminServiceServer
	// UnimplementedUserHostServiceServer 提供 host domain RPC 的默认返回。
	userv1.UnimplementedUserHostServiceServer
	// UnimplementedUserHostAdminServiceServer 提供 host 后台关系管理 RPC 的默认返回。
	userv1.UnimplementedUserHostAdminServiceServer
	// UnimplementedUserCronServiceServer 提供 cron-service 批处理 RPC 的默认返回。
	userv1.UnimplementedUserCronServiceServer
	// UnimplementedUserCPServiceServer 提供 CP 关系 App RPC 的默认返回。
	userv1.UnimplementedUserCPServiceServer
	// UnimplementedUserCPInternalServiceServer 提供 CP 内部消费 RPC 的默认返回。
	userv1.UnimplementedUserCPInternalServiceServer

	// authSvc 承载登录、设置密码、refresh 和 logout。
	authSvc *authservice.Service
	// userSvc 承载用户主数据和 display_user_id/靓号用例。
	userSvc *userservice.Service
	// hostSvc 承载 user-service 内 host/Agency/BD 事实用例。
	hostSvc *hostservice.Service
	// micTimeSvc 承载用户维度麦位在线时长聚合查询。
	micTimeSvc *mictimeservice.Service
	// roomTimeSvc 承载用户维度在房时长会话补偿。
	roomTimeSvc *roomtimeservice.Service
	// cpSvc 承载 CP/兄弟/姐妹申请、关系和房间送礼消费用例。
	cpSvc *cpservice.Service
}

// NewServer 创建 user-service gRPC server。
func NewServer(authSvc *authservice.Service, userSvc *userservice.Service, hostSvc ...*hostservice.Service) *Server {
	// gRPC 层只做协议转换和错误转换，不保存领域状态。
	server := &Server{
		authSvc: authSvc,
		userSvc: userSvc,
	}
	if len(hostSvc) > 0 {
		server.hostSvc = hostSvc[0]
	}

	return server
}

// SetMicTimeService 挂载可选的麦位时长 read model，避免破坏现有测试构造函数签名。
func (s *Server) SetMicTimeService(micTimeSvc *mictimeservice.Service) {
	s.micTimeSvc = micTimeSvc
}

// SetRoomTimeService 挂载可选的在房时长 read model，避免破坏现有测试构造函数签名。
func (s *Server) SetRoomTimeService(roomTimeSvc *roomtimeservice.Service) {
	s.roomTimeSvc = roomTimeSvc
}

// SetCPService 挂载 CP 关系 service，保持 NewServer 老测试构造方式兼容。
func (s *Server) SetCPService(cpSvc *cpservice.Service) {
	s.cpSvc = cpSvc
}

// ResolveApp 把客户端包名映射成内部 app_code，gateway 后续会把 app_code 写入 JWT 和 RequestMeta。
func (s *Server) ResolveApp(ctx context.Context, req *userv1.ResolveAppRequest) (*userv1.ResolveAppResponse, error) {
	app, err := s.userSvc.ResolveApp(ctx, req.GetAppCode(), req.GetPackageName(), req.GetPlatform())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.ResolveAppResponse{App: toProtoApp(app)}, nil
}

// ListApps 给内部 scheduler 提供启用租户目录，避免每个后台任务在 YAML 重复维护 app_code。
func (s *Server) ListApps(ctx context.Context, _ *userv1.ListAppsRequest) (*userv1.ListAppsResponse, error) {
	apps, err := s.userSvc.ListApps(ctx)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	items := make([]*userv1.App, 0, len(apps))
	for _, app := range apps {
		items = append(items, toProtoApp(app))
	}
	return &userv1.ListAppsResponse{Apps: items}, nil
}

// LoginPassword 统一把短号不存在、未设置密码和密码错误留给 service 层映射为 AUTH_FAILED。
func (s *Server) LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// device_id、request_id、client_ip 等审计字段统一从 RequestMeta 转换。
	token, err := s.authSvc.LoginPassword(ctx, req.GetDisplayUserId(), req.GetPassword(), req.GetMeta().GetDeviceId(), authMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.AuthResponse{Token: toProtoToken(token), ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil
}

// LoginThirdParty 只接受 provider 凭证，不在 gateway 泄漏 provider secret。
func (s *Server) LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	registration := authdomain.ThirdPartyRegistration{
		Username:       req.GetUsername(),
		Gender:         req.GetGender(),
		Country:        req.GetCountry(),
		InviteCode:     req.GetInviteCode(),
		IP:             req.GetMeta().GetClientIp(),
		DeviceID:       req.GetDeviceId(),
		Device:         req.GetDevice(),
		OSVersion:      req.GetOsVersion(),
		Avatar:         req.GetAvatar(),
		BirthDate:      req.GetBirth(),
		AppVersion:     req.GetAppVersion(),
		BuildNumber:    req.GetBuildNumber(),
		Source:         req.GetSource(),
		InstallChannel: req.GetInstallChannel(),
		Campaign:       req.GetCampaign(),
		Platform:       req.GetPlatform(),
		Language:       req.GetLanguage(),
		Timezone:       req.GetTimezone(),
	}
	// 是否新用户由 auth service 根据 provider subject 绑定是否存在决定。
	token, isNewUser, err := s.authSvc.LoginThirdParty(ctx, req.GetProvider(), req.GetCredential(), registration, authMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser, ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil
}

// RegisterThirdParty 在资料页完成时一次性校验 provider credential、创建或补齐用户并签发完整 session。
func (s *Server) RegisterThirdParty(ctx context.Context, req *userv1.RegisterThirdPartyRequest) (*userv1.AuthResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	registration := authdomain.ThirdPartyRegistration{
		Username:       req.GetUsername(),
		Gender:         req.GetGender(),
		Country:        req.GetCountry(),
		InviteCode:     req.GetInviteCode(),
		IP:             req.GetMeta().GetClientIp(),
		DeviceID:       req.GetDeviceId(),
		Device:         req.GetDevice(),
		OSVersion:      req.GetOsVersion(),
		Avatar:         req.GetAvatar(),
		BirthDate:      req.GetBirth(),
		AppVersion:     req.GetAppVersion(),
		BuildNumber:    req.GetBuildNumber(),
		Source:         req.GetSource(),
		InstallChannel: req.GetInstallChannel(),
		Campaign:       req.GetCampaign(),
		Platform:       req.GetPlatform(),
		Language:       req.GetLanguage(),
		Timezone:       req.GetTimezone(),
	}
	// 注册专用 RPC 不返回 profile_required token；service 层会在写库前验证资料完整度。
	token, isNewUser, err := s.authSvc.RegisterThirdParty(ctx, req.GetProvider(), req.GetCredential(), registration, authMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser, ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil
}

// CheckThirdPartyRegistered 只检查 Firebase/三方身份是否已完成注册，不创建用户或 session。
func (s *Server) CheckThirdPartyRegistered(ctx context.Context, req *userv1.CheckThirdPartyRegisteredRequest) (*userv1.CheckThirdPartyRegisteredResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	registered, err := s.authSvc.CheckThirdPartyRegistered(ctx, req.GetProvider(), req.GetCredential(), authMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.CheckThirdPartyRegisteredResponse{Registered: registered}, nil
}

// SetPassword 为已登录用户首次写入密码身份。
func (s *Server) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// user_id 必须由 gateway 鉴权后传入，service 层会拒绝非法值。
	if err := s.authSvc.SetPassword(ctx, req.GetUserId(), req.GetPassword(), authMeta(req.GetMeta())); err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.SetPasswordResponse{PasswordSet: true}, nil
}

// QuickCreateAccount 为后台快速创建完整资料账号；source=game_robot 时只创建资料号，不返回可登录 session。
func (s *Server) QuickCreateAccount(ctx context.Context, req *userv1.QuickCreateAccountRequest) (*userv1.QuickCreateAccountResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	registration := authdomain.ThirdPartyRegistration{
		Username:       req.GetUsername(),
		Gender:         req.GetGender(),
		Country:        req.GetCountry(),
		InviteCode:     req.GetInviteCode(),
		IP:             req.GetMeta().GetClientIp(),
		DeviceID:       req.GetDeviceId(),
		Device:         req.GetDevice(),
		OSVersion:      req.GetOsVersion(),
		Avatar:         req.GetAvatar(),
		BirthDate:      req.GetBirth(),
		AppVersion:     req.GetAppVersion(),
		BuildNumber:    req.GetBuildNumber(),
		Source:         req.GetSource(),
		InstallChannel: req.GetInstallChannel(),
		Campaign:       req.GetCampaign(),
		Platform:       req.GetPlatform(),
		Language:       req.GetLanguage(),
		Timezone:       req.GetTimezone(),
	}
	token, err := s.authSvc.QuickCreateAccount(ctx, req.GetPassword(), registration, authMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.QuickCreateAccountResponse{Token: toProtoToken(token), PasswordSet: true}, nil
}

// RefreshToken 执行 refresh token 轮换。
func (s *Server) RefreshToken(ctx context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// refresh token 轮换后响应里会返回新的 refresh token，旧 token 立即失效。
	meta := authMeta(req.GetMeta())
	meta.RefreshRequestID = req.GetRefreshRequestId()
	token, err := s.authSvc.RefreshToken(ctx, req.GetRefreshToken(), req.GetMeta().GetDeviceId(), meta)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.RefreshTokenResponse{Token: toProtoToken(token)}, nil
}

// Logout 失效指定会话或 refresh token。
func (s *Server) Logout(ctx context.Context, req *userv1.LogoutRequest) (*userv1.LogoutResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// Logout 支持按 session_id 或 refresh token 失效会话。
	revoked, err := s.authSvc.Logout(ctx, req.GetSessionId(), req.GetRefreshToken(), authMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.LogoutResponse{Revoked: revoked}, nil
}

// RecordLoginBlocked 记录 gateway 登录入口快拦审计，不参与认证主链路。
func (s *Server) RecordLoginBlocked(ctx context.Context, req *userv1.RecordLoginBlockedRequest) (*userv1.RecordLoginBlockedResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	recorded := s.authSvc.RecordLoginBlocked(ctx, authMeta(req.GetMeta()), req.GetChannel(), req.GetPlatform(), req.GetLanguage(), req.GetTimezone(), req.GetBlockReason())
	return &userv1.RecordLoginBlockedResponse{Recorded: recorded}, nil
}

// CheckLoginRiskIPWhitelist 判断登录入口 IP 是否命中后台白名单；gateway 用它在快拦截前保留运营例外。
func (s *Server) CheckLoginRiskIPWhitelist(ctx context.Context, req *userv1.CheckLoginRiskIPWhitelistRequest) (*userv1.CheckLoginRiskIPWhitelistResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	whitelisted, err := s.authSvc.CheckLoginRiskIPWhitelisted(ctx, req.GetClientIp())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.CheckLoginRiskIPWhitelistResponse{Whitelisted: whitelisted}, nil
}

// RefreshLoginRiskWhitelistCache 让后台保存白名单后立刻重建 Redis 缓存，避免等待 TTL 自然过期。
func (s *Server) RefreshLoginRiskWhitelistCache(ctx context.Context, req *userv1.RefreshLoginRiskWhitelistCacheRequest) (*userv1.RefreshLoginRiskWhitelistCacheResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	snapshot, err := s.authSvc.RefreshLoginRiskWhitelistCache(ctx)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.RefreshLoginRiskWhitelistCacheResponse{
		Refreshed:            snapshot.Refreshed,
		IpWhitelistCount:     int32(snapshot.IPWhitelistCount),
		UserWhitelistCount:   int32(snapshot.UserWhitelistCount),
		UserCacheExpiresAtMs: snapshot.UserCacheExpiresAtMs,
	}, nil
}

// GetRegisterRiskConfig 返回后台配置页和注册链路共用的设备注册上限。
func (s *Server) GetRegisterRiskConfig(ctx context.Context, req *userv1.GetRegisterRiskConfigRequest) (*userv1.GetRegisterRiskConfigResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	config, err := s.authSvc.GetRegisterRiskConfig(ctx)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.GetRegisterRiskConfigResponse{Config: toProtoRegisterRiskConfig(config)}, nil
}

// UpdateRegisterRiskConfig 保存后台注册风控配置，并由 auth service 刷新 Redis 缓存。
func (s *Server) UpdateRegisterRiskConfig(ctx context.Context, req *userv1.UpdateRegisterRiskConfigRequest) (*userv1.UpdateRegisterRiskConfigResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	config, err := s.authSvc.UpdateRegisterRiskConfig(ctx, req.GetMaxAccountsPerDevice(), req.GetOperatorAdminId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.UpdateRegisterRiskConfigResponse{Config: toProtoRegisterRiskConfig(config)}, nil
}

// AppHeartbeat 刷新当前 App 登录会话最近在线时间，不创建房间或麦位 presence。
func (s *Server) AppHeartbeat(ctx context.Context, req *userv1.AppHeartbeatRequest) (*userv1.AppHeartbeatResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	session, err := s.authSvc.AppHeartbeat(ctx, req.GetUserId(), req.GetSessionId(), authMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	// H5 游戏页可能停留在同一 access token 上连续轮询；心跳成功后用当前 active session 重签 access token，
	// 不旋转 refresh token，避免游戏过程因为旧 access token 到期而被 gateway 拒绝。
	token, err := s.authSvc.IssueAccessTokenForSession(ctx, req.GetUserId(), req.GetSessionId(), authMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.AppHeartbeatResponse{
		Accepted:      true,
		HeartbeatAtMs: session.LastHeartbeatAtMs,
		ServerTimeMs:  session.LastHeartbeatAtMs,
		Token:         toProtoToken(token),
	}, nil
}

// GetUser 返回用户主状态。
func (s *Server) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 用户查询可能触发靓号懒过期，因此仍走 service 层而不是直接读 repository。
	user, err := s.userSvc.GetUser(ctx, req.GetUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.GetUserResponse{User: toProtoUser(user)}, nil
}

// SearchInviteReferrer 用当前用户国家边界搜索 H5 邀请页可绑定邀请人。
func (s *Server) SearchInviteReferrer(ctx context.Context, req *userv1.SearchInviteReferrerRequest) (*userv1.SearchInviteReferrerResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	inviter, err := s.userSvc.SearchInviteReferrer(ctx, req.GetUserId(), req.GetInviteCode())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.SearchInviteReferrerResponse{Inviter: toProtoUser(inviter)}, nil
}

// BindInviteReferrer 绑定当前用户的邀请人；邀请码归属由 user-service 事务内确认。
func (s *Server) BindInviteReferrer(ctx context.Context, req *userv1.BindInviteReferrerRequest) (*userv1.BindInviteReferrerResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	updated, err := s.userSvc.BindInviteReferrer(ctx, req.GetUserId(), req.GetInviteCode(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	inviter, err := s.userSvc.GetUser(ctx, updated.InviteBinding.InviterUserID)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.BindInviteReferrerResponse{
		Invite:  toProtoInviteBinding(updated.InviteBinding),
		Inviter: toProtoUser(inviter),
	}, nil
}

// GetInviteAttribution 返回被邀请用户的邀请人归因，供活动服务按充值事实累计邀请活动奖励。
func (s *Server) GetInviteAttribution(ctx context.Context, req *userv1.GetInviteAttributionRequest) (*userv1.GetInviteAttributionResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	attribution, err := s.userSvc.GetInviteAttribution(ctx, req.GetInvitedUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.GetInviteAttributionResponse{Attribution: toProtoInviteAttribution(attribution)}, nil
}

// BusinessUserLookup 是带业务场景和能力校验的用户查询入口。
func (s *Server) BusinessUserLookup(ctx context.Context, req *userv1.BusinessUserLookupRequest) (*userv1.BusinessUserLookupResponse, error) {
	if s.hostSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
	}
	ctx = contextWithApp(ctx, req.GetMeta())
	capability, ok := capabilityForBusinessLookupScene(req.GetScene())
	if !ok {
		return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "business lookup scene is invalid"))
	}
	allowed, reason, err := s.hostSvc.CheckBusinessCapability(ctx, req.GetActorUserId(), capability)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	if !allowed {
		if reason == "" {
			reason = "permission denied"
		}
		return nil, xerr.ToGRPCError(xerr.New(xerr.PermissionDenied, reason))
	}
	users, err := s.userSvc.BusinessUserLookup(ctx, req.GetScene(), req.GetKeyword(), req.GetPageSize())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.BusinessUserLookupResponse{Users: make([]*userv1.BusinessUserLookupItem, 0, len(users))}
	for _, user := range users {
		resp.Users = append(resp.Users, toProtoBusinessUserLookupItem(user))
	}
	return resp, nil
}

func capabilityForBusinessLookupScene(scene string) (string, bool) {
	switch strings.ToLower(strings.TrimSpace(scene)) {
	case userservice.BusinessSceneManagerResourceGrant:
		return hostservice.CapabilityManagerResourceGrant, true
	case userservice.BusinessSceneManagerVIPGrant:
		return hostservice.CapabilityManagerGrantVIP, true
	case userservice.BusinessSceneManagerUpdateLevel:
		return hostservice.CapabilityManagerUpdateUserLevel, true
	case userservice.BusinessSceneManagerAddBDLeader:
		return hostservice.CapabilityManagerAddBDLeader, true
	case userservice.BusinessSceneManagerAddAdmin:
		return hostservice.CapabilityManagerAddAdmin, true
	case userservice.BusinessSceneManagerAddSuperadmin:
		return hostservice.CapabilityManagerAddSuperadmin, true
	case userservice.BusinessSceneManagerBlockUser:
		return hostservice.CapabilityManagerBlockUser, true
	case userservice.BusinessSceneManagerTransfer:
		return hostservice.CapabilityManagerTransferCountry, true
	default:
		return "", false
	}
}

// GetMyProfileStats 返回我的页顶部统计计数。
func (s *Server) GetMyProfileStats(ctx context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	stats, err := s.userSvc.GetMyProfileStats(ctx, req.GetUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.GetMyProfileStatsResponse{Stats: toProtoProfileStats(stats)}, nil
}

// SetUserStatus 处理平台封禁、停用和恢复用户。
func (s *Server) SetUserStatus(ctx context.Context, req *userv1.SetUserStatusRequest) (*userv1.SetUserStatusResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	result, err := s.userSvc.SetUserStatus(ctx, userservice.UserStatusCommand{
		AppCode:        req.GetMeta().GetAppCode(),
		TargetUserID:   req.GetTargetUserId(),
		Status:         fromProtoStatus(req.GetStatus()),
		OperatorType:   req.GetOperatorType(),
		OperatorUserID: req.GetOperatorUserId(),
		Reason:         req.GetReason(),
		RequestID:      req.GetMeta().GetRequestId(),
		NowMs:          req.GetMeta().GetSentAtMs(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return toProtoSetUserStatusResponse(result), nil
}

// AdminBanUser 创建后台管理员永久或限时封禁；expires_at_ms=0 明确表示永久。
func (s *Server) AdminBanUser(ctx context.Context, req *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	ban, status, err := s.userSvc.AdminBanUser(ctx, userservice.AdminUserBanCommand{
		CommandID:       req.GetCommandId(),
		TargetUserID:    req.GetTargetUserId(),
		ExpiresAtMs:     req.GetExpiresAtMs(),
		OperatorAdminID: req.GetOperatorAdminId(),
		Reason:          req.GetReason(),
		RequestID:       req.GetMeta().GetRequestId(),
		NowMs:           req.GetMeta().GetSentAtMs(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.AdminBanUserResponse{Ban: toProtoAdminUserBan(ban), Status: toProtoSetUserStatusResponse(status)}, nil
}

// AdminUnbanUser 由后台管理员强制释放目标用户全部管理员、经理和 legacy 封禁来源。
func (s *Server) AdminUnbanUser(ctx context.Context, req *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	release, status, err := s.userSvc.AdminUnbanUser(ctx, userservice.AdminForceUnbanCommand{
		TargetUserID:    req.GetTargetUserId(),
		OperatorAdminID: req.GetOperatorAdminId(),
		Reason:          req.GetReason(),
		RequestID:       req.GetMeta().GetRequestId(),
		NowMs:           req.GetMeta().GetSentAtMs(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.AdminUnbanUserResponse{
		Status:                    toProtoSetUserStatusResponse(status),
		ReleasedAdminBanCount:     release.ReleasedAdminBanCount,
		ReleasedManagerBlockCount: release.ReleasedManagerBlockCount,
		StatusRestored:            release.StatusRestored,
	}, nil
}

// CreateManagerUserBlock 创建经理中心封禁记录，并同步把目标账号置为 banned。
func (s *Server) CreateManagerUserBlock(ctx context.Context, req *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	block, status, err := s.userSvc.CreateManagerUserBlock(ctx, userservice.ManagerUserBlockCommand{
		CommandID:      req.GetCommandId(),
		ManagerUserID:  req.GetManagerUserId(),
		TargetUserID:   req.GetTargetUserId(),
		BlockedUntilMS: req.GetBlockedUntilMs(),
		Reason:         req.GetReason(),
		RequestID:      req.GetMeta().GetRequestId(),
		NowMS:          req.GetMeta().GetSentAtMs(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.CreateManagerUserBlockResponse{
		Block:  toProtoManagerUserBlock(block),
		Status: toProtoSetUserStatusResponse(status),
	}, nil
}

// ListManagerUserBlocks 返回当前经理名下的封禁记录，默认只列 active 记录。
func (s *Server) ListManagerUserBlocks(ctx context.Context, req *userv1.ListManagerUserBlocksRequest) (*userv1.ListManagerUserBlocksResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	blocks, err := s.userSvc.ListManagerUserBlocks(ctx, req.GetManagerUserId(), req.GetStatus(), req.GetPageSize())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.ListManagerUserBlocksResponse{Blocks: make([]*userv1.ManagerUserBlock, 0, len(blocks))}
	for _, block := range blocks {
		resp.Blocks = append(resp.Blocks, toProtoManagerUserBlock(block))
	}
	return resp, nil
}

// UnblockManagerUser 手动释放经理封禁；service 层会判断是否可以恢复用户主状态。
func (s *Server) UnblockManagerUser(ctx context.Context, req *userv1.UnblockManagerUserRequest) (*userv1.UnblockManagerUserResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	block, status, err := s.userSvc.UnblockManagerUser(ctx, userservice.ReleaseManagerUserBlockCommand{
		BlockID:       req.GetBlockId(),
		ManagerUserID: req.GetManagerUserId(),
		Reason:        req.GetReason(),
		RequestID:     req.GetMeta().GetRequestId(),
		NowMS:         req.GetMeta().GetSentAtMs(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.UnblockManagerUserResponse{
		Block:  toProtoManagerUserBlock(block),
		Status: toProtoSetUserStatusResponse(status),
	}, nil
}

func toProtoSetUserStatusResponse(result userservice.UserStatusResult) *userv1.SetUserStatusResponse {
	// 解封时如果还有其他封禁记录或后台封禁链路，service 会返回空状态结果；空响应表示记录已释放但用户主状态不应被恢复。
	if result.User.UserID == 0 {
		return nil
	}
	return &userv1.SetUserStatusResponse{
		User:                   toProtoUser(result.User),
		RevokedSessionCount:    result.RevokedSessionCount,
		AccessTokenRevoked:     result.AccessTokenRevoked,
		AccessTokenRevokeError: result.AccessTokenError,
		ImKicked:               result.IMKicked,
		ImKickError:            result.IMKickError,
		RoomEvicted:            result.RoomEvicted,
		RoomId:                 result.RoomID,
		RtcKicked:              result.RTCKicked,
		RtcKickError:           result.RTCKickError,
		RoomEvictError:         result.RoomEvictError,
	}
}

// RecordProfileVisit 记录访问足迹；重复访问只刷新访问次数和最后访问时间。
func (s *Server) RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	result, err := s.userSvc.RecordProfileVisit(ctx, requestIDFromMeta(req.GetMeta()), req.GetVisitorUserId(), req.GetTargetUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.RecordProfileVisitResponse{
		Recorded:          result.Recorded,
		TargetStatsHidden: result.TargetStatsHidden,
	}
	if !result.TargetStatsHidden {
		// service 已在隐藏分支清空 stats；transport 再按显式标志省略 protobuf 对象，避免零值对象被客户端误认为真实数据。
		resp.TargetStats = toProtoProfileStats(result.TargetStats)
	}
	return resp, nil
}

// ListProfileVisitors 返回访问当前用户主页的人，按最后访问时间倒序。
func (s *Server) ListProfileVisitors(ctx context.Context, req *userv1.ListProfileVisitorsRequest) (*userv1.ListProfileVisitorsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	records, total, err := s.userSvc.ListProfileVisitors(ctx, req.GetUserId(), req.GetPage(), req.GetPageSize())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.ListProfileVisitorsResponse{Records: make([]*userv1.ProfileVisitRecord, 0, len(records)), Total: total}
	for _, record := range records {
		resp.Records = append(resp.Records, toProtoProfileVisitRecord(record))
	}
	return resp, nil
}

// FollowUser 建立关注关系；重复关注保持幂等。
func (s *Server) FollowUser(ctx context.Context, req *userv1.FollowUserRequest) (*userv1.FollowUserResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	stats, err := s.userSvc.FollowUser(ctx, req.GetFollowerUserId(), req.GetFolloweeUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.FollowUserResponse{Following: true, FollowerStats: toProtoProfileStats(stats)}, nil
}

// UnfollowUser 取消关注关系；未关注时按幂等成功处理。
func (s *Server) UnfollowUser(ctx context.Context, req *userv1.UnfollowUserRequest) (*userv1.UnfollowUserResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	stats, err := s.userSvc.UnfollowUser(ctx, req.GetFollowerUserId(), req.GetFolloweeUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.UnfollowUserResponse{Following: false, FollowerStats: toProtoProfileStats(stats)}, nil
}

// ListFollowing 返回当前用户正在关注的人。
func (s *Server) ListFollowing(ctx context.Context, req *userv1.ListFollowingRequest) (*userv1.ListFollowingResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	records, total, err := s.userSvc.ListFollowing(ctx, req.GetUserId(), req.GetPage(), req.GetPageSize(), req.GetCursorUpdatedAtMs(), req.GetCursorUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.ListFollowingResponse{Records: make([]*userv1.FollowRecord, 0, len(records)), Total: total}
	for _, record := range records {
		resp.Records = append(resp.Records, toProtoFollowRecord(record))
	}
	return resp, nil
}

// ApplyFriend 创建好友申请。客户端随后用 IM 把申请消息送进聊天窗口。
func (s *Server) ApplyFriend(ctx context.Context, req *userv1.ApplyFriendRequest) (*userv1.ApplyFriendResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	application, alreadyFriends, err := s.userSvc.ApplyFriend(ctx, req.GetRequesterUserId(), req.GetTargetUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.ApplyFriendResponse{Application: toProtoFriendApplication(application), AlreadyFriends: alreadyFriends}, nil
}

// AcceptFriendApplication 在聊天窗口同意申请后，把申请转成双向好友关系。
func (s *Server) AcceptFriendApplication(ctx context.Context, req *userv1.AcceptFriendApplicationRequest) (*userv1.AcceptFriendApplicationResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	friend, err := s.userSvc.AcceptFriendApplication(ctx, req.GetAccepterUserId(), req.GetRequesterUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.AcceptFriendApplicationResponse{Friend: toProtoFriendRecord(friend)}, nil
}

// DeleteFriend 删除双向好友关系；缺失关系时按幂等成功返回 deleted=false。
func (s *Server) DeleteFriend(ctx context.Context, req *userv1.DeleteFriendRequest) (*userv1.DeleteFriendResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	deleted, err := s.userSvc.DeleteFriend(ctx, req.GetUserId(), req.GetFriendUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.DeleteFriendResponse{Deleted: deleted}, nil
}

// ListFriends 返回好友列表。
func (s *Server) ListFriends(ctx context.Context, req *userv1.ListFriendsRequest) (*userv1.ListFriendsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	records, total, err := s.userSvc.ListFriends(ctx, req.GetUserId(), req.GetPage(), req.GetPageSize(), req.GetCursorUpdatedAtMs(), req.GetCursorUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.ListFriendsResponse{Records: make([]*userv1.FriendRecord, 0, len(records)), Total: total}
	for _, record := range records {
		resp.Records = append(resp.Records, toProtoFriendRecord(record))
	}
	return resp, nil
}

// ListFriendApplications 返回待处理好友申请，direction 支持 incoming/outgoing。
func (s *Server) ListFriendApplications(ctx context.Context, req *userv1.ListFriendApplicationsRequest) (*userv1.ListFriendApplicationsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	applications, total, err := s.userSvc.ListFriendApplications(ctx, req.GetUserId(), req.GetDirection(), req.GetPage(), req.GetPageSize())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.ListFriendApplicationsResponse{Applications: make([]*userv1.FriendApplication, 0, len(applications)), Total: total}
	for _, application := range applications {
		resp.Applications = append(resp.Applications, toProtoFriendApplication(application))
	}
	return resp, nil
}

// SubmitReport 记录 App 用户提交的用户或房间举报。
func (s *Server) SubmitReport(ctx context.Context, req *userv1.SubmitReportRequest) (*userv1.SubmitReportResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	report, err := s.userSvc.SubmitReport(ctx, req.GetReporterUserId(), req.GetUserId(), req.GetRoomId(), req.GetReportType(), req.GetReason(), req.GetImageUrls(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.SubmitReportResponse{Report: toProtoUserReport(report)}, nil
}

// BatchGetUsers 批量返回用户主状态，缺失用户不填充占位对象。
func (s *Server) BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 批量查询缺失用户不填充空对象，客户端按 map key 判断存在。
	users, err := s.userSvc.BatchGetUsers(ctx, req.GetUserIds())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	result := make(map[int64]*userv1.User, len(users))
	for userID, user := range users {
		// proto map key 使用 user_id，value 是当前有效用户投影。
		result[userID] = toProtoUser(user)
	}

	return &userv1.BatchGetUsersResponse{Users: result}, nil
}

// BatchGetUserAdminProfiles 返回后台列表需要的用户、登录、身份和封禁 owner 事实。
func (s *Server) BatchGetUserAdminProfiles(ctx context.Context, req *userv1.BatchGetUserAdminProfilesRequest) (*userv1.BatchGetUserAdminProfilesResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	profiles, err := s.userSvc.BatchGetUserAdminProfiles(ctx, req.GetUserIds())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	result := make(map[int64]*userv1.UserAdminProfile, len(profiles))
	for userID, profile := range profiles {
		result[userID] = toProtoUserAdminProfile(profile)
	}
	return &userv1.BatchGetUserAdminProfilesResponse{Profiles: result}, nil
}

// AdminIssueUserAccessToken 后台按用户最新 active session 重签 access token。
// 不写 login_audit、不动 session 行；取用审计由 admin 操作日志承担。
func (s *Server) AdminIssueUserAccessToken(ctx context.Context, req *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	meta := authMeta(req.GetMeta())
	if code := strings.TrimSpace(req.GetAppCode()); code != "" {
		// 显式 app_code 字段优先于 meta，后台调用方不依赖 meta 组装细节。
		ctx = appcode.WithContext(ctx, code)
		meta.AppCode = code
	}
	token, session, err := s.authSvc.AdminIssueUserAccessToken(ctx, req.GetUserId(), meta)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.AdminIssueUserAccessTokenResponse{
		AccessToken:              token.AccessToken,
		TokenType:                token.TokenType,
		ExpiresInSec:             token.ExpiresInSec,
		SessionId:                token.SessionID,
		DeviceId:                 session.DeviceID,
		SessionLastHeartbeatAtMs: session.LastHeartbeatAtMs,
	}, nil
}

// ListUserContentBlacklist 返回当前 App 的运营内容屏蔽用户。
func (s *Server) ListUserContentBlacklist(ctx context.Context, req *userv1.ListUserContentBlacklistRequest) (*userv1.ListUserContentBlacklistResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	items, total, err := s.userSvc.ListUserContentBlacklist(ctx, req.GetPage(), req.GetPageSize())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	result := make([]*userv1.UserContentBlacklistEntry, 0, len(items))
	for _, item := range items {
		result = append(result, toProtoUserContentBlacklistEntry(item))
	}
	return &userv1.ListUserContentBlacklistResponse{Items: result, Total: total}, nil
}

// AdminAddUserContentBlacklist 只启用游戏列表和 Banner 屏蔽，不修改用户主状态或会话。
func (s *Server) AdminAddUserContentBlacklist(ctx context.Context, req *userv1.AdminAddUserContentBlacklistRequest) (*userv1.AdminAddUserContentBlacklistResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	item, err := s.userSvc.AdminAddUserContentBlacklist(ctx, userservice.UserContentBlacklistCommand{
		UserID:          req.GetUserId(),
		OperatorAdminID: req.GetOperatorAdminId(),
		Reason:          req.GetReason(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.AdminAddUserContentBlacklistResponse{Item: toProtoUserContentBlacklistEntry(item)}, nil
}

// AdminRemoveUserContentBlacklist 幂等关闭运营内容屏蔽，removed=false 表示目标已经不在名单中。
func (s *Server) AdminRemoveUserContentBlacklist(ctx context.Context, req *userv1.AdminRemoveUserContentBlacklistRequest) (*userv1.AdminRemoveUserContentBlacklistResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	removed, err := s.userSvc.AdminRemoveUserContentBlacklist(ctx, userservice.UserContentBlacklistCommand{
		UserID:          req.GetUserId(),
		OperatorAdminID: req.GetOperatorAdminId(),
		Reason:          req.GetReason(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.AdminRemoveUserContentBlacklistResponse{Removed: removed}, nil
}

// BatchGetRoomBasicUsers 批量返回房间首屏最小用户展示资料。
func (s *Server) BatchGetRoomBasicUsers(ctx context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	users, err := s.userSvc.BatchGetRoomBasicUsers(ctx, req.GetUserIds())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	result := make(map[int64]*userv1.RoomBasicUser, len(users))
	for userID, user := range users {
		// 只透出首屏绘制必需字段，避免 gateway lite 路径拿到完整用户主状态。
		result[userID] = &userv1.RoomBasicUser{
			UserId:        user.UserID,
			Username:      user.Username,
			Avatar:        user.Avatar,
			DisplayUserId: user.DisplayUserID,
		}
	}
	return &userv1.BatchGetRoomBasicUsersResponse{Users: result}, nil
}

// ListUserIDs 为后台 fanout 任务返回稳定递增的用户 ID 游标页。
func (s *Server) ListUserIDs(ctx context.Context, req *userv1.ListUserIDsRequest) (*userv1.ListUserIDsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	userIDs, nextCursor, done, err := s.userSvc.ListUserIDs(ctx, userdomain.UserIDPageFilter{
		TargetScope:  req.GetTargetScope(),
		CursorUserID: req.GetCursorUserId(),
		PageSize:     int(req.GetPageSize()),
		RegionID:     req.GetRegionId(),
		Country:      req.GetCountry(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.ListUserIDsResponse{UserIds: userIDs, NextCursorUserId: nextCursor, Done: done}, nil
}

// GetUserMicLifetimeStats 返回用户维度全部历史麦位时长，主播任务后续也读这份基础指标。
func (s *Server) GetUserMicLifetimeStats(ctx context.Context, req *userv1.GetUserMicLifetimeStatsRequest) (*userv1.GetUserMicLifetimeStatsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	if s.micTimeSvc == nil {
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "mic time service is not configured"))
	}
	appCode := ""
	if req.GetMeta() != nil {
		appCode = req.GetMeta().GetAppCode()
	}
	stats, err := s.micTimeSvc.GetLifetimeStats(ctx, appCode, req.GetUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.GetUserMicLifetimeStatsResponse{Stats: toProtoMicLifetimeStats(stats)}, nil
}

// AuthorizeUserAvatarUpload 在 gateway 写 COS 前占用上传凭证并执行动态头像权益校验。
func (s *Server) AuthorizeUserAvatarUpload(ctx context.Context, req *userv1.AuthorizeUserAvatarUploadRequest) (*userv1.AuthorizeUserAvatarUploadResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	requestID := ""
	if req.GetMeta() != nil {
		requestID = req.GetMeta().GetRequestId()
	}
	registration, err := s.userSvc.AuthorizeUserAvatarUpload(ctx, requestID, req.GetUserId(), req.GetCommandId(), userservice.AvatarMediaFromProto(req.GetMedia()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.AuthorizeUserAvatarUploadResponse{
		Allowed: true, AvatarUploadId: registration.UploadID, ObjectKey: registration.ExpectedObjectKey, ServerTimeMs: time.Now().UTC().UnixMilli(),
	}, nil
}

// CompleteUserAvatarUpload 固化 COS 返回地址；后续资料更新只能按 avatar_upload_id 消费该记录。
func (s *Server) CompleteUserAvatarUpload(ctx context.Context, req *userv1.CompleteUserAvatarUploadRequest) (*userv1.CompleteUserAvatarUploadResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	registration, err := s.userSvc.CompleteUserAvatarUpload(ctx, req.GetUserId(), req.GetAvatarUploadId(), userservice.AvatarMediaFromProto(req.GetMedia()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.CompleteUserAvatarUploadResponse{
		Active: registration.Status == "active", AvatarUploadId: registration.UploadID,
		Media: userservice.AvatarMediaToProto(registration.Media), ServerTimeMs: time.Now().UTC().UnixMilli(),
	}, nil
}

// UpdateUserProfile 修改当前用户基础资料。
func (s *Server) UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 可选字段由 proto optional 表达，service 层根据 nil 区分“不修改”和“清空”。
	requestID := ""
	if req.GetMeta() != nil {
		requestID = req.GetMeta().GetRequestId()
	}
	user, err := s.userSvc.UpdateUserProfile(ctx, req.GetUserId(), req.Username, req.Avatar, req.Gender, req.Birth, req.AvatarUploadId, requestID)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.UpdateUserProfileResponse{User: toProtoUser(user)}, nil
}

// UpdateUserProfileBackground 修改当前用户个人信息页背景图。
func (s *Server) UpdateUserProfileBackground(ctx context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 背景图是独立用户资料字段，不能复用背包装备或通用资料 PATCH 语义。
	user, err := s.userSvc.UpdateUserProfileBackground(ctx, req.GetUserId(), req.GetProfileBgImg())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.UpdateUserProfileBackgroundResponse{User: toProtoUser(user)}, nil
}

// UpdateUserContactInfo 修改当前用户级联系方式。
func (s *Server) UpdateUserContactInfo(ctx context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 联系方式是用户主资料字段，不落到具体经营身份表，避免同一用户多身份时展示不一致。
	user, err := s.userSvc.UpdateUserContactInfo(ctx, req.GetUserId(), req.GetContactInfo())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.UpdateUserContactInfoResponse{User: toProtoUser(user)}, nil
}

// UpdateUserWithdrawAddress 修改当前用户默认提现地址。
func (s *Server) UpdateUserWithdrawAddress(ctx context.Context, req *userv1.UpdateUserWithdrawAddressRequest) (*userv1.UpdateUserWithdrawAddressResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 默认提现地址是用户资料快照；真正提现申请仍由 wallet-service 在创建订单时保存当时地址。
	user, err := s.userSvc.UpdateUserWithdrawAddress(ctx, req.GetUserId(), req.GetUsdtTrc20Address())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.UpdateUserWithdrawAddressResponse{User: toProtoUser(user)}, nil
}

// ChangeUserCountry 是 App 自助改国家入口；受限经营身份会被 service 层按当前角色摘要拦截。
func (s *Server) ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 先读取旧区域只用于响应对比；真正的一致性事件在 repository 事务内写 user_outbox。
	before, err := s.userSvc.GetUser(ctx, req.GetUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	user, nextChangeAllowedAtMs, err := s.userSvc.ChangeUserCountry(ctx, req.GetUserId(), req.GetCountry(), requestIDFromMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.ChangeUserCountryResponse{
		User:                  toProtoUser(user),
		NextChangeAllowedAtMs: nextChangeAllowedAtMs,
		OldRegionId:           before.RegionID,
		NewRegionId:           user.RegionID,
		RegionChanged:         before.RegionID != user.RegionID,
	}, nil
}

// AdminChangeUserCountry 是后台显式覆盖国家入口；后台可治理受限身份用户，仍必须通过 user-service 事务写主表和 outbox。
func (s *Server) AdminChangeUserCountry(ctx context.Context, req *userv1.AdminChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	before, err := s.userSvc.GetUser(ctx, req.GetTargetUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	user, nextChangeAllowedAtMs, err := s.userSvc.AdminChangeUserCountry(ctx, req.GetTargetUserId(), req.GetCountry(), requestIDFromMeta(req.GetMeta()))
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.ChangeUserCountryResponse{
		User:                  toProtoUser(user),
		NextChangeAllowedAtMs: nextChangeAllowedAtMs,
		OldRegionId:           before.RegionID,
		NewRegionId:           user.RegionID,
		RegionChanged:         before.RegionID != user.RegionID,
	}, nil
}

// CompleteOnboarding 原子完成注册页必填资料。
func (s *Server) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	if s.authSvc == nil {
		// 完成注册后必须重签 access token；缺少 auth service 时不能先写资料再让客户端继续拿旧 token。
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "auth service is not configured"))
	}
	if !s.authSvc.TokenConfigValid() {
		// 签名配置缺失时提前失败，避免资料已完成但响应无法带新 access token。
		return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "token service is not configured"))
	}
	// gateway 只透传已鉴权 user_id；资料校验和国家解析必须停留在 user-service。
	user, err := s.userSvc.CompleteOnboarding(ctx, req.GetUserId(), req.GetUsername(), req.GetAvatar(), req.GetGender(), req.GetCountry(), req.GetInviteCode(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	token, err := s.authSvc.IssueAccessTokenForSession(ctx, req.GetUserId(), req.GetMeta().GetSessionId(), authMeta(req.GetMeta()))
	if err != nil {
		// token 签发失败时返回错误，避免客户端拿旧 profile_required JWT 继续撞 profile gate。
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.CompleteOnboardingResponse{
		User:                 toProtoUser(user),
		ProfileCompleted:     user.ProfileCompleted,
		ProfileCompletedAtMs: user.ProfileCompletedAtMs,
		OnboardingStatus:     string(user.OnboardingStatus),
		Token:                toProtoToken(token),
		Invite:               toProtoInviteBinding(user.InviteBinding),
	}, nil
}

// BindPushToken 绑定当前用户设备推送 token。
func (s *Server) BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	updatedAtMS, err := s.userSvc.BindPushToken(ctx, req.GetUserId(), req.GetDeviceId(), req.GetPushToken(), req.GetProvider(), req.GetPlatform(), req.GetAppVersion(), req.GetLanguage(), req.GetTimezone(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.BindPushTokenResponse{Bound: true, UpdatedAtMs: updatedAtMS}, nil
}

// DeletePushToken 失效当前用户设备推送 token。
func (s *Server) DeletePushToken(ctx context.Context, req *userv1.DeletePushTokenRequest) (*userv1.DeletePushTokenResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	deleted, updatedAtMS, err := s.userSvc.DeletePushToken(ctx, req.GetUserId(), req.GetDeviceId(), req.GetPushToken(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.DeletePushTokenResponse{Deleted: deleted, UpdatedAtMs: updatedAtMS}, nil
}

// ListCountries 返回国家主数据列表。
func (s *Server) ListCountries(ctx context.Context, req *userv1.ListCountriesRequest) (*userv1.ListCountriesResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	countries, err := s.userSvc.ListCountries(ctx, req.Enabled)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	result := make([]*userv1.Country, 0, len(countries))
	for _, country := range countries {
		result = append(result, toProtoCountry(country))
	}

	return &userv1.ListCountriesResponse{Countries: result}, nil
}

// UpdateCountry 修改国家展示字段，country_code 不允许原地修改。
func (s *Server) UpdateCountry(ctx context.Context, req *userv1.UpdateCountryRequest) (*userv1.CountryResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	country, err := s.userSvc.UpdateCountry(ctx, userdomain.UpdateCountryCommand{
		CountryID:          req.GetCountryId(),
		CountryName:        req.GetCountryName(),
		ISOAlpha3:          req.GetIsoAlpha3(),
		ISONumeric:         req.GetIsoNumeric(),
		CountryDisplayName: req.GetCountryDisplayName(),
		PhoneCountryCode:   req.GetPhoneCountryCode(),
		Flag:               req.GetFlag(),
		SortOrder:          req.GetSortOrder(),
		OperatorUserID:     req.GetOperatorUserId(),
		RequestID:          req.GetMeta().GetRequestId(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.CountryResponse{Country: toProtoCountry(country)}, nil
}

// ListRegistrationCountries 返回 App 注册页公开国家列表。
func (s *Server) ListRegistrationCountries(ctx context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	countries, err := s.userSvc.ListRegistrationCountries(ctx)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	result := make([]*userv1.Country, 0, len(countries))
	for _, country := range countries {
		result = append(result, toProtoCountry(country))
	}

	return &userv1.ListRegistrationCountriesResponse{Countries: result}, nil
}

// ListLoginRiskBlockedCountries 返回 App 登录后异步风控可消费的地区屏蔽词表。
func (s *Server) ListLoginRiskBlockedCountries(ctx context.Context, req *userv1.ListLoginRiskBlockedCountriesRequest) (*userv1.ListLoginRiskBlockedCountriesResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	blocks, err := s.authSvc.ListLoginRiskBlockedCountries(ctx)
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	result := make([]*userv1.LoginRiskBlockedCountry, 0, len(blocks))
	var updatedAtMs int64
	for _, block := range blocks {
		if block.UpdatedAtMs > updatedAtMs {
			updatedAtMs = block.UpdatedAtMs
		}
		result = append(result, &userv1.LoginRiskBlockedCountry{
			CountryCode: block.CountryCode,
			Keyword:     block.Keyword,
			UpdatedAtMs: block.UpdatedAtMs,
		})
	}

	return &userv1.ListLoginRiskBlockedCountriesResponse{Countries: result, UpdatedAtMs: updatedAtMs}, nil
}

// ListRegions 返回区域列表。
func (s *Server) ListRegions(ctx context.Context, req *userv1.ListRegionsRequest) (*userv1.ListRegionsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	regions, err := s.userSvc.ListRegions(ctx, req.GetStatus())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	result := make([]*userv1.Region, 0, len(regions))
	for _, region := range regions {
		result = append(result, toProtoRegion(region))
	}

	return &userv1.ListRegionsResponse{Regions: result}, nil
}

// GetRegion 返回单个区域及其 active 国家列表。
func (s *Server) GetRegion(ctx context.Context, req *userv1.GetRegionRequest) (*userv1.RegionResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	region, err := s.userSvc.GetRegion(ctx, req.GetRegionId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.RegionResponse{Region: toProtoRegion(region)}, nil
}

// UpdateRegion 修改区域展示字段，不改变 countries。
func (s *Server) UpdateRegion(ctx context.Context, req *userv1.UpdateRegionRequest) (*userv1.RegionResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	region, err := s.userSvc.UpdateRegion(ctx, req.GetRegionId(), req.GetRegionCode(), req.GetName(), req.GetSortOrder(), req.GetOperatorUserId(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.RegionResponse{Region: toProtoRegion(region)}, nil
}

// ReplaceRegionCountries 原子替换区域国家归属。
func (s *Server) ReplaceRegionCountries(ctx context.Context, req *userv1.ReplaceRegionCountriesRequest) (*userv1.RegionResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	region, err := s.userSvc.ReplaceRegionCountries(ctx, req.GetRegionId(), req.GetCountries(), req.GetOperatorUserId(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.RegionResponse{Region: toProtoRegion(region)}, nil
}

// GetUserIdentity 按系统 user_id 查询当前 active display_user_id。
func (s *Server) GetUserIdentity(ctx context.Context, req *userv1.GetUserIdentityRequest) (*userv1.GetUserIdentityResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// Identity 查询返回当前 active display_user_id，过期靓号会在 service 层恢复。
	identity, err := s.userSvc.GetUserIdentity(ctx, req.GetUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.GetUserIdentityResponse{Identity: toProtoIdentity(identity)}, nil
}

// ResolveDisplayUserID 把展示短号解析为系统 user_id。
func (s *Server) ResolveDisplayUserID(ctx context.Context, req *userv1.ResolveDisplayUserIDRequest) (*userv1.ResolveDisplayUserIDResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// display_user_id 解析只接受当前 active 短号，不解析 held 默认号或过期靓号。
	identity, err := s.userSvc.ResolveDisplayUserID(ctx, req.GetDisplayUserId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.ResolveDisplayUserIDResponse{Identity: toProtoIdentity(identity)}, nil
}

// ResolveAdminUserIdentifier 为内部后台精确解析默认短号、当前有效展示号或系统长 user_id。
func (s *Server) ResolveAdminUserIdentifier(ctx context.Context, req *userv1.ResolveAdminUserIdentifierRequest) (*userv1.ResolveAdminUserIdentifierResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 后台搜索语义由独立 service 用例承载，禁止扩大客户端 ResolveDisplayUserID 的 active-only 边界。
	identity, err := s.userSvc.ResolveAdminUserIdentifier(ctx, req.GetUserIdentifier())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.ResolveAdminUserIdentifierResponse{Identity: toProtoIdentity(identity)}, nil
}

// ChangeDisplayUserID 修改当前 active 短号并返回新绑定。
func (s *Server) ChangeDisplayUserID(ctx context.Context, req *userv1.ChangeDisplayUserIDRequest) (*userv1.ChangeDisplayUserIDResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 默认短号修改会检查占用和冷却期，具体事务由 service/repository 完成。
	identity, err := s.userSvc.ChangeDisplayUserID(ctx, req.GetUserId(), req.GetNewDisplayUserId(), req.GetReason(), req.GetOperatorUserId(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.ChangeDisplayUserIDResponse{Identity: toProtoIdentity(identity)}, nil
}

// ApplyPrettyDisplayUserID 申请临时靓号并覆盖当前展示号。
func (s *Server) ApplyPrettyDisplayUserID(ctx context.Context, req *userv1.ApplyPrettyDisplayUserIDRequest) (*userv1.ApplyPrettyDisplayUserIDResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 靓号申请要求 payment_receipt_id，user-service 只保存回执不处理账务。
	identity, leaseID, err := s.userSvc.ApplyPrettyDisplayUserID(ctx, req.GetUserId(), req.GetPrettyDisplayUserId(), req.GetLeaseDurationMs(), req.GetPaymentReceiptId(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.ApplyPrettyDisplayUserIDResponse{Identity: toProtoIdentity(identity), LeaseId: leaseID}, nil
}

// ListAvailablePrettyDisplayIDs 返回当前登录用户可申请的靓号池号码。
func (s *Server) ListAvailablePrettyDisplayIDs(ctx context.Context, req *userv1.ListAvailablePrettyDisplayIDsRequest) (*userv1.ListAvailablePrettyDisplayIDsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// gRPC 层不接收等级参数，service 会按 user_id 读取真实等级后再查询可申请池号。
	items, total, err := s.userSvc.ListAvailablePrettyDisplayIDs(ctx, req.GetUserId(), req.GetPage(), req.GetPageSize(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.ListAvailablePrettyDisplayIDsResponse{Items: make([]*userv1.PrettyDisplayID, 0, len(items)), Total: total}
	for _, item := range items {
		resp.Items = append(resp.Items, toProtoPrettyDisplayID(item))
	}
	return resp, nil
}

// ApplyPrettyDisplayIDFromPool 按 pretty_id 申请等级池靓号。
func (s *Server) ApplyPrettyDisplayIDFromPool(ctx context.Context, req *userv1.ApplyPrettyDisplayIDFromPoolRequest) (*userv1.ApplyPrettyDisplayIDFromPoolResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 池申请只把 pretty_id 传入服务层，靓号内容、租期和等级校验都不能由客户端决定。
	identity, leaseID, err := s.userSvc.ApplyPrettyDisplayIDFromPool(ctx, req.GetUserId(), req.GetPrettyId(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.ApplyPrettyDisplayIDFromPoolResponse{Identity: toProtoIdentity(identity), PrettyId: req.GetPrettyId(), LeaseId: leaseID}, nil
}

// ExpirePrettyDisplayUserID 主动触发靓号过期恢复。
func (s *Server) ExpirePrettyDisplayUserID(ctx context.Context, req *userv1.ExpirePrettyDisplayUserIDRequest) (*userv1.ExpirePrettyDisplayUserIDResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 过期恢复可由定时任务、懒过期或人工 RPC 触发，事务语义一致。
	identity, err := s.userSvc.ExpirePrettyDisplayUserID(ctx, req.GetUserId(), req.GetLeaseId(), req.GetMeta().GetRequestId())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}

	return &userv1.ExpirePrettyDisplayUserIDResponse{Identity: toProtoIdentity(identity)}, nil
}

// ListPrettyDisplayIDPools 返回后台靓号池配置列表。
func (s *Server) ListPrettyDisplayIDPools(ctx context.Context, req *userv1.ListPrettyDisplayIDPoolsRequest) (*userv1.ListPrettyDisplayIDPoolsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 后台 RPC 只透传筛选和分页，应用隔离从 meta.app_code 写入 context 后由 service/repository 使用。
	items, total, err := s.userSvc.ListPrettyDisplayIDPools(ctx, req.GetStatus(), req.GetLevelTrack(), req.GetPage(), req.GetPageSize())
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.ListPrettyDisplayIDPoolsResponse{Items: make([]*userv1.PrettyDisplayIDPool, 0, len(items)), Total: total}
	for _, item := range items {
		resp.Items = append(resp.Items, toProtoPrettyDisplayIDPool(item))
	}
	return resp, nil
}

// CreatePrettyDisplayIDPool 创建后台靓号池配置。
func (s *Server) CreatePrettyDisplayIDPool(ctx context.Context, req *userv1.CreatePrettyDisplayIDPoolRequest) (*userv1.PrettyDisplayIDPoolResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// pool_id 和时间在服务层生成，这里只把后台填写的配置字段组成领域命令。
	pool, err := s.userSvc.CreatePrettyDisplayIDPool(ctx, userdomain.PrettyDisplayIDPoolCommand{
		Name:            req.GetName(),
		LevelTrack:      req.GetLevelTrack(),
		MinLevel:        req.GetMinLevel(),
		MaxLevel:        req.GetMaxLevel(),
		RuleType:        req.GetRuleType(),
		RuleConfigJSON:  req.GetRuleConfigJson(),
		Status:          req.GetStatus(),
		SortOrder:       req.GetSortOrder(),
		OperatorAdminID: req.GetOperatorAdminId(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.PrettyDisplayIDPoolResponse{Pool: toProtoPrettyDisplayIDPool(pool)}, nil
}

// UpdatePrettyDisplayIDPool 更新后台靓号池配置。
func (s *Server) UpdatePrettyDisplayIDPool(ctx context.Context, req *userv1.UpdatePrettyDisplayIDPoolRequest) (*userv1.PrettyDisplayIDPoolResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// pool_id 来自请求路径对应的 RPC 字段，service 会校验目标池是否存在并保持同应用更新。
	pool, err := s.userSvc.UpdatePrettyDisplayIDPool(ctx, userdomain.PrettyDisplayIDPoolCommand{
		PoolID:          req.GetPoolId(),
		Name:            req.GetName(),
		LevelTrack:      req.GetLevelTrack(),
		MinLevel:        req.GetMinLevel(),
		MaxLevel:        req.GetMaxLevel(),
		RuleType:        req.GetRuleType(),
		RuleConfigJSON:  req.GetRuleConfigJson(),
		Status:          req.GetStatus(),
		SortOrder:       req.GetSortOrder(),
		OperatorAdminID: req.GetOperatorAdminId(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.PrettyDisplayIDPoolResponse{Pool: toProtoPrettyDisplayIDPool(pool)}, nil
}

// GeneratePrettyDisplayIDs 按池规则批量生成可申请靓号。
func (s *Server) GeneratePrettyDisplayIDs(ctx context.Context, req *userv1.GeneratePrettyDisplayIDsRequest) (*userv1.GeneratePrettyDisplayIDsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 生成批次需要 request_id 参与审计，冲突过滤和 batch 统计由 service/repository 同事务完成。
	batch, err := s.userSvc.GeneratePrettyDisplayIDs(ctx, userdomain.PrettyDisplayIDGenerateCommand{
		PoolID:          req.GetPoolId(),
		RuleType:        req.GetRuleType(),
		RuleConfigJSON:  req.GetRuleConfigJson(),
		Count:           req.GetCount(),
		OperatorAdminID: req.GetOperatorAdminId(),
		RequestID:       req.GetMeta().GetRequestId(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.GeneratePrettyDisplayIDsResponse{Batch: toProtoPrettyDisplayIDBatch(batch)}, nil
}

// ListPrettyDisplayIDs 返回后台靓号明细列表。
func (s *Server) ListPrettyDisplayIDs(ctx context.Context, req *userv1.ListPrettyDisplayIDsRequest) (*userv1.ListPrettyDisplayIDsResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 列表查询允许按来源、状态、池、用户和关键字组合筛选，service 会统一修正分页。
	items, total, err := s.userSvc.ListPrettyDisplayIDs(ctx, userdomain.PrettyDisplayIDQuery{
		PoolID:         req.GetPoolId(),
		Source:         req.GetSource(),
		Status:         req.GetStatus(),
		Keyword:        req.GetKeyword(),
		AssignedUserID: req.GetAssignedUserId(),
		Page:           req.GetPage(),
		PageSize:       req.GetPageSize(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	resp := &userv1.ListPrettyDisplayIDsResponse{Items: make([]*userv1.PrettyDisplayID, 0, len(items)), Total: total}
	for _, item := range items {
		resp.Items = append(resp.Items, toProtoPrettyDisplayID(item))
	}
	return resp, nil
}

// SetPrettyDisplayIDStatus 修改未占用靓号状态。
func (s *Server) SetPrettyDisplayIDStatus(ctx context.Context, req *userv1.SetPrettyDisplayIDStatusRequest) (*userv1.PrettyDisplayIDResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// assigned 靓号不能通过该 RPC 改状态，service/repository 会把这类请求返回不可用。
	item, err := s.userSvc.SetPrettyDisplayIDStatus(ctx, userdomain.PrettyDisplayIDStatusCommand{
		PrettyID:        req.GetPrettyId(),
		Status:          req.GetStatus(),
		OperatorAdminID: req.GetOperatorAdminId(),
		Reason:          req.GetReason(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.PrettyDisplayIDResponse{Item: toProtoPrettyDisplayID(item)}, nil
}

// RecyclePrettyDisplayID 后台回收用户当前占用靓号并恢复默认短号。
func (s *Server) RecyclePrettyDisplayID(ctx context.Context, req *userv1.RecyclePrettyDisplayIDRequest) (*userv1.PrettyDisplayIDResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 回收允许处理长期靓号，不能复用 ExpirePrettyDisplayUserID 的到期校验；具体 lease 一致性由 user-service 事务完成。
	item, err := s.userSvc.RecyclePrettyDisplayID(ctx, userdomain.PrettyDisplayIDRecycleCommand{
		PrettyID:        req.GetPrettyId(),
		Reason:          req.GetReason(),
		OperatorAdminID: req.GetOperatorAdminId(),
		RequestID:       req.GetMeta().GetRequestId(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.PrettyDisplayIDResponse{Item: toProtoPrettyDisplayID(item)}, nil
}

// AdminGrantPrettyDisplayID 后台直接给用户发放自由靓号。
func (s *Server) AdminGrantPrettyDisplayID(ctx context.Context, req *userv1.AdminGrantPrettyDisplayIDRequest) (*userv1.AdminGrantPrettyDisplayIDResponse, error) {
	ctx = contextWithApp(ctx, req.GetMeta())
	// 后台发放不校验用户等级，duration_ms<=0 表示长期，具体内容格式和覆盖释放由 service/repository 处理。
	identity, leaseID, err := s.userSvc.AdminGrantPrettyDisplayID(ctx, userdomain.AdminGrantPrettyDisplayIDCommand{
		TargetUserID:    req.GetTargetUserId(),
		DisplayUserID:   req.GetDisplayUserId(),
		DurationMs:      req.GetDurationMs(),
		Reason:          req.GetReason(),
		OperatorAdminID: req.GetOperatorAdminId(),
		RequestID:       req.GetMeta().GetRequestId(),
	})
	if err != nil {
		return nil, xerr.ToGRPCError(err)
	}
	return &userv1.AdminGrantPrettyDisplayIDResponse{Identity: toProtoIdentity(identity), PrettyId: identity.PrettyID, LeaseId: leaseID}, nil
}

func contextWithApp(ctx context.Context, meta *userv1.RequestMeta) context.Context {
	if meta == nil {
		return appcode.WithContext(ctx, "")
	}
	return appcode.WithContext(ctx, meta.GetAppCode())
}

func requestIDFromMeta(meta *userv1.RequestMeta) string {
	if meta == nil {
		return ""
	}
	return meta.GetRequestId()
}
