2026-05-06 21:22:39 +08:00

437 lines
20 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"
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"
userservice "hyapp/services/user-service/internal/service/user"
)
// Server 把 user-service 用例层适配为 protobuf gRPC 接口。
type Server struct {
// UnimplementedAuthServiceServer 提供未实现 RPC 的默认返回。
userv1.UnimplementedAuthServiceServer
// UnimplementedUserServiceServer 提供未实现 RPC 的默认返回。
userv1.UnimplementedUserServiceServer
// 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
// authSvc 承载登录、设置密码、refresh 和 logout。
authSvc *authservice.Service
// userSvc 承载用户主数据和 display_user_id/靓号用例。
userSvc *userservice.Service
// hostSvc 承载 user-service 内 host/Agency/BD 事实用例。
hostSvc *hostservice.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
}
// 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
}
// 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
}
// 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
}
// 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
}
// 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
}
// 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())
// 国家修改有独立审计和冷却期,不能混进普通资料更新。
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}, 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())
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),
}, 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
}
// 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
}
// 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())
}