747 lines
33 KiB
Go
747 lines
33 KiB
Go
// 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"
|
||
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
|
||
// 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
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 为游戏联调快速创建一个已完成资料且可密码登录的账号。
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 &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,
|
||
}, nil
|
||
}
|
||
|
||
// 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_id,value 是当前有效用户投影。
|
||
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
|
||
}
|
||
|
||
// ChangeUserCountry 单独修改国家并返回下一次可修改时间。
|
||
func (s *Server) ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
|
||
ctx = contextWithApp(ctx, req.GetMeta())
|
||
// 国家修改有独立审计和冷却期,不能混进普通资料更新。
|
||
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(), req.GetMeta().GetRequestId())
|
||
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_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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
func contextWithApp(ctx context.Context, meta *userv1.RequestMeta) context.Context {
|
||
if meta == nil {
|
||
return appcode.WithContext(ctx, "")
|
||
}
|
||
return appcode.WithContext(ctx, meta.GetAppCode())
|
||
}
|