2026-06-25 21:30:26 +08:00

1107 lines
51 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 grpc 将 user-service 认证、用户和短号用例适配成 protobuf gRPC 服务。
package grpc
import (
"context"
"strings"
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"
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
// 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
}
// SetCPService 挂载 CP 关系 service保持 NewServer 老测试构造方式兼容。
func (s *Server) SetCPService(cpSvc *cpservice.Service) {
s.cpSvc = cpSvc
}
// ResolveApp 把客户端包名映射成内部 app_codegateway 后续会把 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
}
// 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
}
// CompletePendingThirdPartyRegistration 消费 pending token 对应的三方注册态,并一次性创建真实用户。
func (s *Server) CompletePendingThirdPartyRegistration(ctx context.Context, req *userv1.CompletePendingThirdPartyRegistrationRequest) (*userv1.CompleteOnboardingResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
if s.authSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "auth service is not configured"))
}
if !s.authSvc.TokenConfigValid() {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "token service is not configured"))
}
user, token, err := s.authSvc.CompletePendingThirdPartyRegistration(ctx, req.GetPendingRegistrationId(), req.GetUsername(), req.GetAvatar(), req.GetGender(), req.GetCountry(), req.GetInviteCode(), authMeta(req.GetMeta()))
if err != nil {
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
}
// 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 立即失效。
token, err := s.authSvc.RefreshToken(ctx, req.GetRefreshToken(), req.GetMeta().GetDeviceId(), authMeta(req.GetMeta()))
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
}
// 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
}
// 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
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
}
// 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(),
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())
recorded, stats, err := s.userSvc.RecordProfileVisit(ctx, req.GetVisitorUserId(), req.GetTargetUserId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.RecordProfileVisitResponse{Recorded: recorded, TargetStats: toProtoProfileStats(stats)}, 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_idvalue 是当前有效用户投影。
result[userID] = toProtoUser(user)
}
return &userv1.BatchGetUsersResponse{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
}
// UpdateUserProfile 修改当前用户基础资料。
func (s *Server) UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
// 可选字段由 proto optional 表达service 层根据 nil 区分“不修改”和“清空”。
user, err := s.userSvc.UpdateUserProfile(ctx, req.GetUserId(), req.Username, req.Avatar, req.Gender, req.Birth)
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
}
// 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
}
// 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_iduser-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
}
// 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()
}