diff --git a/api/proto/user/v1/user.pb.go b/api/proto/user/v1/user.pb.go index 143ebb04..7ad14893 100644 --- a/api/proto/user/v1/user.pb.go +++ b/api/proto/user/v1/user.pb.go @@ -414,33 +414,34 @@ type User struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - Status UserStatus `protobuf:"varint,2,opt,name=status,proto3,enum=hyapp.user.v1.UserStatus" json:"status,omitempty"` - CreatedAtMs int64 `protobuf:"varint,3,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` - UpdatedAtMs int64 `protobuf:"varint,4,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` - DisplayUserId string `protobuf:"bytes,5,opt,name=display_user_id,json=displayUserId,proto3" json:"display_user_id,omitempty"` - DefaultDisplayUserId string `protobuf:"bytes,6,opt,name=default_display_user_id,json=defaultDisplayUserId,proto3" json:"default_display_user_id,omitempty"` - DisplayUserIdKind string `protobuf:"bytes,7,opt,name=display_user_id_kind,json=displayUserIdKind,proto3" json:"display_user_id_kind,omitempty"` - DisplayUserIdExpiresAtMs int64 `protobuf:"varint,8,opt,name=display_user_id_expires_at_ms,json=displayUserIdExpiresAtMs,proto3" json:"display_user_id_expires_at_ms,omitempty"` - Username string `protobuf:"bytes,9,opt,name=username,proto3" json:"username,omitempty"` - Gender string `protobuf:"bytes,10,opt,name=gender,proto3" json:"gender,omitempty"` - Country string `protobuf:"bytes,11,opt,name=country,proto3" json:"country,omitempty"` - Avatar string `protobuf:"bytes,12,opt,name=avatar,proto3" json:"avatar,omitempty"` - Birth string `protobuf:"bytes,13,opt,name=birth,proto3" json:"birth,omitempty"` - Language string `protobuf:"bytes,14,opt,name=language,proto3" json:"language,omitempty"` - CountryId int64 `protobuf:"varint,15,opt,name=country_id,json=countryId,proto3" json:"country_id,omitempty"` - CountryName string `protobuf:"bytes,16,opt,name=country_name,json=countryName,proto3" json:"country_name,omitempty"` - CountryDisplayName string `protobuf:"bytes,17,opt,name=country_display_name,json=countryDisplayName,proto3" json:"country_display_name,omitempty"` - RegionId int64 `protobuf:"varint,18,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` - RegionCode string `protobuf:"bytes,19,opt,name=region_code,json=regionCode,proto3" json:"region_code,omitempty"` - RegionName string `protobuf:"bytes,20,opt,name=region_name,json=regionName,proto3" json:"region_name,omitempty"` - ProfileCompleted bool `protobuf:"varint,21,opt,name=profile_completed,json=profileCompleted,proto3" json:"profile_completed,omitempty"` - ProfileCompletedAtMs int64 `protobuf:"varint,22,opt,name=profile_completed_at_ms,json=profileCompletedAtMs,proto3" json:"profile_completed_at_ms,omitempty"` - OnboardingStatus string `protobuf:"bytes,23,opt,name=onboarding_status,json=onboardingStatus,proto3" json:"onboarding_status,omitempty"` - IsoNumeric string `protobuf:"bytes,24,opt,name=iso_numeric,json=isoNumeric,proto3" json:"iso_numeric,omitempty"` - PhoneCountryCode string `protobuf:"bytes,25,opt,name=phone_country_code,json=phoneCountryCode,proto3" json:"phone_country_code,omitempty"` - CountryEnabled bool `protobuf:"varint,26,opt,name=country_enabled,json=countryEnabled,proto3" json:"country_enabled,omitempty"` - AppCode string `protobuf:"bytes,27,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Status UserStatus `protobuf:"varint,2,opt,name=status,proto3,enum=hyapp.user.v1.UserStatus" json:"status,omitempty"` + CreatedAtMs int64 `protobuf:"varint,3,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + UpdatedAtMs int64 `protobuf:"varint,4,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + DisplayUserId string `protobuf:"bytes,5,opt,name=display_user_id,json=displayUserId,proto3" json:"display_user_id,omitempty"` + DefaultDisplayUserId string `protobuf:"bytes,6,opt,name=default_display_user_id,json=defaultDisplayUserId,proto3" json:"default_display_user_id,omitempty"` + DisplayUserIdKind string `protobuf:"bytes,7,opt,name=display_user_id_kind,json=displayUserIdKind,proto3" json:"display_user_id_kind,omitempty"` + DisplayUserIdExpiresAtMs int64 `protobuf:"varint,8,opt,name=display_user_id_expires_at_ms,json=displayUserIdExpiresAtMs,proto3" json:"display_user_id_expires_at_ms,omitempty"` + Username string `protobuf:"bytes,9,opt,name=username,proto3" json:"username,omitempty"` + Gender string `protobuf:"bytes,10,opt,name=gender,proto3" json:"gender,omitempty"` + Country string `protobuf:"bytes,11,opt,name=country,proto3" json:"country,omitempty"` + Avatar string `protobuf:"bytes,12,opt,name=avatar,proto3" json:"avatar,omitempty"` + Birth string `protobuf:"bytes,13,opt,name=birth,proto3" json:"birth,omitempty"` + Language string `protobuf:"bytes,14,opt,name=language,proto3" json:"language,omitempty"` + CountryId int64 `protobuf:"varint,15,opt,name=country_id,json=countryId,proto3" json:"country_id,omitempty"` + CountryName string `protobuf:"bytes,16,opt,name=country_name,json=countryName,proto3" json:"country_name,omitempty"` + CountryDisplayName string `protobuf:"bytes,17,opt,name=country_display_name,json=countryDisplayName,proto3" json:"country_display_name,omitempty"` + RegionId int64 `protobuf:"varint,18,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"` + RegionCode string `protobuf:"bytes,19,opt,name=region_code,json=regionCode,proto3" json:"region_code,omitempty"` + RegionName string `protobuf:"bytes,20,opt,name=region_name,json=regionName,proto3" json:"region_name,omitempty"` + ProfileCompleted bool `protobuf:"varint,21,opt,name=profile_completed,json=profileCompleted,proto3" json:"profile_completed,omitempty"` + ProfileCompletedAtMs int64 `protobuf:"varint,22,opt,name=profile_completed_at_ms,json=profileCompletedAtMs,proto3" json:"profile_completed_at_ms,omitempty"` + OnboardingStatus string `protobuf:"bytes,23,opt,name=onboarding_status,json=onboardingStatus,proto3" json:"onboarding_status,omitempty"` + IsoNumeric string `protobuf:"bytes,24,opt,name=iso_numeric,json=isoNumeric,proto3" json:"iso_numeric,omitempty"` + PhoneCountryCode string `protobuf:"bytes,25,opt,name=phone_country_code,json=phoneCountryCode,proto3" json:"phone_country_code,omitempty"` + CountryEnabled bool `protobuf:"varint,26,opt,name=country_enabled,json=countryEnabled,proto3" json:"country_enabled,omitempty"` + AppCode string `protobuf:"bytes,27,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + Invite *InviteOverview `protobuf:"bytes,28,opt,name=invite,proto3" json:"invite,omitempty"` } func (x *User) Reset() { @@ -662,6 +663,354 @@ func (x *User) GetAppCode() string { return "" } +func (x *User) GetInvite() *InviteOverview { + if x != nil { + return x.Invite + } + return nil +} + +// InviteOverview 是我的页可直接展示的邀请码和邀请计数 read model。 +type InviteOverview struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + MyInviteCode string `protobuf:"bytes,1,opt,name=my_invite_code,json=myInviteCode,proto3" json:"my_invite_code,omitempty"` + InviteEnabled bool `protobuf:"varint,2,opt,name=invite_enabled,json=inviteEnabled,proto3" json:"invite_enabled,omitempty"` + InviteCount int64 `protobuf:"varint,3,opt,name=invite_count,json=inviteCount,proto3" json:"invite_count,omitempty"` + ValidInviteCount int64 `protobuf:"varint,4,opt,name=valid_invite_count,json=validInviteCount,proto3" json:"valid_invite_count,omitempty"` + ValidInviteThresholdCoin int64 `protobuf:"varint,5,opt,name=valid_invite_threshold_coin,json=validInviteThresholdCoin,proto3" json:"valid_invite_threshold_coin,omitempty"` +} + +func (x *InviteOverview) Reset() { + *x = InviteOverview{} + mi := &file_proto_user_v1_user_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InviteOverview) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InviteOverview) ProtoMessage() {} + +func (x *InviteOverview) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InviteOverview.ProtoReflect.Descriptor instead. +func (*InviteOverview) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{5} +} + +func (x *InviteOverview) GetMyInviteCode() string { + if x != nil { + return x.MyInviteCode + } + return "" +} + +func (x *InviteOverview) GetInviteEnabled() bool { + if x != nil { + return x.InviteEnabled + } + return false +} + +func (x *InviteOverview) GetInviteCount() int64 { + if x != nil { + return x.InviteCount + } + return 0 +} + +func (x *InviteOverview) GetValidInviteCount() int64 { + if x != nil { + return x.ValidInviteCount + } + return 0 +} + +func (x *InviteOverview) GetValidInviteThresholdCoin() int64 { + if x != nil { + return x.ValidInviteThresholdCoin + } + return 0 +} + +// InviteBinding 表示注册完成请求本次是否新绑定邀请关系。 +type InviteBinding struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Bound bool `protobuf:"varint,1,opt,name=bound,proto3" json:"bound,omitempty"` + InviteCode string `protobuf:"bytes,2,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"` + InviterUserId int64 `protobuf:"varint,3,opt,name=inviter_user_id,json=inviterUserId,proto3" json:"inviter_user_id,omitempty"` +} + +func (x *InviteBinding) Reset() { + *x = InviteBinding{} + mi := &file_proto_user_v1_user_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InviteBinding) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InviteBinding) ProtoMessage() {} + +func (x *InviteBinding) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InviteBinding.ProtoReflect.Descriptor instead. +func (*InviteBinding) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{6} +} + +func (x *InviteBinding) GetBound() bool { + if x != nil { + return x.Bound + } + return false +} + +func (x *InviteBinding) GetInviteCode() string { + if x != nil { + return x.InviteCode + } + return "" +} + +func (x *InviteBinding) GetInviterUserId() int64 { + if x != nil { + return x.InviterUserId + } + return 0 +} + +// UserMicLifetimeStats 是用户维度的麦上累计基础指标,不区分主播/公会身份。 +type UserMicLifetimeStats struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + SeatOccupiedMs int64 `protobuf:"varint,2,opt,name=seat_occupied_ms,json=seatOccupiedMs,proto3" json:"seat_occupied_ms,omitempty"` + MicOnlineMs int64 `protobuf:"varint,3,opt,name=mic_online_ms,json=micOnlineMs,proto3" json:"mic_online_ms,omitempty"` + SessionCount int64 `protobuf:"varint,4,opt,name=session_count,json=sessionCount,proto3" json:"session_count,omitempty"` + FirstMicAtMs int64 `protobuf:"varint,5,opt,name=first_mic_at_ms,json=firstMicAtMs,proto3" json:"first_mic_at_ms,omitempty"` + LastMicAtMs int64 `protobuf:"varint,6,opt,name=last_mic_at_ms,json=lastMicAtMs,proto3" json:"last_mic_at_ms,omitempty"` + UpdatedAtMs int64 `protobuf:"varint,7,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` +} + +func (x *UserMicLifetimeStats) Reset() { + *x = UserMicLifetimeStats{} + mi := &file_proto_user_v1_user_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserMicLifetimeStats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserMicLifetimeStats) ProtoMessage() {} + +func (x *UserMicLifetimeStats) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserMicLifetimeStats.ProtoReflect.Descriptor instead. +func (*UserMicLifetimeStats) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{7} +} + +func (x *UserMicLifetimeStats) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *UserMicLifetimeStats) GetSeatOccupiedMs() int64 { + if x != nil { + return x.SeatOccupiedMs + } + return 0 +} + +func (x *UserMicLifetimeStats) GetMicOnlineMs() int64 { + if x != nil { + return x.MicOnlineMs + } + return 0 +} + +func (x *UserMicLifetimeStats) GetSessionCount() int64 { + if x != nil { + return x.SessionCount + } + return 0 +} + +func (x *UserMicLifetimeStats) GetFirstMicAtMs() int64 { + if x != nil { + return x.FirstMicAtMs + } + return 0 +} + +func (x *UserMicLifetimeStats) GetLastMicAtMs() int64 { + if x != nil { + return x.LastMicAtMs + } + return 0 +} + +func (x *UserMicLifetimeStats) GetUpdatedAtMs() int64 { + if x != nil { + return x.UpdatedAtMs + } + return 0 +} + +func (x *UserMicLifetimeStats) GetAppCode() string { + if x != nil { + return x.AppCode + } + return "" +} + +// GetUserMicLifetimeStatsRequest 查询单个用户全部历史麦位时长。 +type GetUserMicLifetimeStatsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` +} + +func (x *GetUserMicLifetimeStatsRequest) Reset() { + *x = GetUserMicLifetimeStatsRequest{} + mi := &file_proto_user_v1_user_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetUserMicLifetimeStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetUserMicLifetimeStatsRequest) ProtoMessage() {} + +func (x *GetUserMicLifetimeStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetUserMicLifetimeStatsRequest.ProtoReflect.Descriptor instead. +func (*GetUserMicLifetimeStatsRequest) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{8} +} + +func (x *GetUserMicLifetimeStatsRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *GetUserMicLifetimeStatsRequest) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +type GetUserMicLifetimeStatsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Stats *UserMicLifetimeStats `protobuf:"bytes,1,opt,name=stats,proto3" json:"stats,omitempty"` +} + +func (x *GetUserMicLifetimeStatsResponse) Reset() { + *x = GetUserMicLifetimeStatsResponse{} + mi := &file_proto_user_v1_user_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetUserMicLifetimeStatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetUserMicLifetimeStatsResponse) ProtoMessage() {} + +func (x *GetUserMicLifetimeStatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetUserMicLifetimeStatsResponse.ProtoReflect.Descriptor instead. +func (*GetUserMicLifetimeStatsResponse) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{9} +} + +func (x *GetUserMicLifetimeStatsResponse) GetStats() *UserMicLifetimeStats { + if x != nil { + return x.Stats + } + return nil +} + // AuthToken 是登录、刷新和注册完成后返回给 gateway 的访问令牌投影。 type AuthToken struct { state protoimpl.MessageState @@ -685,7 +1034,7 @@ type AuthToken struct { func (x *AuthToken) Reset() { *x = AuthToken{} - mi := &file_proto_user_v1_user_proto_msgTypes[5] + mi := &file_proto_user_v1_user_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -697,7 +1046,7 @@ func (x *AuthToken) String() string { func (*AuthToken) ProtoMessage() {} func (x *AuthToken) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[5] + mi := &file_proto_user_v1_user_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -710,7 +1059,7 @@ func (x *AuthToken) ProtoReflect() protoreflect.Message { // Deprecated: Use AuthToken.ProtoReflect.Descriptor instead. func (*AuthToken) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{5} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{10} } func (x *AuthToken) GetUserId() int64 { @@ -816,7 +1165,7 @@ type GetUserRequest struct { func (x *GetUserRequest) Reset() { *x = GetUserRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[6] + mi := &file_proto_user_v1_user_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -828,7 +1177,7 @@ func (x *GetUserRequest) String() string { func (*GetUserRequest) ProtoMessage() {} func (x *GetUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[6] + mi := &file_proto_user_v1_user_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -841,7 +1190,7 @@ func (x *GetUserRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetUserRequest.ProtoReflect.Descriptor instead. func (*GetUserRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{6} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{11} } func (x *GetUserRequest) GetMeta() *RequestMeta { @@ -869,7 +1218,7 @@ type GetUserResponse struct { func (x *GetUserResponse) Reset() { *x = GetUserResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[7] + mi := &file_proto_user_v1_user_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -881,7 +1230,7 @@ func (x *GetUserResponse) String() string { func (*GetUserResponse) ProtoMessage() {} func (x *GetUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[7] + mi := &file_proto_user_v1_user_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -894,7 +1243,7 @@ func (x *GetUserResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetUserResponse.ProtoReflect.Descriptor instead. func (*GetUserResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{7} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{12} } func (x *GetUserResponse) GetUser() *User { @@ -916,7 +1265,7 @@ type BatchGetUsersRequest struct { func (x *BatchGetUsersRequest) Reset() { *x = BatchGetUsersRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[8] + mi := &file_proto_user_v1_user_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -928,7 +1277,7 @@ func (x *BatchGetUsersRequest) String() string { func (*BatchGetUsersRequest) ProtoMessage() {} func (x *BatchGetUsersRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[8] + mi := &file_proto_user_v1_user_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -941,7 +1290,7 @@ func (x *BatchGetUsersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchGetUsersRequest.ProtoReflect.Descriptor instead. func (*BatchGetUsersRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{8} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{13} } func (x *BatchGetUsersRequest) GetMeta() *RequestMeta { @@ -969,7 +1318,7 @@ type BatchGetUsersResponse struct { func (x *BatchGetUsersResponse) Reset() { *x = BatchGetUsersResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[9] + mi := &file_proto_user_v1_user_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -981,7 +1330,7 @@ func (x *BatchGetUsersResponse) String() string { func (*BatchGetUsersResponse) ProtoMessage() {} func (x *BatchGetUsersResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[9] + mi := &file_proto_user_v1_user_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -994,7 +1343,7 @@ func (x *BatchGetUsersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BatchGetUsersResponse.ProtoReflect.Descriptor instead. func (*BatchGetUsersResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{9} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{14} } func (x *BatchGetUsersResponse) GetUsers() map[int64]*User { @@ -1020,7 +1369,7 @@ type ListUserIDsRequest struct { func (x *ListUserIDsRequest) Reset() { *x = ListUserIDsRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[10] + mi := &file_proto_user_v1_user_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1032,7 +1381,7 @@ func (x *ListUserIDsRequest) String() string { func (*ListUserIDsRequest) ProtoMessage() {} func (x *ListUserIDsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[10] + mi := &file_proto_user_v1_user_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1045,7 +1394,7 @@ func (x *ListUserIDsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListUserIDsRequest.ProtoReflect.Descriptor instead. func (*ListUserIDsRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{10} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{15} } func (x *ListUserIDsRequest) GetMeta() *RequestMeta { @@ -1103,7 +1452,7 @@ type ListUserIDsResponse struct { func (x *ListUserIDsResponse) Reset() { *x = ListUserIDsResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[11] + mi := &file_proto_user_v1_user_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1115,7 +1464,7 @@ func (x *ListUserIDsResponse) String() string { func (*ListUserIDsResponse) ProtoMessage() {} func (x *ListUserIDsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[11] + mi := &file_proto_user_v1_user_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1128,7 +1477,7 @@ func (x *ListUserIDsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListUserIDsResponse.ProtoReflect.Descriptor instead. func (*ListUserIDsResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{11} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{16} } func (x *ListUserIDsResponse) GetUserIds() []int64 { @@ -1168,7 +1517,7 @@ type UpdateUserProfileRequest struct { func (x *UpdateUserProfileRequest) Reset() { *x = UpdateUserProfileRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[12] + mi := &file_proto_user_v1_user_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1180,7 +1529,7 @@ func (x *UpdateUserProfileRequest) String() string { func (*UpdateUserProfileRequest) ProtoMessage() {} func (x *UpdateUserProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[12] + mi := &file_proto_user_v1_user_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1193,7 +1542,7 @@ func (x *UpdateUserProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateUserProfileRequest.ProtoReflect.Descriptor instead. func (*UpdateUserProfileRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{12} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{17} } func (x *UpdateUserProfileRequest) GetMeta() *RequestMeta { @@ -1249,7 +1598,7 @@ type UpdateUserProfileResponse struct { func (x *UpdateUserProfileResponse) Reset() { *x = UpdateUserProfileResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[13] + mi := &file_proto_user_v1_user_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1261,7 +1610,7 @@ func (x *UpdateUserProfileResponse) String() string { func (*UpdateUserProfileResponse) ProtoMessage() {} func (x *UpdateUserProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[13] + mi := &file_proto_user_v1_user_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1274,7 +1623,7 @@ func (x *UpdateUserProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateUserProfileResponse.ProtoReflect.Descriptor instead. func (*UpdateUserProfileResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{13} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{18} } func (x *UpdateUserProfileResponse) GetUser() *User { @@ -1297,7 +1646,7 @@ type ChangeUserCountryRequest struct { func (x *ChangeUserCountryRequest) Reset() { *x = ChangeUserCountryRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[14] + mi := &file_proto_user_v1_user_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1309,7 +1658,7 @@ func (x *ChangeUserCountryRequest) String() string { func (*ChangeUserCountryRequest) ProtoMessage() {} func (x *ChangeUserCountryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[14] + mi := &file_proto_user_v1_user_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1322,7 +1671,7 @@ func (x *ChangeUserCountryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeUserCountryRequest.ProtoReflect.Descriptor instead. func (*ChangeUserCountryRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{14} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{19} } func (x *ChangeUserCountryRequest) GetMeta() *RequestMeta { @@ -1358,7 +1707,7 @@ type ChangeUserCountryResponse struct { func (x *ChangeUserCountryResponse) Reset() { *x = ChangeUserCountryResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[15] + mi := &file_proto_user_v1_user_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1370,7 +1719,7 @@ func (x *ChangeUserCountryResponse) String() string { func (*ChangeUserCountryResponse) ProtoMessage() {} func (x *ChangeUserCountryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[15] + mi := &file_proto_user_v1_user_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1383,7 +1732,7 @@ func (x *ChangeUserCountryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeUserCountryResponse.ProtoReflect.Descriptor instead. func (*ChangeUserCountryResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{15} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{20} } func (x *ChangeUserCountryResponse) GetUser() *User { @@ -1406,17 +1755,18 @@ type CompleteOnboardingRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` - UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` - Avatar string `protobuf:"bytes,4,opt,name=avatar,proto3" json:"avatar,omitempty"` - Country string `protobuf:"bytes,5,opt,name=country,proto3" json:"country,omitempty"` - Gender string `protobuf:"bytes,6,opt,name=gender,proto3" json:"gender,omitempty"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Avatar string `protobuf:"bytes,4,opt,name=avatar,proto3" json:"avatar,omitempty"` + Country string `protobuf:"bytes,5,opt,name=country,proto3" json:"country,omitempty"` + Gender string `protobuf:"bytes,6,opt,name=gender,proto3" json:"gender,omitempty"` + InviteCode string `protobuf:"bytes,7,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"` } func (x *CompleteOnboardingRequest) Reset() { *x = CompleteOnboardingRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[16] + mi := &file_proto_user_v1_user_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1428,7 +1778,7 @@ func (x *CompleteOnboardingRequest) String() string { func (*CompleteOnboardingRequest) ProtoMessage() {} func (x *CompleteOnboardingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[16] + mi := &file_proto_user_v1_user_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1441,7 +1791,7 @@ func (x *CompleteOnboardingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CompleteOnboardingRequest.ProtoReflect.Descriptor instead. func (*CompleteOnboardingRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{16} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{21} } func (x *CompleteOnboardingRequest) GetMeta() *RequestMeta { @@ -1486,22 +1836,30 @@ func (x *CompleteOnboardingRequest) GetGender() string { return "" } +func (x *CompleteOnboardingRequest) GetInviteCode() string { + if x != nil { + return x.InviteCode + } + return "" +} + // CompleteOnboardingResponse 返回固化后的最小注册状态。 type CompleteOnboardingResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - ProfileCompleted bool `protobuf:"varint,2,opt,name=profile_completed,json=profileCompleted,proto3" json:"profile_completed,omitempty"` - ProfileCompletedAtMs int64 `protobuf:"varint,3,opt,name=profile_completed_at_ms,json=profileCompletedAtMs,proto3" json:"profile_completed_at_ms,omitempty"` - OnboardingStatus string `protobuf:"bytes,4,opt,name=onboarding_status,json=onboardingStatus,proto3" json:"onboarding_status,omitempty"` - Token *AuthToken `protobuf:"bytes,5,opt,name=token,proto3" json:"token,omitempty"` + User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + ProfileCompleted bool `protobuf:"varint,2,opt,name=profile_completed,json=profileCompleted,proto3" json:"profile_completed,omitempty"` + ProfileCompletedAtMs int64 `protobuf:"varint,3,opt,name=profile_completed_at_ms,json=profileCompletedAtMs,proto3" json:"profile_completed_at_ms,omitempty"` + OnboardingStatus string `protobuf:"bytes,4,opt,name=onboarding_status,json=onboardingStatus,proto3" json:"onboarding_status,omitempty"` + Token *AuthToken `protobuf:"bytes,5,opt,name=token,proto3" json:"token,omitempty"` + Invite *InviteBinding `protobuf:"bytes,6,opt,name=invite,proto3" json:"invite,omitempty"` } func (x *CompleteOnboardingResponse) Reset() { *x = CompleteOnboardingResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[17] + mi := &file_proto_user_v1_user_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1513,7 +1871,7 @@ func (x *CompleteOnboardingResponse) String() string { func (*CompleteOnboardingResponse) ProtoMessage() {} func (x *CompleteOnboardingResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[17] + mi := &file_proto_user_v1_user_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1526,7 +1884,7 @@ func (x *CompleteOnboardingResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CompleteOnboardingResponse.ProtoReflect.Descriptor instead. func (*CompleteOnboardingResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{17} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{22} } func (x *CompleteOnboardingResponse) GetUser() *User { @@ -1564,6 +1922,13 @@ func (x *CompleteOnboardingResponse) GetToken() *AuthToken { return nil } +func (x *CompleteOnboardingResponse) GetInvite() *InviteBinding { + if x != nil { + return x.Invite + } + return nil +} + // BindPushTokenRequest 绑定当前登录用户的系统推送 token。 // push token 只服务离线触达,不作为登录凭证或设备身份凭证。 type BindPushTokenRequest struct { @@ -1584,7 +1949,7 @@ type BindPushTokenRequest struct { func (x *BindPushTokenRequest) Reset() { *x = BindPushTokenRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[18] + mi := &file_proto_user_v1_user_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1596,7 +1961,7 @@ func (x *BindPushTokenRequest) String() string { func (*BindPushTokenRequest) ProtoMessage() {} func (x *BindPushTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[18] + mi := &file_proto_user_v1_user_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1609,7 +1974,7 @@ func (x *BindPushTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BindPushTokenRequest.ProtoReflect.Descriptor instead. func (*BindPushTokenRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{18} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{23} } func (x *BindPushTokenRequest) GetMeta() *RequestMeta { @@ -1686,7 +2051,7 @@ type BindPushTokenResponse struct { func (x *BindPushTokenResponse) Reset() { *x = BindPushTokenResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[19] + mi := &file_proto_user_v1_user_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1698,7 +2063,7 @@ func (x *BindPushTokenResponse) String() string { func (*BindPushTokenResponse) ProtoMessage() {} func (x *BindPushTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[19] + mi := &file_proto_user_v1_user_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1711,7 +2076,7 @@ func (x *BindPushTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BindPushTokenResponse.ProtoReflect.Descriptor instead. func (*BindPushTokenResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{19} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{24} } func (x *BindPushTokenResponse) GetBound() bool { @@ -1743,7 +2108,7 @@ type DeletePushTokenRequest struct { func (x *DeletePushTokenRequest) Reset() { *x = DeletePushTokenRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[20] + mi := &file_proto_user_v1_user_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1755,7 +2120,7 @@ func (x *DeletePushTokenRequest) String() string { func (*DeletePushTokenRequest) ProtoMessage() {} func (x *DeletePushTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[20] + mi := &file_proto_user_v1_user_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1768,7 +2133,7 @@ func (x *DeletePushTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePushTokenRequest.ProtoReflect.Descriptor instead. func (*DeletePushTokenRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{20} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{25} } func (x *DeletePushTokenRequest) GetMeta() *RequestMeta { @@ -1810,7 +2175,7 @@ type DeletePushTokenResponse struct { func (x *DeletePushTokenResponse) Reset() { *x = DeletePushTokenResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[21] + mi := &file_proto_user_v1_user_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1822,7 +2187,7 @@ func (x *DeletePushTokenResponse) String() string { func (*DeletePushTokenResponse) ProtoMessage() {} func (x *DeletePushTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[21] + mi := &file_proto_user_v1_user_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1835,7 +2200,7 @@ func (x *DeletePushTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePushTokenResponse.ProtoReflect.Descriptor instead. func (*DeletePushTokenResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{21} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{26} } func (x *DeletePushTokenResponse) GetDeleted() bool { @@ -1875,7 +2240,7 @@ type Country struct { func (x *Country) Reset() { *x = Country{} - mi := &file_proto_user_v1_user_proto_msgTypes[22] + mi := &file_proto_user_v1_user_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1887,7 +2252,7 @@ func (x *Country) String() string { func (*Country) ProtoMessage() {} func (x *Country) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[22] + mi := &file_proto_user_v1_user_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1900,7 +2265,7 @@ func (x *Country) ProtoReflect() protoreflect.Message { // Deprecated: Use Country.ProtoReflect.Descriptor instead. func (*Country) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{22} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{27} } func (x *Country) GetCountryId() int64 { @@ -2013,7 +2378,7 @@ type Region struct { func (x *Region) Reset() { *x = Region{} - mi := &file_proto_user_v1_user_proto_msgTypes[23] + mi := &file_proto_user_v1_user_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2025,7 +2390,7 @@ func (x *Region) String() string { func (*Region) ProtoMessage() {} func (x *Region) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[23] + mi := &file_proto_user_v1_user_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2038,7 +2403,7 @@ func (x *Region) ProtoReflect() protoreflect.Message { // Deprecated: Use Region.ProtoReflect.Descriptor instead. func (*Region) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{23} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{28} } func (x *Region) GetRegionId() int64 { @@ -2115,7 +2480,7 @@ type ListCountriesRequest struct { func (x *ListCountriesRequest) Reset() { *x = ListCountriesRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[24] + mi := &file_proto_user_v1_user_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2127,7 +2492,7 @@ func (x *ListCountriesRequest) String() string { func (*ListCountriesRequest) ProtoMessage() {} func (x *ListCountriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[24] + mi := &file_proto_user_v1_user_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2140,7 +2505,7 @@ func (x *ListCountriesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListCountriesRequest.ProtoReflect.Descriptor instead. func (*ListCountriesRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{24} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{29} } func (x *ListCountriesRequest) GetMeta() *RequestMeta { @@ -2167,7 +2532,7 @@ type ListCountriesResponse struct { func (x *ListCountriesResponse) Reset() { *x = ListCountriesResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[25] + mi := &file_proto_user_v1_user_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2179,7 +2544,7 @@ func (x *ListCountriesResponse) String() string { func (*ListCountriesResponse) ProtoMessage() {} func (x *ListCountriesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[25] + mi := &file_proto_user_v1_user_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2192,7 +2557,7 @@ func (x *ListCountriesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListCountriesResponse.ProtoReflect.Descriptor instead. func (*ListCountriesResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{25} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{30} } func (x *ListCountriesResponse) GetCountries() []*Country { @@ -2222,7 +2587,7 @@ type UpdateCountryRequest struct { func (x *UpdateCountryRequest) Reset() { *x = UpdateCountryRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[26] + mi := &file_proto_user_v1_user_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2234,7 +2599,7 @@ func (x *UpdateCountryRequest) String() string { func (*UpdateCountryRequest) ProtoMessage() {} func (x *UpdateCountryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[26] + mi := &file_proto_user_v1_user_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2247,7 +2612,7 @@ func (x *UpdateCountryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateCountryRequest.ProtoReflect.Descriptor instead. func (*UpdateCountryRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{26} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{31} } func (x *UpdateCountryRequest) GetMeta() *RequestMeta { @@ -2330,7 +2695,7 @@ type CountryResponse struct { func (x *CountryResponse) Reset() { *x = CountryResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[27] + mi := &file_proto_user_v1_user_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2342,7 +2707,7 @@ func (x *CountryResponse) String() string { func (*CountryResponse) ProtoMessage() {} func (x *CountryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[27] + mi := &file_proto_user_v1_user_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2355,7 +2720,7 @@ func (x *CountryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CountryResponse.ProtoReflect.Descriptor instead. func (*CountryResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{27} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{32} } func (x *CountryResponse) GetCountry() *Country { @@ -2375,7 +2740,7 @@ type ListRegistrationCountriesRequest struct { func (x *ListRegistrationCountriesRequest) Reset() { *x = ListRegistrationCountriesRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[28] + mi := &file_proto_user_v1_user_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2387,7 +2752,7 @@ func (x *ListRegistrationCountriesRequest) String() string { func (*ListRegistrationCountriesRequest) ProtoMessage() {} func (x *ListRegistrationCountriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[28] + mi := &file_proto_user_v1_user_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2400,7 +2765,7 @@ func (x *ListRegistrationCountriesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRegistrationCountriesRequest.ProtoReflect.Descriptor instead. func (*ListRegistrationCountriesRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{28} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{33} } func (x *ListRegistrationCountriesRequest) GetMeta() *RequestMeta { @@ -2420,7 +2785,7 @@ type ListRegistrationCountriesResponse struct { func (x *ListRegistrationCountriesResponse) Reset() { *x = ListRegistrationCountriesResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[29] + mi := &file_proto_user_v1_user_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2432,7 +2797,7 @@ func (x *ListRegistrationCountriesResponse) String() string { func (*ListRegistrationCountriesResponse) ProtoMessage() {} func (x *ListRegistrationCountriesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[29] + mi := &file_proto_user_v1_user_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2445,7 +2810,7 @@ func (x *ListRegistrationCountriesResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ListRegistrationCountriesResponse.ProtoReflect.Descriptor instead. func (*ListRegistrationCountriesResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{29} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{34} } func (x *ListRegistrationCountriesResponse) GetCountries() []*Country { @@ -2466,7 +2831,7 @@ type ListRegionsRequest struct { func (x *ListRegionsRequest) Reset() { *x = ListRegionsRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[30] + mi := &file_proto_user_v1_user_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2478,7 +2843,7 @@ func (x *ListRegionsRequest) String() string { func (*ListRegionsRequest) ProtoMessage() {} func (x *ListRegionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[30] + mi := &file_proto_user_v1_user_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2491,7 +2856,7 @@ func (x *ListRegionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRegionsRequest.ProtoReflect.Descriptor instead. func (*ListRegionsRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{30} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{35} } func (x *ListRegionsRequest) GetMeta() *RequestMeta { @@ -2518,7 +2883,7 @@ type ListRegionsResponse struct { func (x *ListRegionsResponse) Reset() { *x = ListRegionsResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[31] + mi := &file_proto_user_v1_user_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2530,7 +2895,7 @@ func (x *ListRegionsResponse) String() string { func (*ListRegionsResponse) ProtoMessage() {} func (x *ListRegionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[31] + mi := &file_proto_user_v1_user_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2543,7 +2908,7 @@ func (x *ListRegionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRegionsResponse.ProtoReflect.Descriptor instead. func (*ListRegionsResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{31} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{36} } func (x *ListRegionsResponse) GetRegions() []*Region { @@ -2564,7 +2929,7 @@ type GetRegionRequest struct { func (x *GetRegionRequest) Reset() { *x = GetRegionRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[32] + mi := &file_proto_user_v1_user_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2576,7 +2941,7 @@ func (x *GetRegionRequest) String() string { func (*GetRegionRequest) ProtoMessage() {} func (x *GetRegionRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[32] + mi := &file_proto_user_v1_user_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2589,7 +2954,7 @@ func (x *GetRegionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetRegionRequest.ProtoReflect.Descriptor instead. func (*GetRegionRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{32} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{37} } func (x *GetRegionRequest) GetMeta() *RequestMeta { @@ -2622,7 +2987,7 @@ type UpdateRegionRequest struct { func (x *UpdateRegionRequest) Reset() { *x = UpdateRegionRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[33] + mi := &file_proto_user_v1_user_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2634,7 +2999,7 @@ func (x *UpdateRegionRequest) String() string { func (*UpdateRegionRequest) ProtoMessage() {} func (x *UpdateRegionRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[33] + mi := &file_proto_user_v1_user_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2647,7 +3012,7 @@ func (x *UpdateRegionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateRegionRequest.ProtoReflect.Descriptor instead. func (*UpdateRegionRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{33} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{38} } func (x *UpdateRegionRequest) GetMeta() *RequestMeta { @@ -2705,7 +3070,7 @@ type ReplaceRegionCountriesRequest struct { func (x *ReplaceRegionCountriesRequest) Reset() { *x = ReplaceRegionCountriesRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[34] + mi := &file_proto_user_v1_user_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2717,7 +3082,7 @@ func (x *ReplaceRegionCountriesRequest) String() string { func (*ReplaceRegionCountriesRequest) ProtoMessage() {} func (x *ReplaceRegionCountriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[34] + mi := &file_proto_user_v1_user_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2730,7 +3095,7 @@ func (x *ReplaceRegionCountriesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReplaceRegionCountriesRequest.ProtoReflect.Descriptor instead. func (*ReplaceRegionCountriesRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{34} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{39} } func (x *ReplaceRegionCountriesRequest) GetMeta() *RequestMeta { @@ -2771,7 +3136,7 @@ type RegionResponse struct { func (x *RegionResponse) Reset() { *x = RegionResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[35] + mi := &file_proto_user_v1_user_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2783,7 +3148,7 @@ func (x *RegionResponse) String() string { func (*RegionResponse) ProtoMessage() {} func (x *RegionResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[35] + mi := &file_proto_user_v1_user_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2796,7 +3161,7 @@ func (x *RegionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RegionResponse.ProtoReflect.Descriptor instead. func (*RegionResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{35} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{40} } func (x *RegionResponse) GetRegion() *Region { @@ -2823,7 +3188,7 @@ type UserIdentity struct { func (x *UserIdentity) Reset() { *x = UserIdentity{} - mi := &file_proto_user_v1_user_proto_msgTypes[36] + mi := &file_proto_user_v1_user_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2835,7 +3200,7 @@ func (x *UserIdentity) String() string { func (*UserIdentity) ProtoMessage() {} func (x *UserIdentity) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[36] + mi := &file_proto_user_v1_user_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2848,7 +3213,7 @@ func (x *UserIdentity) ProtoReflect() protoreflect.Message { // Deprecated: Use UserIdentity.ProtoReflect.Descriptor instead. func (*UserIdentity) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{36} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{41} } func (x *UserIdentity) GetUserId() int64 { @@ -2912,7 +3277,7 @@ type GetUserIdentityRequest struct { func (x *GetUserIdentityRequest) Reset() { *x = GetUserIdentityRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[37] + mi := &file_proto_user_v1_user_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2924,7 +3289,7 @@ func (x *GetUserIdentityRequest) String() string { func (*GetUserIdentityRequest) ProtoMessage() {} func (x *GetUserIdentityRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[37] + mi := &file_proto_user_v1_user_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2937,7 +3302,7 @@ func (x *GetUserIdentityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetUserIdentityRequest.ProtoReflect.Descriptor instead. func (*GetUserIdentityRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{37} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{42} } func (x *GetUserIdentityRequest) GetMeta() *RequestMeta { @@ -2965,7 +3330,7 @@ type GetUserIdentityResponse struct { func (x *GetUserIdentityResponse) Reset() { *x = GetUserIdentityResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[38] + mi := &file_proto_user_v1_user_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2977,7 +3342,7 @@ func (x *GetUserIdentityResponse) String() string { func (*GetUserIdentityResponse) ProtoMessage() {} func (x *GetUserIdentityResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[38] + mi := &file_proto_user_v1_user_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2990,7 +3355,7 @@ func (x *GetUserIdentityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetUserIdentityResponse.ProtoReflect.Descriptor instead. func (*GetUserIdentityResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{38} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{43} } func (x *GetUserIdentityResponse) GetIdentity() *UserIdentity { @@ -3012,7 +3377,7 @@ type ResolveDisplayUserIDRequest struct { func (x *ResolveDisplayUserIDRequest) Reset() { *x = ResolveDisplayUserIDRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[39] + mi := &file_proto_user_v1_user_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3024,7 +3389,7 @@ func (x *ResolveDisplayUserIDRequest) String() string { func (*ResolveDisplayUserIDRequest) ProtoMessage() {} func (x *ResolveDisplayUserIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[39] + mi := &file_proto_user_v1_user_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3037,7 +3402,7 @@ func (x *ResolveDisplayUserIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResolveDisplayUserIDRequest.ProtoReflect.Descriptor instead. func (*ResolveDisplayUserIDRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{39} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{44} } func (x *ResolveDisplayUserIDRequest) GetMeta() *RequestMeta { @@ -3065,7 +3430,7 @@ type ResolveDisplayUserIDResponse struct { func (x *ResolveDisplayUserIDResponse) Reset() { *x = ResolveDisplayUserIDResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[40] + mi := &file_proto_user_v1_user_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3077,7 +3442,7 @@ func (x *ResolveDisplayUserIDResponse) String() string { func (*ResolveDisplayUserIDResponse) ProtoMessage() {} func (x *ResolveDisplayUserIDResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[40] + mi := &file_proto_user_v1_user_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3090,7 +3455,7 @@ func (x *ResolveDisplayUserIDResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResolveDisplayUserIDResponse.ProtoReflect.Descriptor instead. func (*ResolveDisplayUserIDResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{40} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{45} } func (x *ResolveDisplayUserIDResponse) GetIdentity() *UserIdentity { @@ -3115,7 +3480,7 @@ type ChangeDisplayUserIDRequest struct { func (x *ChangeDisplayUserIDRequest) Reset() { *x = ChangeDisplayUserIDRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[41] + mi := &file_proto_user_v1_user_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3127,7 +3492,7 @@ func (x *ChangeDisplayUserIDRequest) String() string { func (*ChangeDisplayUserIDRequest) ProtoMessage() {} func (x *ChangeDisplayUserIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[41] + mi := &file_proto_user_v1_user_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3140,7 +3505,7 @@ func (x *ChangeDisplayUserIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeDisplayUserIDRequest.ProtoReflect.Descriptor instead. func (*ChangeDisplayUserIDRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{41} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{46} } func (x *ChangeDisplayUserIDRequest) GetMeta() *RequestMeta { @@ -3189,7 +3554,7 @@ type ChangeDisplayUserIDResponse struct { func (x *ChangeDisplayUserIDResponse) Reset() { *x = ChangeDisplayUserIDResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[42] + mi := &file_proto_user_v1_user_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3201,7 +3566,7 @@ func (x *ChangeDisplayUserIDResponse) String() string { func (*ChangeDisplayUserIDResponse) ProtoMessage() {} func (x *ChangeDisplayUserIDResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[42] + mi := &file_proto_user_v1_user_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3214,7 +3579,7 @@ func (x *ChangeDisplayUserIDResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeDisplayUserIDResponse.ProtoReflect.Descriptor instead. func (*ChangeDisplayUserIDResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{42} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{47} } func (x *ChangeDisplayUserIDResponse) GetIdentity() *UserIdentity { @@ -3239,7 +3604,7 @@ type ApplyPrettyDisplayUserIDRequest struct { func (x *ApplyPrettyDisplayUserIDRequest) Reset() { *x = ApplyPrettyDisplayUserIDRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[43] + mi := &file_proto_user_v1_user_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3251,7 +3616,7 @@ func (x *ApplyPrettyDisplayUserIDRequest) String() string { func (*ApplyPrettyDisplayUserIDRequest) ProtoMessage() {} func (x *ApplyPrettyDisplayUserIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[43] + mi := &file_proto_user_v1_user_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3264,7 +3629,7 @@ func (x *ApplyPrettyDisplayUserIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyPrettyDisplayUserIDRequest.ProtoReflect.Descriptor instead. func (*ApplyPrettyDisplayUserIDRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{43} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{48} } func (x *ApplyPrettyDisplayUserIDRequest) GetMeta() *RequestMeta { @@ -3314,7 +3679,7 @@ type ApplyPrettyDisplayUserIDResponse struct { func (x *ApplyPrettyDisplayUserIDResponse) Reset() { *x = ApplyPrettyDisplayUserIDResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[44] + mi := &file_proto_user_v1_user_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3326,7 +3691,7 @@ func (x *ApplyPrettyDisplayUserIDResponse) String() string { func (*ApplyPrettyDisplayUserIDResponse) ProtoMessage() {} func (x *ApplyPrettyDisplayUserIDResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[44] + mi := &file_proto_user_v1_user_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3339,7 +3704,7 @@ func (x *ApplyPrettyDisplayUserIDResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyPrettyDisplayUserIDResponse.ProtoReflect.Descriptor instead. func (*ApplyPrettyDisplayUserIDResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{44} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{49} } func (x *ApplyPrettyDisplayUserIDResponse) GetIdentity() *UserIdentity { @@ -3369,7 +3734,7 @@ type ExpirePrettyDisplayUserIDRequest struct { func (x *ExpirePrettyDisplayUserIDRequest) Reset() { *x = ExpirePrettyDisplayUserIDRequest{} - mi := &file_proto_user_v1_user_proto_msgTypes[45] + mi := &file_proto_user_v1_user_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3381,7 +3746,7 @@ func (x *ExpirePrettyDisplayUserIDRequest) String() string { func (*ExpirePrettyDisplayUserIDRequest) ProtoMessage() {} func (x *ExpirePrettyDisplayUserIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[45] + mi := &file_proto_user_v1_user_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3394,7 +3759,7 @@ func (x *ExpirePrettyDisplayUserIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExpirePrettyDisplayUserIDRequest.ProtoReflect.Descriptor instead. func (*ExpirePrettyDisplayUserIDRequest) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{45} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{50} } func (x *ExpirePrettyDisplayUserIDRequest) GetMeta() *RequestMeta { @@ -3429,7 +3794,7 @@ type ExpirePrettyDisplayUserIDResponse struct { func (x *ExpirePrettyDisplayUserIDResponse) Reset() { *x = ExpirePrettyDisplayUserIDResponse{} - mi := &file_proto_user_v1_user_proto_msgTypes[46] + mi := &file_proto_user_v1_user_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3441,7 +3806,7 @@ func (x *ExpirePrettyDisplayUserIDResponse) String() string { func (*ExpirePrettyDisplayUserIDResponse) ProtoMessage() {} func (x *ExpirePrettyDisplayUserIDResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_user_v1_user_proto_msgTypes[46] + mi := &file_proto_user_v1_user_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3454,7 +3819,7 @@ func (x *ExpirePrettyDisplayUserIDResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ExpirePrettyDisplayUserIDResponse.ProtoReflect.Descriptor instead. func (*ExpirePrettyDisplayUserIDResponse) Descriptor() ([]byte, []int) { - return file_proto_user_v1_user_proto_rawDescGZIP(), []int{46} + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{51} } func (x *ExpirePrettyDisplayUserIDResponse) GetIdentity() *UserIdentity { @@ -3518,7 +3883,7 @@ var file_proto_user_v1_user_proto_rawDesc = []byte{ 0x65, 0x22, 0x3a, 0x0a, 0x12, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x41, 0x70, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x03, 0x61, 0x70, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, - 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x52, 0x03, 0x61, 0x70, 0x70, 0x22, 0xfa, 0x07, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x52, 0x03, 0x61, 0x70, 0x70, 0x22, 0xb1, 0x08, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, @@ -3582,7 +3947,64 @@ var file_proto_user_v1_user_proto_rawDesc = []byte{ 0x79, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x1b, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x96, 0x04, 0x0a, 0x09, 0x41, + 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x35, 0x0a, 0x06, 0x69, 0x6e, + 0x76, 0x69, 0x74, 0x65, 0x18, 0x1c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x68, 0x79, 0x61, + 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x76, 0x69, 0x74, + 0x65, 0x4f, 0x76, 0x65, 0x72, 0x76, 0x69, 0x65, 0x77, 0x52, 0x06, 0x69, 0x6e, 0x76, 0x69, 0x74, + 0x65, 0x22, 0xed, 0x01, 0x0a, 0x0e, 0x49, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x4f, 0x76, 0x65, 0x72, + 0x76, 0x69, 0x65, 0x77, 0x12, 0x24, 0x0a, 0x0e, 0x6d, 0x79, 0x5f, 0x69, 0x6e, 0x76, 0x69, 0x74, + 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x79, + 0x49, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x69, 0x6e, + 0x76, 0x69, 0x74, 0x65, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0d, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2c, 0x0a, 0x12, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x69, 0x6e, + 0x76, 0x69, 0x74, 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x10, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x49, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x12, 0x3d, 0x0a, 0x1b, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x69, + 0x74, 0x65, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x5f, 0x63, 0x6f, 0x69, + 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x18, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x49, 0x6e, + 0x76, 0x69, 0x74, 0x65, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x43, 0x6f, 0x69, + 0x6e, 0x22, 0x6e, 0x0a, 0x0d, 0x49, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, + 0x6e, 0x67, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x76, 0x69, + 0x74, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, + 0x6e, 0x76, 0x69, 0x74, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x6e, 0x76, + 0x69, 0x74, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0d, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x22, 0xad, 0x02, 0x0a, 0x14, 0x55, 0x73, 0x65, 0x72, 0x4d, 0x69, 0x63, 0x4c, 0x69, 0x66, + 0x65, 0x74, 0x69, 0x6d, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x65, 0x61, 0x74, 0x5f, 0x6f, 0x63, 0x63, 0x75, + 0x70, 0x69, 0x65, 0x64, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x73, + 0x65, 0x61, 0x74, 0x4f, 0x63, 0x63, 0x75, 0x70, 0x69, 0x65, 0x64, 0x4d, 0x73, 0x12, 0x22, 0x0a, + 0x0d, 0x6d, 0x69, 0x63, 0x5f, 0x6f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, 0x69, 0x63, 0x4f, 0x6e, 0x6c, 0x69, 0x6e, 0x65, 0x4d, + 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0f, 0x66, 0x69, 0x72, 0x73, 0x74, 0x5f, + 0x6d, 0x69, 0x63, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0c, 0x66, 0x69, 0x72, 0x73, 0x74, 0x4d, 0x69, 0x63, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x23, 0x0a, + 0x0e, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x69, 0x63, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x69, 0x63, 0x41, 0x74, + 0x4d, 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, + 0x64, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, + 0x65, 0x22, 0x69, 0x0a, 0x1e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x4d, 0x69, 0x63, 0x4c, + 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, + 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x5c, 0x0a, 0x1f, + 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x4d, 0x69, 0x63, 0x4c, 0x69, 0x66, 0x65, 0x74, 0x69, + 0x6d, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x39, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, + 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, + 0x73, 0x65, 0x72, 0x4d, 0x69, 0x63, 0x4c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x53, 0x74, + 0x61, 0x74, 0x73, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x73, 0x22, 0x96, 0x04, 0x0a, 0x09, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, @@ -3701,7 +4123,7 @@ var file_proto_user_v1_user_proto_rawDesc = []byte{ 0x78, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x6e, 0x65, 0x78, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, - 0x41, 0x74, 0x4d, 0x73, 0x22, 0xca, 0x01, 0x0a, 0x19, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, + 0x41, 0x74, 0x4d, 0x73, 0x22, 0xeb, 0x01, 0x0a, 0x19, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, @@ -3714,456 +4136,469 @@ var file_proto_user_v1_user_proto_rawDesc = []byte{ 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, - 0x72, 0x22, 0x86, 0x02, 0x0a, 0x1a, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x6e, - 0x62, 0x6f, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, - 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, - 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x70, 0x72, 0x6f, - 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, - 0x65, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, - 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, - 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x2b, 0x0a, - 0x11, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, - 0x64, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2e, 0x0a, 0x05, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x68, 0x79, 0x61, 0x70, - 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xac, 0x02, 0x0a, 0x14, 0x42, - 0x69, 0x6e, 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, - 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, - 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x73, - 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, - 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, - 0x69, 0x64, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, - 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x70, 0x70, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x70, 0x70, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, 0x08, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x12, 0x1a, 0x0a, - 0x08, 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x22, 0x51, 0x0a, 0x15, 0x42, 0x69, 0x6e, - 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0x9d, 0x01, 0x0a, - 0x16, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, - 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, - 0x0a, 0x70, 0x75, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x70, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x57, 0x0a, 0x17, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, - 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0xbe, 0x03, 0x0a, 0x07, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, - 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x49, 0x64, - 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x4e, - 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63, - 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x30, 0x0a, 0x14, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, - 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x44, 0x69, 0x73, - 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6f, 0x72, 0x74, - 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x73, 0x6f, - 0x72, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x22, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, - 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x75, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, - 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x6f, 0x5f, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x33, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x69, 0x73, 0x6f, 0x41, 0x6c, 0x70, 0x68, 0x61, 0x33, 0x12, 0x1f, - 0x0a, 0x0b, 0x69, 0x73, 0x6f, 0x5f, 0x6e, 0x75, 0x6d, 0x65, 0x72, 0x69, 0x63, 0x18, 0x0a, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x73, 0x6f, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x69, 0x63, 0x12, - 0x2c, 0x0a, 0x12, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, - 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x68, 0x6f, - 0x6e, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, - 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x6c, 0x61, 0x67, 0x18, - 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x6c, 0x61, 0x67, 0x12, 0x19, 0x0a, 0x08, 0x61, - 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, - 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x92, 0x02, 0x0a, 0x06, 0x52, 0x65, 0x67, 0x69, 0x6f, - 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1f, - 0x0a, 0x0b, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x12, - 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x63, - 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6f, 0x72, - 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x73, - 0x6f, 0x72, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x22, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x22, 0x0a, 0x0d, - 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x08, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, - 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x71, 0x0a, 0x14, 0x4c, - 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, - 0x65, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x88, - 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x4d, - 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x68, 0x79, 0x61, - 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, - 0x72, 0x79, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x22, 0x85, 0x03, - 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, - 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, - 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, - 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x72, 0x79, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x72, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x30, 0x0a, 0x14, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x72, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x44, - 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6f, - 0x72, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, - 0x73, 0x6f, 0x72, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x70, 0x65, - 0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x0e, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, - 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x6f, 0x5f, 0x61, 0x6c, 0x70, 0x68, 0x61, - 0x33, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x69, 0x73, 0x6f, 0x41, 0x6c, 0x70, 0x68, - 0x61, 0x33, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x73, 0x6f, 0x5f, 0x6e, 0x75, 0x6d, 0x65, 0x72, 0x69, - 0x63, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x73, 0x6f, 0x4e, 0x75, 0x6d, 0x65, - 0x72, 0x69, 0x63, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x10, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x43, 0x6f, 0x64, - 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x6c, 0x61, 0x67, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x66, 0x6c, 0x61, 0x67, 0x22, 0x43, 0x0a, 0x0f, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x68, 0x79, 0x61, 0x70, - 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x22, 0x52, 0x0a, 0x20, 0x4c, 0x69, - 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, - 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, - 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, - 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x59, - 0x0a, 0x21, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, - 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x22, 0x5c, 0x0a, 0x12, 0x4c, 0x69, 0x73, - 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, - 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, - 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x46, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x52, - 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, - 0x0a, 0x07, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x15, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, - 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x22, - 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, - 0x65, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, - 0x22, 0xe0, 0x01, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, - 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, - 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x67, 0x69, - 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x72, 0x65, 0x67, - 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, - 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x67, 0x69, - 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6f, - 0x72, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, - 0x73, 0x6f, 0x72, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x70, 0x65, - 0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x03, 0x52, 0x0e, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, - 0x72, 0x49, 0x64, 0x22, 0xb4, 0x01, 0x0a, 0x1d, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x52, - 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, - 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, - 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, - 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, - 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, - 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x6f, 0x70, 0x65, 0x72, - 0x61, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x3f, 0x0a, 0x0e, 0x52, 0x65, - 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x06, - 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x68, - 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, - 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x22, 0xab, 0x02, 0x0a, 0x0c, - 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x17, 0x0a, 0x07, - 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, - 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, - 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, - 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, - 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x35, 0x0a, 0x17, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, - 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x44, - 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x14, - 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, - 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x64, 0x69, 0x73, 0x70, - 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x3f, 0x0a, - 0x1d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x18, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, - 0x72, 0x49, 0x64, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x19, - 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x61, 0x0a, 0x16, 0x47, 0x65, 0x74, - 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, - 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x52, 0x0a, 0x17, - 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, - 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x22, 0x75, 0x0a, 0x1b, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, - 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, - 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, - 0x26, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, - 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x57, 0x0a, 0x1c, 0x52, 0x65, 0x73, 0x6f, 0x6c, - 0x76, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, - 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, - 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x22, 0xd6, 0x01, 0x0a, 0x1a, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, - 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, - 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, - 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2d, 0x0a, 0x13, 0x6e, 0x65, 0x77, 0x5f, - 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x6e, 0x65, 0x77, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, - 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, - 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, - 0x28, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, - 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x6f, 0x70, 0x65, 0x72, 0x61, - 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x56, 0x0a, 0x1b, 0x43, 0x68, 0x61, - 0x6e, 0x67, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x08, 0x69, 0x64, 0x65, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x68, 0x79, 0x61, - 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x22, 0xf9, 0x01, 0x0a, 0x1f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x50, 0x72, 0x65, 0x74, 0x74, - 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, - 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x33, - 0x0a, 0x16, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, - 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, - 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, - 0x72, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x5f, 0x64, 0x75, 0x72, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, - 0x6c, 0x65, 0x61, 0x73, 0x65, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, - 0x2c, 0x0a, 0x12, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, - 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x61, 0x79, - 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64, 0x22, 0x76, 0x0a, - 0x20, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, - 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x37, 0x0a, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, - 0x52, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x65, - 0x61, 0x73, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x65, - 0x61, 0x73, 0x65, 0x49, 0x64, 0x22, 0x86, 0x01, 0x0a, 0x20, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, - 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, - 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, + 0x72, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x43, 0x6f, + 0x64, 0x65, 0x22, 0xbc, 0x02, 0x0a, 0x1a, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, + 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x13, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, + 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x70, 0x72, + 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x43, 0x6f, + 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x70, 0x72, 0x6f, 0x66, 0x69, + 0x6c, 0x65, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, + 0x6d, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, + 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x2b, + 0x0a, 0x11, 0x6f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x6f, 0x6e, 0x62, 0x6f, 0x61, + 0x72, 0x64, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2e, 0x0a, 0x05, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x68, 0x79, 0x61, + 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x06, 0x69, + 0x6e, 0x76, 0x69, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x68, 0x79, + 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x76, 0x69, + 0x74, 0x65, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x06, 0x69, 0x6e, 0x76, 0x69, 0x74, + 0x65, 0x22, 0xac, 0x02, 0x0a, 0x14, 0x42, 0x69, 0x6e, 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, - 0x72, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x5f, 0x69, 0x64, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x49, 0x64, 0x22, 0x5c, - 0x0a, 0x21, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, - 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x52, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x2a, 0x73, 0x0a, 0x0a, - 0x55, 0x73, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1b, 0x0a, 0x17, 0x55, 0x53, - 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x55, 0x53, 0x45, 0x52, 0x5f, - 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, - 0x18, 0x0a, 0x14, 0x55, 0x53, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x44, - 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x02, 0x12, 0x16, 0x0a, 0x12, 0x55, 0x53, 0x45, - 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x42, 0x41, 0x4e, 0x4e, 0x45, 0x44, 0x10, - 0x03, 0x32, 0xc4, 0x04, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x48, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x1d, 0x2e, 0x68, - 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, - 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x68, 0x79, - 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, - 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0d, 0x42, - 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x23, 0x2e, 0x68, - 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, - 0x63, 0x68, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x55, - 0x73, 0x65, 0x72, 0x49, 0x44, 0x73, 0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, - 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x44, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, - 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, - 0x65, 0x72, 0x49, 0x44, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x66, 0x0a, - 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x66, 0x69, - 0x6c, 0x65, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x6f, - 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x68, 0x79, - 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x66, 0x0a, 0x11, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x55, - 0x73, 0x65, 0x72, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61, - 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, - 0x65, 0x55, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x55, 0x73, 0x65, 0x72, 0x43, 0x6f, - 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, - 0x12, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, - 0x69, 0x6e, 0x67, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x6e, 0x62, 0x6f, - 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, - 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, - 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xd1, 0x01, 0x0a, 0x11, 0x55, 0x73, 0x65, - 0x72, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5a, - 0x0a, 0x0d, 0x42, 0x69, 0x6e, 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, - 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, - 0x42, 0x69, 0x6e, 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, - 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x69, 0x6e, 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x60, 0x0a, 0x0f, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x25, 0x2e, - 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, - 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, 0x73, 0x68, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x67, 0x0a, 0x12, - 0x41, 0x70, 0x70, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x12, 0x51, 0x0a, 0x0a, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x41, 0x70, 0x70, - 0x12, 0x20, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, - 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x41, 0x70, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x41, 0x70, 0x70, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xc7, 0x01, 0x0a, 0x13, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, - 0x79, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5a, 0x0a, - 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x23, - 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, - 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0d, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, - 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x1e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, - 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, - 0x95, 0x01, 0x0a, 0x13, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x51, 0x75, 0x65, 0x72, 0x79, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x7e, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x52, - 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, - 0x72, 0x69, 0x65, 0x73, 0x12, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, - 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, + 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, + 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, + 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x70, + 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, + 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x70, 0x70, 0x5f, 0x76, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x70, + 0x70, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, + 0x75, 0x61, 0x67, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, + 0x75, 0x61, 0x67, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, + 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, + 0x22, 0x51, 0x0a, 0x15, 0x42, 0x69, 0x6e, 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6f, 0x75, + 0x6e, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x12, + 0x22, 0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, + 0x74, 0x4d, 0x73, 0x22, 0x9d, 0x01, 0x0a, 0x16, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, + 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, + 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, + 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, + 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, + 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x75, 0x73, 0x68, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x22, 0x57, 0x0a, 0x17, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, 0x73, + 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, + 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0xbe, 0x03, 0x0a, + 0x07, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x72, 0x79, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x72, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x30, 0x0a, + 0x14, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, + 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x72, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x1d, 0x0a, 0x0a, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x09, 0x73, 0x6f, 0x72, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x22, + 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, + 0x4d, 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x5f, 0x6d, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, 0x6f, 0x5f, 0x61, 0x6c, + 0x70, 0x68, 0x61, 0x33, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x69, 0x73, 0x6f, 0x41, + 0x6c, 0x70, 0x68, 0x61, 0x33, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x73, 0x6f, 0x5f, 0x6e, 0x75, 0x6d, + 0x65, 0x72, 0x69, 0x63, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x73, 0x6f, 0x4e, + 0x75, 0x6d, 0x65, 0x72, 0x69, 0x63, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0b, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x10, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, + 0x43, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, + 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x66, 0x6c, 0x61, 0x67, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x6c, + 0x61, 0x67, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x0e, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x92, 0x02, + 0x0a, 0x06, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x67, 0x69, + 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x72, 0x65, 0x67, + 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, + 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x67, 0x69, + 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, + 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, + 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x73, 0x6f, 0x72, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, + 0x22, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, + 0x74, 0x4d, 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, + 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, + 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, + 0x64, 0x65, 0x22, 0x71, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, + 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, + 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, + 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x07, 0x65, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x07, 0x65, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x65, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x4d, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, + 0x0a, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x16, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x72, 0x69, 0x65, 0x73, 0x22, 0x85, 0x03, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, + 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, + 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1d, 0x0a, + 0x0a, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x30, 0x0a, 0x14, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, + 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, + 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x73, 0x6f, 0x72, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x6f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x73, + 0x6f, 0x5f, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x33, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x69, 0x73, 0x6f, 0x41, 0x6c, 0x70, 0x68, 0x61, 0x33, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x73, 0x6f, + 0x5f, 0x6e, 0x75, 0x6d, 0x65, 0x72, 0x69, 0x63, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, + 0x69, 0x73, 0x6f, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x69, 0x63, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x68, + 0x6f, 0x6e, 0x65, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, + 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x6c, 0x61, 0x67, + 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x6c, 0x61, 0x67, 0x22, 0x43, 0x0a, 0x0f, + 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x30, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x16, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, + 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, + 0x79, 0x22, 0x52, 0x0a, 0x20, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, + 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x59, 0x0a, 0x21, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, + 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, + 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x34, 0x0a, 0x09, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, + 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, + 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, + 0x22, 0x5c, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, + 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x46, + 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x07, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, + 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x72, + 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, + 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, + 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, + 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x72, + 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0xe0, 0x01, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, + 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x08, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, + 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x73, 0x6f, 0x72, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, + 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x6f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xb4, 0x01, 0x0a, 0x1d, 0x52, + 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, + 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, + 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1b, 0x0a, 0x09, + 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x08, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, + 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0e, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x22, 0x3f, 0x0a, 0x0e, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x2d, 0x0a, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x72, 0x65, 0x67, 0x69, + 0x6f, 0x6e, 0x22, 0xab, 0x02, 0x0a, 0x0c, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, + 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, + 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x35, 0x0a, 0x17, + 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x64, + 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x14, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, + 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x11, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x3f, 0x0a, 0x1d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, + 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x18, 0x64, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, + 0x73, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, + 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, + 0x22, 0x61, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, + 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, + 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x22, 0x52, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, + 0x0a, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, + 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, 0x69, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0x75, 0x0a, 0x1b, 0x52, 0x65, 0x73, 0x6f, 0x6c, + 0x76, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, + 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x26, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, + 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x57, + 0x0a, 0x1c, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, + 0x0a, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, + 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, 0x69, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xd6, 0x01, 0x0a, 0x1a, 0x43, 0x68, 0x61, 0x6e, + 0x67, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, + 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x2d, 0x0a, 0x13, 0x6e, 0x65, 0x77, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, + 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x6e, 0x65, + 0x77, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, + 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, + 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0e, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x22, 0x56, 0x0a, 0x1b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, + 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x37, 0x0a, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, + 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x22, 0xf9, 0x01, 0x0a, 0x1f, 0x41, 0x70, 0x70, + 0x6c, 0x79, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, + 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, + 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, + 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, + 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x33, 0x0a, 0x16, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, + 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x6c, 0x65, + 0x61, 0x73, 0x65, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x44, 0x75, 0x72, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, + 0x74, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x10, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x63, 0x65, 0x69, + 0x70, 0x74, 0x49, 0x64, 0x22, 0x76, 0x0a, 0x20, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x50, 0x72, 0x65, + 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x08, 0x69, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x68, 0x79, 0x61, + 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, + 0x79, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x65, 0x61, 0x73, 0x65, 0x49, 0x64, 0x22, 0x86, 0x01, 0x0a, + 0x20, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, + 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x65, + 0x61, 0x73, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x65, + 0x61, 0x73, 0x65, 0x49, 0x64, 0x22, 0x5c, 0x0a, 0x21, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x50, + 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, + 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x08, 0x69, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x68, + 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x08, 0x69, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x74, 0x79, 0x2a, 0x73, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x12, 0x1b, 0x0a, 0x17, 0x55, 0x53, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, + 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x16, + 0x0a, 0x12, 0x55, 0x53, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, + 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x53, 0x45, 0x52, 0x5f, 0x53, + 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x02, + 0x12, 0x16, 0x0a, 0x12, 0x55, 0x53, 0x45, 0x52, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, + 0x42, 0x41, 0x4e, 0x4e, 0x45, 0x44, 0x10, 0x03, 0x32, 0xbe, 0x05, 0x0a, 0x0b, 0x55, 0x73, 0x65, + 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x48, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x55, + 0x73, 0x65, 0x72, 0x12, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0d, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x55, 0x73, + 0x65, 0x72, 0x73, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, + 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, + 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, + 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x73, 0x12, 0x21, 0x2e, + 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, + 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x78, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x4d, + 0x69, 0x63, 0x4c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, + 0x2d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, + 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x4d, 0x69, 0x63, 0x4c, 0x69, 0x66, 0x65, 0x74, 0x69, + 0x6d, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, + 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, + 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x4d, 0x69, 0x63, 0x4c, 0x69, 0x66, 0x65, 0x74, 0x69, 0x6d, + 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x66, + 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x66, + 0x69, 0x6c, 0x65, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, + 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x68, + 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x66, 0x0a, 0x11, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x55, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x27, 0x2e, 0x68, 0x79, + 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x61, 0x6e, + 0x67, 0x65, 0x55, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x55, 0x73, 0x65, 0x72, 0x43, + 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, + 0x0a, 0x12, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x6e, 0x62, 0x6f, 0x61, 0x72, + 0x64, 0x69, 0x6e, 0x67, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x6e, 0x62, + 0x6f, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, + 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x69, 0x6e, + 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xd1, 0x01, 0x0a, 0x11, 0x55, 0x73, + 0x65, 0x72, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, + 0x5a, 0x0a, 0x0d, 0x42, 0x69, 0x6e, 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, + 0x2e, 0x42, 0x69, 0x6e, 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x69, 0x6e, 0x64, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x60, 0x0a, 0x0f, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x25, + 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x75, 0x73, 0x68, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x67, 0x0a, + 0x12, 0x41, 0x70, 0x70, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x79, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x0a, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x41, 0x70, + 0x70, 0x12, 0x20, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x41, 0x70, 0x70, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x41, 0x70, 0x70, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xc7, 0x01, 0x0a, 0x13, 0x43, 0x6f, 0x75, 0x6e, 0x74, + 0x72, 0x79, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5a, + 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, + 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, + 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, + 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0d, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x23, 0x2e, 0x68, 0x79, + 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, + 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x32, 0x95, 0x01, 0x0a, 0x13, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x51, 0x75, 0x65, 0x72, + 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x7e, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, + 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, + 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xf1, 0x02, 0x0a, 0x12, 0x52, 0x65, 0x67, 0x69, - 0x6f, 0x6e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x54, - 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x21, 0x2e, - 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, - 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, - 0x6e, 0x12, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, - 0x6e, 0x12, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x16, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x52, - 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x2c, - 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, - 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, - 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, - 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, - 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xd3, 0x04, 0x0a, 0x13, - 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x12, 0x60, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x25, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, - 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, - 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, - 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6f, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, - 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x12, 0x2a, 0x2e, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, + 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, + 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xf1, 0x02, 0x0a, 0x12, 0x52, 0x65, 0x67, + 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, + 0x54, 0x0a, 0x0b, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x21, + 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, + 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, + 0x6f, 0x6e, 0x12, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, + 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, + 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x16, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, + 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, + 0x2c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, + 0x52, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x75, + 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, - 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, - 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6c, 0x0a, 0x13, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, - 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x12, 0x29, 0x2e, - 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, - 0x61, 0x6e, 0x67, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, - 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x44, - 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7b, 0x0a, 0x18, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x50, 0x72, 0x65, - 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, - 0x12, 0x2e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, - 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, - 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, - 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, - 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x7e, 0x0a, 0x19, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x50, 0x72, 0x65, 0x74, 0x74, - 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x12, 0x2f, - 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x45, - 0x78, 0x70, 0x69, 0x72, 0x65, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, - 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, + 0x67, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xd3, 0x04, 0x0a, + 0x13, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x12, 0x60, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x25, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, + 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, + 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, + 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6f, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, + 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x12, 0x2a, + 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, + 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x68, 0x79, 0x61, + 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x6c, + 0x76, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6c, 0x0a, 0x13, 0x43, 0x68, 0x61, 0x6e, 0x67, + 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x12, 0x29, + 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, + 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, + 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7b, 0x0a, 0x18, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x50, 0x72, + 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, + 0x44, 0x12, 0x2e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x7e, 0x0a, 0x19, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x50, 0x72, 0x65, 0x74, + 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x12, + 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, 0x70, - 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x42, 0x26, 0x5a, 0x24, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, - 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2f, - 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, + 0x2e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, + 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, + 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( @@ -4179,7 +4614,7 @@ func file_proto_user_v1_user_proto_rawDescGZIP() []byte { } var file_proto_user_v1_user_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 48) +var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 53) var file_proto_user_v1_user_proto_goTypes = []any{ (UserStatus)(0), // 0: hyapp.user.v1.UserStatus (*RequestMeta)(nil), // 1: hyapp.user.v1.RequestMeta @@ -4187,138 +4622,149 @@ var file_proto_user_v1_user_proto_goTypes = []any{ (*ResolveAppRequest)(nil), // 3: hyapp.user.v1.ResolveAppRequest (*ResolveAppResponse)(nil), // 4: hyapp.user.v1.ResolveAppResponse (*User)(nil), // 5: hyapp.user.v1.User - (*AuthToken)(nil), // 6: hyapp.user.v1.AuthToken - (*GetUserRequest)(nil), // 7: hyapp.user.v1.GetUserRequest - (*GetUserResponse)(nil), // 8: hyapp.user.v1.GetUserResponse - (*BatchGetUsersRequest)(nil), // 9: hyapp.user.v1.BatchGetUsersRequest - (*BatchGetUsersResponse)(nil), // 10: hyapp.user.v1.BatchGetUsersResponse - (*ListUserIDsRequest)(nil), // 11: hyapp.user.v1.ListUserIDsRequest - (*ListUserIDsResponse)(nil), // 12: hyapp.user.v1.ListUserIDsResponse - (*UpdateUserProfileRequest)(nil), // 13: hyapp.user.v1.UpdateUserProfileRequest - (*UpdateUserProfileResponse)(nil), // 14: hyapp.user.v1.UpdateUserProfileResponse - (*ChangeUserCountryRequest)(nil), // 15: hyapp.user.v1.ChangeUserCountryRequest - (*ChangeUserCountryResponse)(nil), // 16: hyapp.user.v1.ChangeUserCountryResponse - (*CompleteOnboardingRequest)(nil), // 17: hyapp.user.v1.CompleteOnboardingRequest - (*CompleteOnboardingResponse)(nil), // 18: hyapp.user.v1.CompleteOnboardingResponse - (*BindPushTokenRequest)(nil), // 19: hyapp.user.v1.BindPushTokenRequest - (*BindPushTokenResponse)(nil), // 20: hyapp.user.v1.BindPushTokenResponse - (*DeletePushTokenRequest)(nil), // 21: hyapp.user.v1.DeletePushTokenRequest - (*DeletePushTokenResponse)(nil), // 22: hyapp.user.v1.DeletePushTokenResponse - (*Country)(nil), // 23: hyapp.user.v1.Country - (*Region)(nil), // 24: hyapp.user.v1.Region - (*ListCountriesRequest)(nil), // 25: hyapp.user.v1.ListCountriesRequest - (*ListCountriesResponse)(nil), // 26: hyapp.user.v1.ListCountriesResponse - (*UpdateCountryRequest)(nil), // 27: hyapp.user.v1.UpdateCountryRequest - (*CountryResponse)(nil), // 28: hyapp.user.v1.CountryResponse - (*ListRegistrationCountriesRequest)(nil), // 29: hyapp.user.v1.ListRegistrationCountriesRequest - (*ListRegistrationCountriesResponse)(nil), // 30: hyapp.user.v1.ListRegistrationCountriesResponse - (*ListRegionsRequest)(nil), // 31: hyapp.user.v1.ListRegionsRequest - (*ListRegionsResponse)(nil), // 32: hyapp.user.v1.ListRegionsResponse - (*GetRegionRequest)(nil), // 33: hyapp.user.v1.GetRegionRequest - (*UpdateRegionRequest)(nil), // 34: hyapp.user.v1.UpdateRegionRequest - (*ReplaceRegionCountriesRequest)(nil), // 35: hyapp.user.v1.ReplaceRegionCountriesRequest - (*RegionResponse)(nil), // 36: hyapp.user.v1.RegionResponse - (*UserIdentity)(nil), // 37: hyapp.user.v1.UserIdentity - (*GetUserIdentityRequest)(nil), // 38: hyapp.user.v1.GetUserIdentityRequest - (*GetUserIdentityResponse)(nil), // 39: hyapp.user.v1.GetUserIdentityResponse - (*ResolveDisplayUserIDRequest)(nil), // 40: hyapp.user.v1.ResolveDisplayUserIDRequest - (*ResolveDisplayUserIDResponse)(nil), // 41: hyapp.user.v1.ResolveDisplayUserIDResponse - (*ChangeDisplayUserIDRequest)(nil), // 42: hyapp.user.v1.ChangeDisplayUserIDRequest - (*ChangeDisplayUserIDResponse)(nil), // 43: hyapp.user.v1.ChangeDisplayUserIDResponse - (*ApplyPrettyDisplayUserIDRequest)(nil), // 44: hyapp.user.v1.ApplyPrettyDisplayUserIDRequest - (*ApplyPrettyDisplayUserIDResponse)(nil), // 45: hyapp.user.v1.ApplyPrettyDisplayUserIDResponse - (*ExpirePrettyDisplayUserIDRequest)(nil), // 46: hyapp.user.v1.ExpirePrettyDisplayUserIDRequest - (*ExpirePrettyDisplayUserIDResponse)(nil), // 47: hyapp.user.v1.ExpirePrettyDisplayUserIDResponse - nil, // 48: hyapp.user.v1.BatchGetUsersResponse.UsersEntry + (*InviteOverview)(nil), // 6: hyapp.user.v1.InviteOverview + (*InviteBinding)(nil), // 7: hyapp.user.v1.InviteBinding + (*UserMicLifetimeStats)(nil), // 8: hyapp.user.v1.UserMicLifetimeStats + (*GetUserMicLifetimeStatsRequest)(nil), // 9: hyapp.user.v1.GetUserMicLifetimeStatsRequest + (*GetUserMicLifetimeStatsResponse)(nil), // 10: hyapp.user.v1.GetUserMicLifetimeStatsResponse + (*AuthToken)(nil), // 11: hyapp.user.v1.AuthToken + (*GetUserRequest)(nil), // 12: hyapp.user.v1.GetUserRequest + (*GetUserResponse)(nil), // 13: hyapp.user.v1.GetUserResponse + (*BatchGetUsersRequest)(nil), // 14: hyapp.user.v1.BatchGetUsersRequest + (*BatchGetUsersResponse)(nil), // 15: hyapp.user.v1.BatchGetUsersResponse + (*ListUserIDsRequest)(nil), // 16: hyapp.user.v1.ListUserIDsRequest + (*ListUserIDsResponse)(nil), // 17: hyapp.user.v1.ListUserIDsResponse + (*UpdateUserProfileRequest)(nil), // 18: hyapp.user.v1.UpdateUserProfileRequest + (*UpdateUserProfileResponse)(nil), // 19: hyapp.user.v1.UpdateUserProfileResponse + (*ChangeUserCountryRequest)(nil), // 20: hyapp.user.v1.ChangeUserCountryRequest + (*ChangeUserCountryResponse)(nil), // 21: hyapp.user.v1.ChangeUserCountryResponse + (*CompleteOnboardingRequest)(nil), // 22: hyapp.user.v1.CompleteOnboardingRequest + (*CompleteOnboardingResponse)(nil), // 23: hyapp.user.v1.CompleteOnboardingResponse + (*BindPushTokenRequest)(nil), // 24: hyapp.user.v1.BindPushTokenRequest + (*BindPushTokenResponse)(nil), // 25: hyapp.user.v1.BindPushTokenResponse + (*DeletePushTokenRequest)(nil), // 26: hyapp.user.v1.DeletePushTokenRequest + (*DeletePushTokenResponse)(nil), // 27: hyapp.user.v1.DeletePushTokenResponse + (*Country)(nil), // 28: hyapp.user.v1.Country + (*Region)(nil), // 29: hyapp.user.v1.Region + (*ListCountriesRequest)(nil), // 30: hyapp.user.v1.ListCountriesRequest + (*ListCountriesResponse)(nil), // 31: hyapp.user.v1.ListCountriesResponse + (*UpdateCountryRequest)(nil), // 32: hyapp.user.v1.UpdateCountryRequest + (*CountryResponse)(nil), // 33: hyapp.user.v1.CountryResponse + (*ListRegistrationCountriesRequest)(nil), // 34: hyapp.user.v1.ListRegistrationCountriesRequest + (*ListRegistrationCountriesResponse)(nil), // 35: hyapp.user.v1.ListRegistrationCountriesResponse + (*ListRegionsRequest)(nil), // 36: hyapp.user.v1.ListRegionsRequest + (*ListRegionsResponse)(nil), // 37: hyapp.user.v1.ListRegionsResponse + (*GetRegionRequest)(nil), // 38: hyapp.user.v1.GetRegionRequest + (*UpdateRegionRequest)(nil), // 39: hyapp.user.v1.UpdateRegionRequest + (*ReplaceRegionCountriesRequest)(nil), // 40: hyapp.user.v1.ReplaceRegionCountriesRequest + (*RegionResponse)(nil), // 41: hyapp.user.v1.RegionResponse + (*UserIdentity)(nil), // 42: hyapp.user.v1.UserIdentity + (*GetUserIdentityRequest)(nil), // 43: hyapp.user.v1.GetUserIdentityRequest + (*GetUserIdentityResponse)(nil), // 44: hyapp.user.v1.GetUserIdentityResponse + (*ResolveDisplayUserIDRequest)(nil), // 45: hyapp.user.v1.ResolveDisplayUserIDRequest + (*ResolveDisplayUserIDResponse)(nil), // 46: hyapp.user.v1.ResolveDisplayUserIDResponse + (*ChangeDisplayUserIDRequest)(nil), // 47: hyapp.user.v1.ChangeDisplayUserIDRequest + (*ChangeDisplayUserIDResponse)(nil), // 48: hyapp.user.v1.ChangeDisplayUserIDResponse + (*ApplyPrettyDisplayUserIDRequest)(nil), // 49: hyapp.user.v1.ApplyPrettyDisplayUserIDRequest + (*ApplyPrettyDisplayUserIDResponse)(nil), // 50: hyapp.user.v1.ApplyPrettyDisplayUserIDResponse + (*ExpirePrettyDisplayUserIDRequest)(nil), // 51: hyapp.user.v1.ExpirePrettyDisplayUserIDRequest + (*ExpirePrettyDisplayUserIDResponse)(nil), // 52: hyapp.user.v1.ExpirePrettyDisplayUserIDResponse + nil, // 53: hyapp.user.v1.BatchGetUsersResponse.UsersEntry } var file_proto_user_v1_user_proto_depIdxs = []int32{ 1, // 0: hyapp.user.v1.ResolveAppRequest.meta:type_name -> hyapp.user.v1.RequestMeta 2, // 1: hyapp.user.v1.ResolveAppResponse.app:type_name -> hyapp.user.v1.App 0, // 2: hyapp.user.v1.User.status:type_name -> hyapp.user.v1.UserStatus - 1, // 3: hyapp.user.v1.GetUserRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 5, // 4: hyapp.user.v1.GetUserResponse.user:type_name -> hyapp.user.v1.User - 1, // 5: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 48, // 6: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry - 1, // 7: hyapp.user.v1.ListUserIDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 8: hyapp.user.v1.UpdateUserProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 5, // 9: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User - 1, // 10: hyapp.user.v1.ChangeUserCountryRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 5, // 11: hyapp.user.v1.ChangeUserCountryResponse.user:type_name -> hyapp.user.v1.User - 1, // 12: hyapp.user.v1.CompleteOnboardingRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 5, // 13: hyapp.user.v1.CompleteOnboardingResponse.user:type_name -> hyapp.user.v1.User - 6, // 14: hyapp.user.v1.CompleteOnboardingResponse.token:type_name -> hyapp.user.v1.AuthToken - 1, // 15: hyapp.user.v1.BindPushTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 16: hyapp.user.v1.DeletePushTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 17: hyapp.user.v1.ListCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 23, // 18: hyapp.user.v1.ListCountriesResponse.countries:type_name -> hyapp.user.v1.Country - 1, // 19: hyapp.user.v1.UpdateCountryRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 23, // 20: hyapp.user.v1.CountryResponse.country:type_name -> hyapp.user.v1.Country - 1, // 21: hyapp.user.v1.ListRegistrationCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 23, // 22: hyapp.user.v1.ListRegistrationCountriesResponse.countries:type_name -> hyapp.user.v1.Country - 1, // 23: hyapp.user.v1.ListRegionsRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 24, // 24: hyapp.user.v1.ListRegionsResponse.regions:type_name -> hyapp.user.v1.Region - 1, // 25: hyapp.user.v1.GetRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 26: hyapp.user.v1.UpdateRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 1, // 27: hyapp.user.v1.ReplaceRegionCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 24, // 28: hyapp.user.v1.RegionResponse.region:type_name -> hyapp.user.v1.Region - 1, // 29: hyapp.user.v1.GetUserIdentityRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 37, // 30: hyapp.user.v1.GetUserIdentityResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 1, // 31: hyapp.user.v1.ResolveDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 37, // 32: hyapp.user.v1.ResolveDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 1, // 33: hyapp.user.v1.ChangeDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 37, // 34: hyapp.user.v1.ChangeDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 1, // 35: hyapp.user.v1.ApplyPrettyDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 37, // 36: hyapp.user.v1.ApplyPrettyDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 1, // 37: hyapp.user.v1.ExpirePrettyDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 37, // 38: hyapp.user.v1.ExpirePrettyDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 5, // 39: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User - 7, // 40: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest - 9, // 41: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest - 11, // 42: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest - 13, // 43: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest - 15, // 44: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest - 17, // 45: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest - 19, // 46: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest - 21, // 47: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest - 3, // 48: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest - 25, // 49: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest - 27, // 50: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest - 29, // 51: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest - 31, // 52: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest - 33, // 53: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest - 34, // 54: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest - 35, // 55: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest - 38, // 56: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest - 40, // 57: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest - 42, // 58: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest - 44, // 59: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest - 46, // 60: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest - 8, // 61: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse - 10, // 62: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse - 12, // 63: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse - 14, // 64: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse - 16, // 65: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse - 18, // 66: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse - 20, // 67: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse - 22, // 68: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse - 4, // 69: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse - 26, // 70: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse - 28, // 71: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse - 30, // 72: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse - 32, // 73: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse - 36, // 74: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse - 36, // 75: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse - 36, // 76: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse - 39, // 77: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse - 41, // 78: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse - 43, // 79: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse - 45, // 80: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse - 47, // 81: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse - 61, // [61:82] is the sub-list for method output_type - 40, // [40:61] is the sub-list for method input_type - 40, // [40:40] is the sub-list for extension type_name - 40, // [40:40] is the sub-list for extension extendee - 0, // [0:40] is the sub-list for field type_name + 6, // 3: hyapp.user.v1.User.invite:type_name -> hyapp.user.v1.InviteOverview + 1, // 4: hyapp.user.v1.GetUserMicLifetimeStatsRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 8, // 5: hyapp.user.v1.GetUserMicLifetimeStatsResponse.stats:type_name -> hyapp.user.v1.UserMicLifetimeStats + 1, // 6: hyapp.user.v1.GetUserRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 5, // 7: hyapp.user.v1.GetUserResponse.user:type_name -> hyapp.user.v1.User + 1, // 8: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 53, // 9: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry + 1, // 10: hyapp.user.v1.ListUserIDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 11: hyapp.user.v1.UpdateUserProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 5, // 12: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User + 1, // 13: hyapp.user.v1.ChangeUserCountryRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 5, // 14: hyapp.user.v1.ChangeUserCountryResponse.user:type_name -> hyapp.user.v1.User + 1, // 15: hyapp.user.v1.CompleteOnboardingRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 5, // 16: hyapp.user.v1.CompleteOnboardingResponse.user:type_name -> hyapp.user.v1.User + 11, // 17: hyapp.user.v1.CompleteOnboardingResponse.token:type_name -> hyapp.user.v1.AuthToken + 7, // 18: hyapp.user.v1.CompleteOnboardingResponse.invite:type_name -> hyapp.user.v1.InviteBinding + 1, // 19: hyapp.user.v1.BindPushTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 20: hyapp.user.v1.DeletePushTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 21: hyapp.user.v1.ListCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 28, // 22: hyapp.user.v1.ListCountriesResponse.countries:type_name -> hyapp.user.v1.Country + 1, // 23: hyapp.user.v1.UpdateCountryRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 28, // 24: hyapp.user.v1.CountryResponse.country:type_name -> hyapp.user.v1.Country + 1, // 25: hyapp.user.v1.ListRegistrationCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 28, // 26: hyapp.user.v1.ListRegistrationCountriesResponse.countries:type_name -> hyapp.user.v1.Country + 1, // 27: hyapp.user.v1.ListRegionsRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 29, // 28: hyapp.user.v1.ListRegionsResponse.regions:type_name -> hyapp.user.v1.Region + 1, // 29: hyapp.user.v1.GetRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 30: hyapp.user.v1.UpdateRegionRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 1, // 31: hyapp.user.v1.ReplaceRegionCountriesRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 29, // 32: hyapp.user.v1.RegionResponse.region:type_name -> hyapp.user.v1.Region + 1, // 33: hyapp.user.v1.GetUserIdentityRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 42, // 34: hyapp.user.v1.GetUserIdentityResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 1, // 35: hyapp.user.v1.ResolveDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 42, // 36: hyapp.user.v1.ResolveDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 1, // 37: hyapp.user.v1.ChangeDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 42, // 38: hyapp.user.v1.ChangeDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 1, // 39: hyapp.user.v1.ApplyPrettyDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 42, // 40: hyapp.user.v1.ApplyPrettyDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 1, // 41: hyapp.user.v1.ExpirePrettyDisplayUserIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 42, // 42: hyapp.user.v1.ExpirePrettyDisplayUserIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity + 5, // 43: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User + 12, // 44: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest + 14, // 45: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest + 16, // 46: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest + 9, // 47: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest + 18, // 48: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest + 20, // 49: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest + 22, // 50: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest + 24, // 51: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest + 26, // 52: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest + 3, // 53: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest + 30, // 54: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest + 32, // 55: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest + 34, // 56: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest + 36, // 57: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest + 38, // 58: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest + 39, // 59: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest + 40, // 60: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest + 43, // 61: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest + 45, // 62: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest + 47, // 63: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest + 49, // 64: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest + 51, // 65: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest + 13, // 66: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse + 15, // 67: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse + 17, // 68: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse + 10, // 69: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse + 19, // 70: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse + 21, // 71: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse + 23, // 72: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse + 25, // 73: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse + 27, // 74: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse + 4, // 75: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse + 31, // 76: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse + 33, // 77: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse + 35, // 78: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse + 37, // 79: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse + 41, // 80: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse + 41, // 81: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse + 41, // 82: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse + 44, // 83: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse + 46, // 84: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse + 48, // 85: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse + 50, // 86: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse + 52, // 87: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse + 66, // [66:88] is the sub-list for method output_type + 44, // [44:66] is the sub-list for method input_type + 44, // [44:44] is the sub-list for extension type_name + 44, // [44:44] is the sub-list for extension extendee + 0, // [0:44] is the sub-list for field type_name } func init() { file_proto_user_v1_user_proto_init() } @@ -4326,15 +4772,15 @@ func file_proto_user_v1_user_proto_init() { if File_proto_user_v1_user_proto != nil { return } - file_proto_user_v1_user_proto_msgTypes[12].OneofWrappers = []any{} - file_proto_user_v1_user_proto_msgTypes[24].OneofWrappers = []any{} + file_proto_user_v1_user_proto_msgTypes[17].OneofWrappers = []any{} + file_proto_user_v1_user_proto_msgTypes[29].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_proto_user_v1_user_proto_rawDesc, NumEnums: 1, - NumMessages: 48, + NumMessages: 53, NumExtensions: 0, NumServices: 7, }, diff --git a/api/proto/user/v1/user.proto b/api/proto/user/v1/user.proto index 0fc2b7f4..b4dbff98 100644 --- a/api/proto/user/v1/user.proto +++ b/api/proto/user/v1/user.proto @@ -79,6 +79,45 @@ message User { string phone_country_code = 25; bool country_enabled = 26; string app_code = 27; + InviteOverview invite = 28; +} + +// InviteOverview 是我的页可直接展示的邀请码和邀请计数 read model。 +message InviteOverview { + string my_invite_code = 1; + bool invite_enabled = 2; + int64 invite_count = 3; + int64 valid_invite_count = 4; + int64 valid_invite_threshold_coin = 5; +} + +// InviteBinding 表示注册完成请求本次是否新绑定邀请关系。 +message InviteBinding { + bool bound = 1; + string invite_code = 2; + int64 inviter_user_id = 3; +} + +// UserMicLifetimeStats 是用户维度的麦上累计基础指标,不区分主播/公会身份。 +message UserMicLifetimeStats { + int64 user_id = 1; + int64 seat_occupied_ms = 2; + int64 mic_online_ms = 3; + int64 session_count = 4; + int64 first_mic_at_ms = 5; + int64 last_mic_at_ms = 6; + int64 updated_at_ms = 7; + string app_code = 8; +} + +// GetUserMicLifetimeStatsRequest 查询单个用户全部历史麦位时长。 +message GetUserMicLifetimeStatsRequest { + RequestMeta meta = 1; + int64 user_id = 2; +} + +message GetUserMicLifetimeStatsResponse { + UserMicLifetimeStats stats = 1; } // AuthToken 是登录、刷新和注册完成后返回给 gateway 的访问令牌投影。 @@ -173,6 +212,7 @@ message CompleteOnboardingRequest { string avatar = 4; string country = 5; string gender = 6; + string invite_code = 7; } // CompleteOnboardingResponse 返回固化后的最小注册状态。 @@ -182,6 +222,7 @@ message CompleteOnboardingResponse { int64 profile_completed_at_ms = 3; string onboarding_status = 4; AuthToken token = 5; + InviteBinding invite = 6; } // BindPushTokenRequest 绑定当前登录用户的系统推送 token。 @@ -396,6 +437,7 @@ service UserService { rpc GetUser(GetUserRequest) returns (GetUserResponse); rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse); rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse); + rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse); rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse); rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse); rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse); diff --git a/api/proto/user/v1/user_grpc.pb.go b/api/proto/user/v1/user_grpc.pb.go index 94389443..8d16995b 100644 --- a/api/proto/user/v1/user_grpc.pb.go +++ b/api/proto/user/v1/user_grpc.pb.go @@ -19,12 +19,13 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser" - UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers" - UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs" - UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile" - UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry" - UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding" + UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser" + UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers" + UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs" + UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats" + UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile" + UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry" + UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding" ) // UserServiceClient is the client API for UserService service. @@ -36,6 +37,7 @@ type UserServiceClient interface { GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*GetUserResponse, error) BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error) ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error) + GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error) UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error) ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error) CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error) @@ -79,6 +81,16 @@ func (c *userServiceClient) ListUserIDs(ctx context.Context, in *ListUserIDsRequ return out, nil } +func (c *userServiceClient) GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetUserMicLifetimeStatsResponse) + err := c.cc.Invoke(ctx, UserService_GetUserMicLifetimeStats_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *userServiceClient) UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(UpdateUserProfileResponse) @@ -118,6 +130,7 @@ type UserServiceServer interface { GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error) BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error) ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error) + GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error) UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error) ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error) @@ -140,6 +153,9 @@ func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUs func (UnimplementedUserServiceServer) ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListUserIDs not implemented") } +func (UnimplementedUserServiceServer) GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetUserMicLifetimeStats not implemented") +} func (UnimplementedUserServiceServer) UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method UpdateUserProfile not implemented") } @@ -224,6 +240,24 @@ func _UserService_ListUserIDs_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _UserService_GetUserMicLifetimeStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetUserMicLifetimeStatsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).GetUserMicLifetimeStats(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserService_GetUserMicLifetimeStats_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).GetUserMicLifetimeStats(ctx, req.(*GetUserMicLifetimeStatsRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _UserService_UpdateUserProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(UpdateUserProfileRequest) if err := dec(in); err != nil { @@ -297,6 +331,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListUserIDs", Handler: _UserService_ListUserIDs_Handler, }, + { + MethodName: "GetUserMicLifetimeStats", + Handler: _UserService_GetUserMicLifetimeStats_Handler, + }, { MethodName: "UpdateUserProfile", Handler: _UserService_UpdateUserProfile_Handler, diff --git a/docs/app-entry-state-machine.md b/docs/app-entry-state-machine.md index b79fa3c8..16dd5257 100644 --- a/docs/app-entry-state-machine.md +++ b/docs/app-entry-state-machine.md @@ -1,6 +1,6 @@ # App Entry State Machine -本文定义用户从打开 App 到进入主界面的完整状态机。它只描述 App 入口流程、接口调用顺序、跳转条件和异常恢复边界;登录注册细节见 [Login And Registration Technical Design](./auth-login-register-technical-design.md),房间重连细节见 [Room Entry Reconnect Development Requirements](./room-entry-reconnect-development-requirements.md)。 +本文定义用户从打开 App 到进入主界面的完整状态机。它只描述 App 入口流程、接口调用顺序、跳转条件和异常恢复边界;登录注册细节见 [Login And Registration Technical Design](./auth-login-register-technical-design.md),邀请码细节见 [Registration Invite Code Architecture](./registration-invite-code-architecture.md),房间重连细节见 [Room Entry Reconnect Development Requirements](./room-entry-reconnect-development-requirements.md)。 ## Goals @@ -17,7 +17,7 @@ | --- | --- | --- | --- | | 三方登录即注册 | `POST /api/v1/auth/third-party/login` | DONE | 返回 `is_new_user/profile_completed/onboarding_status` | | refresh token | `POST /api/v1/auth/token/refresh` | DONE | 成功后轮换 refresh token | -| 资料补全 | `POST /api/v1/users/me/onboarding/complete` | DONE | 成功后返回新的 access token,不轮换 refresh token | +| 资料补全 | `POST /api/v1/users/me/onboarding/complete` | DONE | 成功后返回新的 access token,不轮换 refresh token;可选提交 `invite_code` | | 注册国家 | `GET /api/v1/countries` | DONE | 登录前可用 | | 我的页首屏 | `GET /api/v1/users/me/overview` | DONE | 主界面轻聚合 | | 房间列表 | `GET /api/v1/rooms?tab=hot/new` | DONE | 需要 completed profile | @@ -201,6 +201,8 @@ GET /api/v1/countries POST /api/v1/users/me/onboarding/complete ``` +请求体包含 `username/avatar/gender/country`,可以选填 `invite_code`。如果用户填写邀请码但服务端无法解析到有效邀请人,返回 `INVALID_INVITE_CODE`,客户端停留在资料补全页让用户修改或清空邀请码。 + 成功后: - 替换响应里的新 access token。 @@ -321,6 +323,7 @@ DELETE /api/v1/devices/push-token | `SESSION_EXPIRED` | refresh session 过期 | 清 token,登录 | | `SESSION_REVOKED` | refresh session 已吊销 | 清 token,登录 | | `PROFILE_REQUIRED` | 已登录但资料未完成 | 资料补全 | +| `INVALID_INVITE_CODE` | 注册页邀请码无效 | 停留资料补全页,提示修改或清空邀请码 | | `USER_DISABLED` | 用户被禁用 | 账号异常页 | | `USER_BANNED` | 用户被封禁 | 账号异常页 | | `UPSTREAM_ERROR` | 上游不可用 | 重试,不清 token | @@ -360,6 +363,8 @@ DELETE /api/v1/devices/push-token | --- | --- | | 首次安装无 token | bootstrap 后进入登录页 | | 新用户三方登录 | 创建用户,进入资料补全页 | +| 新用户资料补全填写有效邀请码 | 注册完成,邀请关系绑定到邀请码 owner | +| 新用户资料补全填写无效邀请码 | 返回 `INVALID_INVITE_CODE`,仍在资料补全页 | | 资料补全成功 | 替换 access token,进入主界面 | | access token 过期 | refresh 成功后重试 overview | | refresh token 过期 | 清 token,进入登录页 | diff --git a/docs/auth-login-register-technical-design.md b/docs/auth-login-register-technical-design.md index fad324e1..0b8c5c24 100644 --- a/docs/auth-login-register-technical-design.md +++ b/docs/auth-login-register-technical-design.md @@ -1,12 +1,13 @@ # Login And Registration Technical Design -本文档定义 v1 登录、三方注册登录、密码设置和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。 +本文档定义 v1 登录、三方注册登录、密码设置、邀请码归因和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。邀请码详细架构见 [Registration Invite Code Architecture](./registration-invite-code-architecture.md)。 ## Scope v1 只有一个用户创建入口: - 三方身份登录即注册。`provider + provider_subject` 不存在时创建用户、生成 `default_display_user_id`、设置当前 `display_user_id`、绑定三方身份并签发 token。 +- 注册邀请码是可选归因能力。非空邀请码如果有效,则在注册事务或资料补全事务内绑定邀请人;邀请码关系一旦绑定不可由客户端修改。 三方登录不拆成“先登录、发现未注册、再注册”两步。客户端调用三方 SDK 拿到 credential 后,只调用 `LoginThirdParty`。服务端根据 `provider + provider_subject` 决定登录还是创建用户,并通过 `is_new_user` 和 `profile_completed` 告诉客户端下一步是否进入资料补全页。 @@ -36,6 +37,7 @@ v1 不支持: | HTTP JSON API | `gateway-service` | 只暴露 `/api/v1/auth/*`,统一返回 envelope | | 用户创建 | `user-service` | 只能由三方身份首次登录触发 | | 注册完成状态 | `user-service` | 创建用户后默认未完成资料,资料补全后显式标记完成 | +| 邀请码归因 | `user-service` | 生成用户自己的邀请码,解析注册邀请码并写邀请关系事实 | | 展示短号 | `user-service` | 新用户创建时生成默认短号,后续登录用当前有效短号;临时靓号有效期内会覆盖默认短号 | | 密码身份 | `user-service` | 已登录用户手动设置后才存在 | | 三方身份绑定 | `user-service` | `provider + subject` 唯一绑定用户 | @@ -118,9 +120,9 @@ Response `data`: } ``` -三方接口不拆注册和登录。新用户不会默认生成密码;注册上下文和来源快照写入 `users`,同时从 App 序列分配 `default_display_user_id`(首号 `163000`),并在同一事务中创建 `user_display_user_ids`、`third_party_identities` 和首个 `auth_sessions`。`birth` 必须是 `yyyy-mm-dd`;`platform` 当前只接受 `android` 或 `ios`;gateway 只接收 `app_version`,不会读取误拼字段。客户端不能提交注册 IP、UA 或 IP 国家,`register_ip` 和 `register_user_agent` 来自 gateway 请求上下文,`country_by_ip` 来自服务端/可信边缘层根据入口 IP 形成的国家快照。`provider_subject` 只允许由 user-service 校验 provider credential 后得到,不能信任客户端直传,也不能把客户端传入的 `credential` 原样当成 subject。三方绑定唯一键是 `app_code + provider + provider_subject`,同一个 Firebase UID 可以在不同 App 下创建不同业务用户。 +三方接口不拆注册和登录。新用户不会默认生成密码;注册上下文和来源快照写入 `users`,同时从 App 序列分配 `default_display_user_id`(首号 `163000`)、生成用户自己的邀请码,并在同一事务中创建 `user_display_user_ids`、`third_party_identities` 和首个 `auth_sessions`。`birth` 必须是 `yyyy-mm-dd`;`platform` 当前只接受 `android` 或 `ios`;gateway 只接收 `app_version`,不会读取误拼字段。客户端不能提交注册 IP、UA 或 IP 国家,`register_ip` 和 `register_user_agent` 来自 gateway 请求上下文,`country_by_ip` 来自服务端/可信边缘层根据入口 IP 形成的国家快照。`provider_subject` 只允许由 user-service 校验 provider credential 后得到,不能信任客户端直传,也不能把客户端传入的 `credential` 原样当成 subject。三方绑定唯一键是 `app_code + provider + provider_subject`,同一个 Firebase UID 可以在不同 App 下创建不同业务用户。 -`LoginThirdParty` 中的 `username/avatar/gender/country` 只作为 provider 或客户端当次传入的注册资料快照,不代表注册完成。App 注册页的权威提交点是 `CompleteOnboarding(username, avatar, gender, country)`;只有该接口成功后才设置 `profile_completed=true`。 +`LoginThirdParty` 中的 `username/avatar/gender/country` 只作为 provider 或客户端当次传入的注册资料快照,不代表注册完成。App 注册页的权威提交点是 `CompleteOnboarding(username, avatar, gender, country, optional invite_code)`;只有该接口成功后才设置 `profile_completed=true`。 注册链路分成两类数据,不能混用: @@ -128,9 +130,12 @@ Response `data`: | --- | --- | --- | --- | | 注册上下文 | `POST /api/v1/auth/third-party/login` | 客户端自动上报或 gateway 观察 | `device_id`、`platform`、`device`、`os_version`、`app_version`、`build_number`、`language`、`timezone`、`source`、`install_channel`、`campaign`、`register_ip`、`register_user_agent`、`country_by_ip` | | 注册页用户资料 | `POST /api/v1/users/me/onboarding/complete` | 用户在注册页主动填写或选择 | `username`、`avatar`、`gender`、`country` | +| 注册邀请归因 | `POST /api/v1/auth/third-party/login` 或 `POST /api/v1/users/me/onboarding/complete` | 用户选填的邀请码 | `invite_code` | `device_id` 和 `platform` 是注册创建用户时必须登记的客户端上下文,不是注册页让用户填写的内容,也不进入 `CompleteOnboarding` request body。注册页只负责让用户完成可展示、可分区的最小资料。 +`invite_code` 推荐在资料补全页提交。为了兼容客户端在三方登录前已经拿到邀请码的场景,`LoginThirdParty` 也保留可选 `invite_code`。已有用户再次登录时提交的邀请码不修改历史邀请关系。 + 客户端跳转规则: - `is_new_user=true` 表示本次调用首次创建了用户和三方绑定,客户端应进入资料补全页。 @@ -145,7 +150,7 @@ Response `data`: | `username` | 64 | trimmed display name | | `gender` | 32 | client enum/string snapshot | | `country` | 3 | ISO 3166-1 alpha-2/alpha-3 canonical code, normalized uppercase | -| `invite_code` | 64 | optional invite code | +| `invite_code` | 64 | optional invite code, trim and uppercase before resolving | | `register_ip` | 64 | gateway observed IP only | | `register_user_agent` | 512 | gateway observed User-Agent only | | `country_by_ip` | 2 | trusted edge/server country code only | @@ -281,7 +286,9 @@ Request: { "username": "hy", "avatar": "https://cdn.example/avatar.png", - "country": "SG" + "gender": "male", + "country": "SG", + "invite_code": "A1B2C3" } ``` @@ -297,6 +304,11 @@ Response `data`: "profile_completed": true, "profile_completed_at_ms": 1777000000000, "onboarding_status": "completed", + "invite": { + "bound": true, + "invite_code": "A1B2C3", + "inviter_user_id": "10001" + }, "token": { "user_id": "918274650129384448", "session_id": "sess_xxx", @@ -310,7 +322,7 @@ Response `data`: } ``` -该接口是注册页唯一提交入口。客户端在注册页一次性提交国家、用户名和头像;user-service 在同一事务内校验资料、解析国家和区域、更新用户资料,并把用户标记为注册完成。 +该接口是注册页唯一提交入口。客户端在注册页一次性提交国家、用户名、头像和可选邀请码;user-service 在同一事务内校验资料、解析国家和区域、按需绑定邀请关系、更新用户资料,并把用户标记为注册完成。 `token.access_token` 是注册完成后立即替换旧 access token 的新 JWT,包含 `profile_completed=true`。该接口不轮换 refresh token,`token.refresh_token` 为空或不返回;客户端必须保留登录时已有的 refresh token。 @@ -322,6 +334,7 @@ Response `data`: | `avatar` | yes | App 注册页要求用户头像,房间和 IM 展示不使用空头像 | | `country` | yes | 房间列表按区域分发,必须有用户选择国家 | | `region_id` | no | 国家可以暂时没有区域映射,此时进入 `GLOBAL` 房间列表 | +| `invite_code` | no | 注册邀请归因;非空时必须能解析到有效邀请人 | 注册上下文必需字段: @@ -336,11 +349,13 @@ Response `data`: - `username` 必须 trim 后非空,并满足 `users.username` 长度限制。 - `avatar` 必须是合法 `http` 或 `https` URL,并满足 `users.avatar` 长度限制。 - `country` 必须命中 active `countries.country_code`,并同步解析 `region_id`。 +- `invite_code` 非空时必须能解析到同一 `app_code` 下的 active 用户,且不能是当前用户自己的邀请码。 +- 邀请关系只能在未完成资料且尚未绑定邀请关系时创建;已完成注册后不能再补填邀请码。 - 初次完成注册时设置国家不消耗国家修改冷却期;注册完成后的国家修改才走 `ChangeUserCountry` 和 30 天冷却。 - 如果用户已经 `profile_completed=true`,该接口不再修改资料;客户端需要改资料时使用资料修改接口,改国家时使用国家修改接口。 - `CompleteOnboarding` 成功后必须返回新的 access token,客户端立即替换旧 token;refresh token 不轮换。 -如果必填字段缺失或格式非法,返回 `INVALID_ARGUMENT`。如果资料未完成用户访问被门禁保护的业务能力,返回 `PROFILE_REQUIRED`。`PROFILE_REQUIRED` 只用于已登录但资料未完成的业务门禁,不用于三方登录接口。 +如果必填字段缺失或格式非法,返回 `INVALID_ARGUMENT`。如果非空邀请码无法解析为有效邀请人,返回 `INVALID_INVITE_CODE`。如果资料未完成用户访问被门禁保护的业务能力,返回 `PROFILE_REQUIRED`。`PROFILE_REQUIRED` 只用于已登录但资料未完成的业务门禁,不用于三方登录接口。 ## App Login State Machine @@ -359,7 +374,7 @@ unauthenticated ```text onboarding_required - -> POST /api/v1/users/me/onboarding/complete(username, avatar, country) + -> POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country, optional invite_code) -> token_received(profile_completed=true) -> authenticated_home ``` @@ -589,6 +604,7 @@ message CompleteOnboardingRequest { string avatar = 4; string country = 5; string gender = 6; + string invite_code = 7; } message CompleteOnboardingResponse { @@ -611,6 +627,7 @@ service UserService { - `profile_completed_at_ms=0` 表示从未完成 onboarding。 - `onboarding_status` 首版只允许 `profile_required` 和 `completed`,后续可以追加 `country_required`、`age_required` 等更细状态。 - `CompleteOnboardingRequest.username/avatar/gender/country` 是注册页必填字段,不能从客户端拆成多次部分提交。 +- `CompleteOnboardingRequest.invite_code` 是注册页选填字段;非空时必须解析到有效邀请人,否则返回 `INVALID_INVITE_CODE`。 - `CompleteOnboardingResponse.token.access_token` 是注册完成后用于替换旧 JWT 的新 access token;它复用当前 `session_id`,不轮换 refresh token。 ## Data Model @@ -629,6 +646,8 @@ service UserService { | `auth_sessions` | refresh token hash 唯一键带 `app_code` | | `login_audit` | 登录审计查询索引带 `app_code` | | `display_user_id_sequences` | 每个 App 独立短号序列,首个 `next_value` 为 `163000` | +| `user_invite_codes` | 用户自己的邀请码按 `app_code + code` 唯一 | +| `user_invite_relations` | 被邀请用户按 `app_code + invited_user_id` 最多一条关系 | | Column | Type | Rule | | --- | --- | --- | @@ -640,7 +659,7 @@ service UserService { | `username` | varchar | registration display name snapshot | | `gender` | varchar | client submitted gender value | | `country` | varchar | ISO 3166-1 alpha-2/alpha-3 canonical country code | -| `invite_code` | varchar | optional invite code | +| `invite_code` | varchar | optional submitted invite code snapshot; not the authoritative inviter relation | | `register_ip` | varchar | gateway observed request ip snapshot | | `register_user_agent` | varchar | gateway observed User-Agent snapshot | | `country_by_ip` | varchar | server/edge inferred country from entry IP | @@ -673,6 +692,7 @@ service UserService { `CompleteOnboarding` 成功时: - 校验 `username/avatar/gender/country`。 +- 如果提交了 `invite_code` 且尚未绑定邀请关系,解析并写 `user_invite_relations`。 - 写入 `users.username`、`users.avatar`、`users.gender`、`users.country` 和 `users.region_id`。 - 设置 `profile_completed=true`。 - 设置 `profile_completed_at_ms=now`。 @@ -680,6 +700,44 @@ service UserService { 资料完成状态只能由 user-service 修改,gateway 和客户端不能直接提交该字段。 +### `user_invite_codes` + +用户自己的邀请码表。新用户创建时必须生成一条 active primary code,供用户在我的页复制分享。 + +| Column | Type | Rule | +| --- | --- | --- | +| `invite_code_id` | bigint | primary id | +| `code` | varchar | trim + uppercase 后保存,`app_code + code` 唯一 | +| `owner_user_id` | bigint | code owner user | +| `status` | varchar | active or disabled | +| `is_primary` | tinyint/bool | 首版一个用户只有一个 primary code | +| `created_at_ms` | bigint | creation time | +| `updated_at_ms` | bigint | update time | + +邀请码不能复用 `current_display_user_id`。展示短号可能因为靓号、运营调整或过期发生变化;邀请码需要长期稳定,才能解释历史邀请关系。 + +### `user_invite_relations` + +用户邀请关系事实表。它是“谁邀请了谁”的权威来源。 + +| Column | Type | Rule | +| --- | --- | --- | +| `invited_user_id` | bigint | 被邀请用户,`app_code + invited_user_id` 主键 | +| `inviter_user_id` | bigint | 邀请人用户 | +| `invite_code_id` | bigint | 使用的邀请码 ID | +| `invite_code` | varchar | 绑定时的邀请码快照 | +| `source` | varchar | login or onboarding | +| `bound_at_ms` | bigint | relation binding time | +| `request_id` | varchar | gateway trace id | +| `created_at_ms` | bigint | creation time | + +关系绑定规则: + +- 一个用户最多一条邀请关系。 +- 非空邀请码不存在、停用、跨 App、owner 非 active 或 owner 是当前用户时,返回 `INVALID_INVITE_CODE`。 +- 已有三方身份再次登录时不能通过 `invite_code` 修改邀请人。 +- 历史关系不因为邀请码后续停用或邀请人资料变化而删除。 + ### `password_accounts` 密码身份表。它表达“该用户已经设置过密码”,不表达独立账号体系。 @@ -789,7 +847,11 @@ sequenceDiagram U->>U: check user status U->>U: create session else identity missing - U->>U: create user + registration context + default_display_user_id + third_party identity + session + U->>U: create user + registration context + default_display_user_id + own invite code + opt invite_code present + U->>U: resolve invite code + bind inviter relation + end + U->>U: create third_party identity + session end U-->>G: AuthToken + is_new_user + profile_completed G-->>C: envelope(data=AuthToken) @@ -809,11 +871,14 @@ sequenceDiagram participant G as gateway-service participant U as user-service - C->>G: POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country) + C->>G: POST /api/v1/users/me/onboarding/complete(username, avatar, gender, country, optional invite_code) G->>G: verify access token - G->>U: CompleteOnboarding(user_id from token, username, avatar, gender, country) + G->>U: CompleteOnboarding(user_id from token, username, avatar, gender, country, invite_code) U->>U: validate username/avatar/gender/country U->>U: resolve active country and region_id + opt invite_code present and no relation exists + U->>U: resolve invite code and bind inviter relation + end U->>U: update profile + mark profile_completed U->>U: re-sign access token with profile_completed=true U-->>G: User + profile_completed=true + AuthToken(access_token only) @@ -933,6 +998,7 @@ gateway HTTP envelope 的 `code` 不使用数字。 | `OK` | 200 | success | | `INVALID_JSON` | 400 | request body is invalid JSON | | `INVALID_ARGUMENT` | 400 | field format or required field invalid | +| `INVALID_INVITE_CODE` | 400 | submitted invite code does not resolve to a valid inviter | | `RATE_LIMITED` | 429 | public auth rate limit exceeded | | `AUTH_FAILED` | 401 | account missing, password wrong, no password set, or provider rejected | | `UNAUTHORIZED` | 401 | missing or invalid access token | @@ -970,6 +1036,8 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所 - 三方 provider secret、Firebase service account 和 Firebase project id 只属于 `user-service` 配置或服务端密钥系统。 - 生产三方 verifier 必须校验 Firebase ID token 或 provider credential 的真实性和用途,不能接受客户端自报 subject,不能把静态 verifier 配到真实 provider。 - 注册完成必须由 user-service 在同一事务内写入 `username/avatar/gender/country/region_id/profile_completed`,不能由 gateway 分多步拼状态。 +- 邀请码解析和邀请关系绑定必须由 user-service 在注册或资料补全事务内完成,不能由 gateway 先查后写。 +- 邀请关系不能只保存邀请码字符串;必须保存 `inviter_user_id` 和绑定时的邀请码快照。 - 登录失败日志不能包含明文密码、三方 credential、refresh token 原文。 - gateway 统一过滤内部错误,客户端只拿稳定错误码和 `request_id`。 - access token 校验失败不访问 `user-service`,避免每个业务请求都打用户服务。 @@ -986,8 +1054,10 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所 - 首版 Firebase 登录必须配置 `provider=firebase`、Firebase project id、Firebase verifier 和 `firebase.sign_in_provider` allowlist;不能只把 `firebase` 加入静态 allowlist。 - 新增直接 provider 必须同时新增专用 verifier、配置项和测试;不能只把 provider 名加入静态 allowlist。 - 三方登录创建用户必须使用事务,保证 `users` 注册上下文、`user_display_user_ids` 和 `third_party_identities` 不出现半绑定。 -- 注册页只调用 `CompleteOnboarding(username, avatar, gender, country)`;不要让客户端分别调用资料、国家和完成状态三个接口拼注册流程,也不要把 `device_id/platform` 放进注册页表单。 +- 三方登录创建新用户时必须同时生成用户自己的邀请码。 +- 注册页只调用 `CompleteOnboarding(username, avatar, gender, country, optional invite_code)`;不要让客户端分别调用资料、国家和完成状态三个接口拼注册流程,也不要把 `device_id/platform` 放进注册页表单。 - `CompleteOnboarding` 成功后必须返回新的 access token;否则旧 access token 的 `profile_completed=false` 仍会被 gateway profile gate 拦截。 +- 已有邀请关系或已完成资料的用户不能通过再次登录或再次 onboarding 修改邀请人。 - 设置密码必须使用 `user_id` 主键幂等判断,不能引入独立账号唯一键。 - 登录响应中的 `display_user_id` 表示当前有效短号,服务端权限、账务、房间和 IM 身份一律使用长 `user_id`。 @@ -999,6 +1069,10 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所 - 三方新用户首次登录返回 `profile_completed=false` 和 `onboarding_status=profile_required`。 - 三方新用户创建时写入 `device_id/platform/app_version/build_number/os_version/language/timezone` 等注册上下文,但不把这些字段作为注册页用户输入。 - 注册页提交 `username/avatar/gender/country` 后,user-service 在同一事务内写用户资料、解析 `region_id` 并返回 `profile_completed=true`。 +- 新用户创建时生成自己的 active 邀请码,`GET /api/v1/users/me/overview` 可以展示该邀请码。 +- 注册页提交有效 `invite_code` 后,user-service 写 `user_invite_relations(invited_user_id, inviter_user_id)`。 +- 注册页提交不存在、停用、跨 App、owner 非 active 或自己的邀请码时返回 `INVALID_INVITE_CODE`。 +- 已有三方身份再次登录时传入新 `invite_code` 不改变原邀请关系。 - 注册页完成后旧 access token 仍被 profile gate 拒绝;`CompleteOnboardingResponse.token.access_token` 能访问房间列表、IM UserSig、RTC Token 和付费链路。 - 注册完成缺少 `username`、缺少 `avatar`、缺少 `gender`、缺少 `country`、头像 URL 非法或国家不存在时返回 `INVALID_ARGUMENT`。 - 未完成注册用户访问房间列表、创建房间、进房、IM UserSig、RTC Token 或付费行为时返回 `PROFILE_REQUIRED`。 diff --git a/docs/host-agency-bd-admin-architecture.md b/docs/host-agency-bd-admin-architecture.md index 4831d293..7c9e4755 100644 --- a/docs/host-agency-bd-admin-architecture.md +++ b/docs/host-agency-bd-admin-architecture.md @@ -40,7 +40,8 @@ graph LR MQ --> User User --> HostOutbox[("host_outbox")] - HostOutbox --> BI["BI / notification / risk"] + HostOutbox --> Message["message inbox owner"] + HostOutbox --> BI["BI / risk / export"] ``` | Service | Admin Responsibility | @@ -76,6 +77,8 @@ graph LR 后台管理某个 App 的 host、Agency、BD、政策或工资时,必须先在后台上下文中选择 `app_code`。`hyapp_admin` 库不按 App 拆库;审计行可以在 `detail` 中记录 `app_code`。业务事实仍写入 user/wallet 等业务库,并由对应表的 `app_code` 隔离。 +当前 `user_id` 是内部稳定主键,后台仍必须在所有 host/Agency/BD 查询、幂等和审计 detail 中带上 `app_code`。如果某些现有表还使用全局 `PRIMARY KEY(user_id)`、`PRIMARY KEY(command_id)` 或 `PRIMARY KEY(event_id)`,这只能说明当前实现暂时要求这些 ID 全局唯一;不能据此省略 `app_code`,也不能在多 App 写入前声称同一个 `command_id` 可以跨 App 复用。 + ```sql admin_operation_logs( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, @@ -281,9 +284,9 @@ stateDiagram-v2 ### Host Salary -输入来自 App 文档定义的 `host_daily_stats`: +输入来自 App 文档定义的 `host_daily_stats`。基础麦上在线事实先按所有用户沉淀,详见 [User Mic Online Time Architecture](./user-mic-online-time-architecture.md);`host_daily_stats` 是工资需要的关系快照派生表。 -- Sum `mic_publishing_ms` within cycle。 +- Sum `mic_online_ms` within cycle。 - Sum `gift_point_received` within cycle。 - Sum `effective_day`。 - 使用 stats 中的 `agency_id/bd_user_id/bd_leader_user_id` 关系快照做归属。 @@ -459,7 +462,23 @@ service UserHostAdminService { | `SalaryAdjustmentPosted` | adjustment credited or reversed in wallet | | `AdminHostRelationChanged` | admin creates/stops BD/Agency/Leader | -Outbox consumers can update admin notifications, BI, risk systems, and export jobs. They must not be required for salary facts to commit. +Outbox consumers can update App message inbox, BI, risk systems, and export jobs. They must not be required for salary facts to commit. + +## App Message Notifications + +后台关系和工资动作需要通知 App 用户时,统一进入 App `消息` tab 的 `system` 分区,具体 message inbox 设计见 [App Message Tab Architecture](./app-message-tab-architecture.md)。`hyapp-admin-server` 只负责后台权限和审计;App 消息事实由 message inbox owner 落库。 + +| Admin/Salary Fact | Target User | Message Type | Action | +| --- | --- | --- | --- | +| Admin creates BD Leader | target user | `system` | `bd_profile_detail` | +| Admin creates BD | target user | `system` | `bd_profile_detail` | +| Admin creates Agency | owner user | `system` | `agency_detail` | +| Agency closed by admin | owner and active members | `system` | `agency_detail` | +| Salary cycle item posted | salary receiver | `system` | `salary_item_detail` | +| Salary adjustment posted | affected receiver | `system` | `salary_adjustment_detail` | +| Salary posting failed after approval | no App user notification by default | internal alert | + +工资、关系和钱包入账事实不依赖消息投递。message inbox 消费失败时只能重试或告警,不能回滚 salary cycle、salary item 或 wallet transaction。 ## Edge Cases diff --git a/docs/host-agency-bd-architecture.md b/docs/host-agency-bd-architecture.md index ca18e93e..bf0ac706 100644 --- a/docs/host-agency-bd-architecture.md +++ b/docs/host-agency-bd-architecture.md @@ -75,7 +75,9 @@ graph LR host、Agency、BD、申请、邀请、统计和收益读模型都按 `app_code` 隔离。gateway 必须把客户端解析出的 `app_code` 写入 `RequestMeta`;`user-service` host domain 的所有查询、锁、唯一约束、幂等结果和 outbox 都必须带 `app_code`。 -不同 App 下可以出现相同的 `user_id`、`agency_id`、`command_id` 或 `event_id`,但不能跨 `app_code` 查询或复用关系事实。后台操作某个 App 的 host 关系时,也必须由 `hyapp-admin-server` 显式选择并透传 `app_code`。 +当前用户体系的 `user_id` 是内部稳定主键,业务查询仍必须带 `app_code` 做租户作用域。`display_user_id`、`command_id`、`event_id`、申请、邀请和 outbox 幂等键都必须在 `app_code` 下判定唯一,不能因为某个本地表暂时使用全局主键就让业务逻辑绕过 `app_code`。 + +后台操作某个 App 的 host 关系时,也必须由 `hyapp-admin-server` 显式选择并透传 `app_code`。如果后续要允许同一数值业务 ID 在不同 App 下复用,必须先把对应表的主键或唯一键迁移为 `(app_code, id)` 或 `(app_code, command_id)`,再开放多 App 数据写入。 ## Identity Rules @@ -388,17 +390,20 @@ Leader inviting Agency uses the same Agency creation flow as BD, but `parent_bd_ App 统计和后台结算都不能直接从当前 membership 反查,因为关系会变化。每条工作指标必须在发生时固化归属快照。 +麦上在线时长的基础事实不是主播专属指标,先按所有用户统一沉淀。通用用户麦上时长见 [User Mic Online Time Architecture](./user-mic-online-time-architecture.md)。本节的 `host_daily_stats` 是主播、Agency、BD 工资和团队统计的派生读模型,不是唯一时长来源。 + ### Input Events | Event | Source | Use | | --- | --- | --- | -| `RoomMicChanged` | `room-service` outbox | 上麦、确认发流、下麦,计算有效上麦时长 | +| `UserMicSessionClosed` / user mic stats | user mic time consumer | 用户级麦上在线时长基础事实,host stats 从这里派生 | +| `RoomMicChanged` | `room-service` outbox | 上麦、确认发流、下麦;由用户麦时长 consumer 先统一处理 | | `RoomGiftSent` | `room-service` outbox | 房间展示和用户贡献统计 | | `WalletGiftDebited` | `wallet-service` outbox | 权威 `gift_point_added` 和价格版本 | | `WalletRechargeRecorded` | `wallet-service` outbox | 用户充值口径,币商转账按区域政策折算后的 `usd_minor_amount` | | `HostAgencyMembershipChanged` | `user-service` host outbox | 关系变化审计和缓存失效 | -有效上麦时长只计算 `publish_state=publishing` 的时间段。`pending_publish` 不计有效工作时长,避免用户占麦但 RTC 没有成功发流时产生收益口径。 +有效麦上在线默认只计算 `publish_state=publishing` 的时间段。`pending_publish` 不计默认在线时长,避免用户占麦但 RTC 没有成功发流时产生任务或收益口径。若某个运营玩法明确要求“占麦时长”,应读取通用用户麦时长里的 `seat_occupied_ms`,不要改 host stats 口径。 ### Daily Stats @@ -412,7 +417,8 @@ host_daily_stats( agency_id BIGINT NULL, bd_user_id BIGINT NULL, bd_leader_user_id BIGINT NULL, - mic_publishing_ms BIGINT NOT NULL, + mic_online_ms BIGINT NOT NULL, + seat_occupied_ms BIGINT NOT NULL DEFAULT 0, gift_point_received BIGINT NOT NULL, coin_received_value BIGINT NOT NULL, effective_day TINYINT NOT NULL, @@ -437,6 +443,7 @@ host_event_dedup( - `agency_id/bd_user_id/bd_leader_user_id` 是事件发生时的关系快照。 - `relation_scope_key` 必须是非空稳定值,例如 `agency:{agency_id}` 或 `none:{region_id}`,避免 MySQL 在 nullable key 上无法保证无 Agency host 的幂等聚合。 - host 一天内换 Agency 时,同一天可以有多行 stats,按不同 Agency 拆分。 +- 用户成为 host 之前的麦上在线历史仍保留在用户级 stats;是否计入某个 host/工资政策,由派生规则决定。 - `effective_day` 由政策解释,例如当天 `publishing >= 60 minutes` 记 1 天;不要硬编码在统计表结构里。 - 所有事件消费必须按 `app_code + event_id` 幂等。 - 迟到事件可以修正未锁定 stats;已生成收益结果的修正由 Admin 调整单处理,App 只读取最终状态。 @@ -576,6 +583,23 @@ service WalletService { Outbox consumers can update search indexes, notifications, BI, admin views, and risk systems. They must not be required for core relationship facts to commit. +## App Message Tab Integration + +Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system` 分区,具体 message inbox 设计见 [App Message Tab Architecture](./app-message-tab-architecture.md)。`user-service` host domain 只产出 host outbox 事件或调用内部 `CreateInboxMessage`;不能直接写 message inbox 表,也不能复用后台 `admin_notifications`。 + +| Source Event | Target User | Message Type | Action | +| --- | --- | --- | --- | +| `AgencyApplicationSubmitted` | Agency owner or authorized reviewer | `system` | `agency_application_detail` | +| `AgencyApplicationApproved` | applicant host | `system` | `host_application_detail` | +| `AgencyApplicationRejected` | applicant user | `system` | `host_application_detail` | +| `AgencyMembershipChanged(kicked)` | kicked host | `system` | `agency_membership_detail` | +| `RoleInvitationCreated(type=agency)` | invited user | `system` | `role_invitation_detail` | +| `RoleInvitationCreated(type=bd)` | invited user | `system` | `role_invitation_detail` | +| `RoleInvitationAccepted` | inviter | `system` | `bd_team_detail` | +| `BDCreated` | new BD | `system` | `bd_profile_detail` | + +消息只做通知和跳转,不是关系事实。App 收到消息后仍必须调用 host/agency/bd 查询接口刷新当前状态。 + ## Edge Cases | Case | Rule | diff --git a/docs/host-agency-bd-implementation-sequence.md b/docs/host-agency-bd-implementation-sequence.md index ba8be4a3..fa88a24d 100644 --- a/docs/host-agency-bd-implementation-sequence.md +++ b/docs/host-agency-bd-implementation-sequence.md @@ -14,6 +14,7 @@ - 新增 protobuf:host profile、Agency、BD profile、coin_seller profile、Agency membership、application、role invitation 的内部 gRPC 契约,由 `user-service` 实现。 - 在 user MySQL schema 新增表:`host_profiles`、`agencies`、`bd_profiles`、`coin_seller_profiles`、`agency_memberships`、`agency_applications`、`role_invitations`、`host_outbox`。 - 所有 host domain 表、命令幂等键、查询条件和 outbox 都必须带 `app_code`;首个种子 App 是 `com.org.laluparty -> lalu`。 +- 审计 host domain 表的主键和唯一键:如果继续使用全局 `PRIMARY KEY(user_id)`、`PRIMARY KEY(command_id)` 或 `PRIMARY KEY(event_id)`,则 ID 生成和命令 ID 必须全局唯一;如果要支持同 ID 跨 App 复用,必须先迁移为 `(app_code, id)` 或 `(app_code, command_id)`。 - 实现 command id 幂等:申请、邀请、审核、踢出、接受邀请都不能因重试重复创建事实。 - 实现关系锁:涉及用户身份变化时锁 `host_profiles`、`agencies`、`bd_profiles` 和 active membership。 @@ -25,6 +26,7 @@ - coin_seller 可以和 Agency、BD、BD Leader 身份并存。 - `go test ./services/user-service/...` 通过。 - 修改 protobuf 后必须 `make proto && go test ./...`。 +- 多 App 写入前必须完成 keying 审计,不能出现文档按 `app_code` 幂等、数据库却用全局唯一键导致跨 App 冲突的灰区。 ## Phase 2: Minimal Admin For Organization Seed @@ -86,21 +88,43 @@ - 币商余额不足时转账失败,玩家 `COIN` 不增加。 - 币商和玩家不在同一区域时拒绝转账;缺少 active 充值政策时不产生账务分录。 -## Phase 4: Stats And Attribution +## Phase 3.5: App Message Notifications -关系主链路稳定后再消费房间和钱包事件。统计必须使用事件发生时的关系快照,不能从当前 membership 反推历史归属。 +关系主链路跑通后接入 App `消息` tab。通知是用户体验能力,不是关系事实提交前置条件;任何通知失败都不能回滚 host/Agency/BD 事务。 交付: -- 消费 `RoomMicChanged`,只累计 `publish_state=publishing` 的有效上麦时间。 +- host outbox 事件接入 message inbox owner,具体接口和表见 [App Message Tab Architecture](./app-message-tab-architecture.md)。 +- Agency 申请提交后通知 Agency owner 或授权审核者。 +- 申请通过、拒绝、被踢出 Agency 时通知目标用户。 +- BD/BD Leader 邀请 Agency 或 BD 时通知被邀请人。 +- 邀请被接受后通知邀请人刷新团队列表。 +- 后台创建 BD Leader、BD、Agency 后通知目标用户或 owner。 +- 所有消息使用 `producer=user-service-host`、稳定 `producer_event_id`,重复消费不重复入箱。 + +验收: + +- 同一 host outbox 事件重复消费,只生成一条 App system 消息。 +- 消息只出现在当前 `app_code`、目标 `user_id` 的 `system` 分区。 +- App 点击消息后仍通过 host/agency/bd 查询接口读取最新事实。 +- message inbox 失败时 host 关系事实已提交,outbox 可重试。 + +## Phase 4: Stats And Attribution + +关系主链路稳定后再消费房间和钱包事件。麦上在线时长先按所有用户统一沉淀,详见 [User Mic Online Time Architecture](./user-mic-online-time-architecture.md)。host stats 是派生读模型;工资和团队统计必须使用派生时的关系快照,不能从当前 membership 反推历史归属。 + +交付: + +- 消费或读取用户麦上时长基础事实,默认只累计 `publish_state=publishing` 的 `mic_online_ms`。 - 消费礼物/钱包事件,累计 host 收到的礼物积分或等价统计。 -- 写入 `host_daily_stats`,包含 `agency_id`、`bd_user_id`、`bd_leader_user_id` 和非空 `relation_scope_key`。 +- 写入通用用户麦时长表,并从中派生 `host_daily_stats`;`host_daily_stats` 包含 `agency_id`、`bd_user_id`、`bd_leader_user_id` 和非空 `relation_scope_key`。 - 写入 `host_event_dedup`,按 `event_id` 幂等消费。 - 构建 App 读模型:`host_me_view`、`agency_member_view`、`bd_team_view`、`earning_summary_view` 的基础查询。 验收: - `pending_publish` 不计有效工作时长。 +- 用户成为 host 前累计的用户级麦上在线时长不会丢;是否用于 host 任务由任务政策决定。 - host 一天内换 Agency 时,stats 按 relation snapshot 拆行。 - 同一事件重复消费不会重复累计。 - 迟到事件可以修正未锁定 stats;已结算结果交给 Admin 调整单处理。 @@ -120,6 +144,7 @@ - 后台审核工资周期。 - 调用 `wallet-service` 给 `USD_BALANCE` 入账,幂等键为 `salary:{salary_item_id}`。 - 支持调整单:迟到事件、人工修正、争议处理都走补差或冲正。 +- Salary posted 和 adjustment posted 事件接入 App message inbox,通知接收人查看收益明细;通知失败不影响钱包入账事实。 验收: @@ -155,6 +180,7 @@ | M1 | Phase 1 | 用 gRPC 创建基础关系事实,证明幂等和锁正确 | | M2 | Phase 2 | 后台创建 BD/Agency,App 可以搜到 Agency | | M3 | Phase 3 | 用户申请、审核、踢出、重新申请、邀请链路完整跑通 | +| M3.5 | Phase 3.5 | 申请、邀请、后台创建关系结果能进入 App 系统消息 | | M4 | Phase 4 | 有效上麦和礼物统计按关系快照落库 | | M5 | Phase 5 | 后台生成工资单,审批后入账 `USD_BALANCE` | | M6 | Phase 6 | 失败补偿、导出、重建和监控可用 | diff --git a/docs/my-page-overview-api.md b/docs/my-page-overview-api.md index 01b48fa6..a187298a 100644 --- a/docs/my-page-overview-api.md +++ b/docs/my-page-overview-api.md @@ -1,6 +1,6 @@ # My Page Overview API -本文定义 App 底部 `我的` 页首屏聚合接口。该接口只返回首屏渲染需要的轻量摘要:用户资料、核心资产余额、身份开关、消息红点和入口显隐。流水、成员列表、背包完整列表、工资明细和团队统计必须放到二级页接口。 +本文定义 App 底部 `我的` 页首屏聚合接口。该接口只返回首屏渲染需要的轻量摘要:用户资料、我的邀请码、核心资产余额、身份开关、消息红点和入口显隐。流水、成员列表、背包完整列表、工资明细、邀请列表和团队统计必须放到二级页接口。 ## Goals @@ -27,6 +27,7 @@ Authorization: Bearer "data": { "profile": {}, "wallet": {}, + "invite": {}, "roles": {}, "badges": {}, "entries": [], @@ -85,6 +86,13 @@ Authorization: Bearer "diamond_exchange_enabled": true, "withdraw_enabled": true }, + "invite": { + "my_invite_code": "A1B2C3", + "invite_enabled": true, + "invite_count": 12, + "valid_invite_count": 3, + "valid_invite_threshold_coin": 80000 + }, "roles": { "is_host": true, "is_agent": false, @@ -139,6 +147,10 @@ Authorization: Bearer | --- | --- | --- | | `profile` | `user-service.GetUser` | single row by `(app_code, user_id)` | | `wallet.balances` | `wallet-service.GetBalances` | point read for `COIN`、`DIAMOND`、`USD_BALANCE` | +| `invite.my_invite_code` | `user-service` invite code module | point read active primary code by `(app_code, user_id)` | +| `invite.invite_count` | invite read model | users that successfully filled this invite code; without read model return `0` or omit | +| `invite.valid_invite_count` | invite read model | invited users whose cumulative eligible recharge reaches the configured threshold | +| `invite.valid_invite_threshold_coin` | invite validity policy | current configured threshold, default `80000` `COIN` | | `roles` | `user-service` host identity module | point read role facts or future role summary read model | | `badges.unread_*` | message inbox owner | counter/read model, not message list scan | | `entries` | gateway composition rules | computed from `roles` and feature flags | @@ -179,6 +191,14 @@ Authorization: Bearer ## Expensive Fields To Exclude +不要在 `overview` 中实时查询这些字段: + +| Field | Reason | Replacement | +| --- | --- | --- | +| 邀请用户列表 | 需要分页,可能扫 `user_invite_relations` | 二级页 `GET /api/v1/invites/users` | +| 实时邀请人数 `COUNT(*)` | 高活跃邀请人会让首页变慢 | read model counter 或返回 `0` | +| 邀请奖励流水 | 属于钱包/活动明细 | 二级页分页 | + 这些字段不要放进首屏 overview: | Field | Reason | Replacement | diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index 350dcfd7..d171ac8c 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -2433,6 +2433,30 @@ definitions: unavailable: type: boolean description: 钱包上游不可用时为 true;此时 balances 为空。 + MyOverviewInviteData: + type: object + properties: + my_invite_code: + type: string + description: 当前用户自己的 active primary 邀请码。 + invite_enabled: + type: boolean + description: 当前用户的邀请码是否可用于新用户注册归因。 + invite_count: + type: integer + format: int64 + description: 邀请人数,表示成功填写当前用户邀请码并绑定关系的人数;没有 read model 时返回 0 或不返回,禁止首页实时 COUNT。 + valid_invite_count: + type: integer + format: int64 + description: 有效邀请人数,表示被邀请用户累计符合条件充值达到后台配置门槛的人数。 + valid_invite_threshold_coin: + type: integer + format: int64 + description: 当前有效邀请门槛,默认 80000 COIN,可由后台配置。 + unavailable: + type: boolean + description: 邀请码上游不可用时为 true;客户端可隐藏复制入口。 MyOverviewBadgesData: type: object required: @@ -2486,6 +2510,7 @@ definitions: required: - profile - wallet + - invite - roles - badges - entries @@ -2495,6 +2520,8 @@ definitions: $ref: "#/definitions/UserProfileData" wallet: $ref: "#/definitions/MyOverviewWalletData" + invite: + $ref: "#/definitions/MyOverviewInviteData" roles: $ref: "#/definitions/MyOverviewRolesData" badges: @@ -2870,11 +2897,25 @@ definitions: maxLength: 3 pattern: "^[A-Za-z]{2,3}$" description: 注册页从 `/api/v1/countries` 取得的 country_code。 + invite_code: + type: string + maxLength: 64 + description: 可选邀请码;非空时必须解析到有效邀请人。 CompleteOnboardingData: allOf: - $ref: "#/definitions/UserProfileData" - type: object properties: + invite: + type: object + properties: + bound: + type: boolean + invite_code: + type: string + inviter_user_id: + type: string + description: 64 位用户 ID 使用字符串返回,未绑定时为空。 token: $ref: "#/definitions/AuthTokenData" description: 注册完成后替换旧 access token;refresh_token 为空或不返回。 diff --git a/docs/openapi/user.swagger.yaml b/docs/openapi/user.swagger.yaml index 12243029..0907dcb1 100644 --- a/docs/openapi/user.swagger.yaml +++ b/docs/openapi/user.swagger.yaml @@ -967,6 +967,34 @@ definitions: enum: - profile_required - completed + invite: + $ref: "#/definitions/InviteOverview" + InviteOverview: + type: object + properties: + my_invite_code: + type: string + invite_enabled: + type: boolean + invite_count: + type: integer + format: int64 + valid_invite_count: + type: integer + format: int64 + valid_invite_threshold_coin: + type: integer + format: int64 + InviteBinding: + type: object + properties: + bound: + type: boolean + invite_code: + type: string + inviter_user_id: + type: integer + format: int64 GetUserRequest: type: object properties: @@ -1077,6 +1105,10 @@ definitions: maxLength: 3 pattern: "^[A-Z]{2,3}$" description: 注册页开放国家列表返回的 country_code。 + invite_code: + type: string + maxLength: 64 + description: 可选邀请码;非空时必须解析到有效邀请人。 CompleteOnboardingResponse: type: object properties: @@ -1089,6 +1121,10 @@ definitions: format: int64 onboarding_status: type: string + token: + $ref: "#/definitions/AuthToken" + invite: + $ref: "#/definitions/InviteBinding" BindPushTokenRequest: type: object required: diff --git a/docs/registration-invite-code-architecture.md b/docs/registration-invite-code-architecture.md new file mode 100644 index 00000000..272b881f --- /dev/null +++ b/docs/registration-invite-code-architecture.md @@ -0,0 +1,538 @@ +# Registration Invite Code Architecture + +本文定义 App 注册邀请码能力。用户注册时可以选填邀请码;如果该邀请码属于某个有效用户,则该用户成为新用户的邀请人。邀请码归因是用户关系事实,不能只把字符串放在 `users.invite_code` 里。 + +## Goals + +- 每个用户有一个可展示、可复制的永久邀请码。 +- 新用户注册或完成资料时可以选填邀请码。 +- 非空邀请码必须能解析到同一 `app_code` 下的有效邀请人。 +- 邀请关系一旦绑定不可被客户端修改。 +- 邀请关系必须落 MySQL;Redis 只能做短 TTL 查询缓存。 +- 邀请统计必须拆成 `invite_count` 和 `valid_invite_count`:前者表示成功填码绑定关系的人数,后者表示被邀请人累计充值达到后台配置门槛的人数。 +- 后续奖励、活动、风控和运营统计都从邀请关系事实消费,不反推历史字符串。 + +## Non-Goals + +- 首版不在注册链路内同步发奖励。 +- 首版不支持用户自己修改邀请码。 +- 首版不支持跨 App 邀请。 +- 首版不把邀请码等同于 Agency、BD、币商或主播组织关系。 +- 首版不做邀请排行榜;只保存事实和提供轻量查询。 +- 首版不在注册链路内同步判断有效邀请;有效邀请由钱包充值事实异步推进。 + +## Ownership + +| Capability | Owner | Rule | +| --- | --- | --- | +| 邀请码生成 | `user-service` | 新用户创建时生成自己的邀请码 | +| 邀请码解析 | `user-service` | 注册事务内按 `app_code + code` 查询有效 code | +| 邀请关系绑定 | `user-service` | 一个被邀请用户最多一条关系 | +| 外部 HTTP | `gateway-service` | 转发 `invite_code`,不自行解析归属 | +| 邀请人数统计 | `user-service` invite module | `invite_count` 来自 `user_invite_relations` 绑定事实 | +| 有效邀请判定 | `user-service` invite module + `wallet-service` recharge event | 钱包只产出充值事实,邀请模块按后台策略累计并标记有效 | +| 我的页展示 | `gateway-service` + `user-service` | `overview` 返回当前用户自己的邀请码、邀请人数和有效邀请人数 | +| 奖励/通知 | 后续活动或钱包模块 | 通过 outbox/event 异步消费,不阻塞注册 | + +## Product Rules + +### Code Ownership + +- 邀请码属于用户,不属于设备、session、三方账号或 Agency。 +- 邀请码按 `app_code` 隔离;`lalu` 的邀请码不能邀请 `popoo` 用户。 +- 用户被禁用或封禁后,默认不允许继续作为新邀请人的有效 code owner;历史邀请关系不删除。 +- 用户自己的邀请码创建后长期稳定。后续如果允许换码,必须保留旧码和历史关系快照。 + +### Binding Rules + +| Case | Result | +| --- | --- | +| 注册不填邀请码 | 创建用户,不创建邀请关系 | +| 注册填写有效邀请码 | 创建用户,并绑定 `inviter_user_id` | +| 注册填写不存在、停用、跨 App 或 owner 非 active 的邀请码 | 返回 `INVALID_INVITE_CODE`,不静默忽略 | +| 用户填写自己的邀请码 | 返回 `INVALID_INVITE_CODE` | +| 已有邀请关系的用户再次提交邀请码 | 不修改原关系 | +| 已完成注册用户提交邀请码 | 不修改原关系;如果通过 onboarding 入口提交,按已完成资料逻辑不处理邀请码 | +| 已存在三方身份再次登录并带新邀请码 | 不创建、不修改邀请关系 | + +非空邀请码失败时选择“失败注册”而不是“忽略邀请码”,原因是用户和渠道都需要明确知道归因没有成功,避免后续奖励和申诉没有事实依据。 + +### Count Rules + +邀请统计必须拆成两个指标: + +| Metric | Meaning | Source | Update Timing | +| --- | --- | --- | --- | +| `invite_count` | 邀请人数,表示有多少用户成功填写邀请码并绑定到当前邀请人 | `user_invite_relations` | 绑定关系成功后增加 | +| `valid_invite_count` | 有效邀请人数,表示有多少被邀请用户累计成功充值金币达到策略门槛 | `user_invite_recharge_progress.status=valid` | 钱包充值事件累计达到门槛后增加 | + +规则: + +- `invite_count` 只看填码绑定事实,不要求被邀请人充值、消费、上麦或进入房间。 +- `valid_invite_count` 默认门槛是被邀请人累计成功充值 `80000` `COIN`,门槛必须由后台策略配置,不能写死在代码里。 +- 有效邀请只统计钱包标记为“充值”的 `COIN` 账务事实;送礼收入、奖励、后台普通调账、资源赠送和当前余额变化都不能直接计入。 +- 币商给玩家转金币如果已经由 `wallet-service.wallet_recharge_records` 记录为用户充值,可以计入;是否计入 Apple/Google、线下人工充值、币商转账等类型由策略的 `eligible_recharge_types` 控制。 +- 同一个被邀请用户只能让邀请人成为一次有效邀请;充值超过门槛后不重复增加。 +- 有效状态一旦达成,首版不因后续消费减少而回退;如果后续接入退款、拒付或作弊处理,需要后台风控撤销或新增负向事件重算。 +- 邀请人后续被封禁不删除历史关系和历史有效事实,但可以让其邀请码失效,避免继续产生新关系。 + +## App Flow + +首版兼容两种客户端采集时机: + +1. 客户端在三方登录前已经拿到邀请码:提交到 `POST /api/v1/auth/third-party/login`。 +2. 客户端在资料补全页让用户输入邀请码:提交到 `POST /api/v1/users/me/onboarding/complete`。 + +推荐 App 把邀请码放在资料补全页,因为当前架构是“三方登录即创建未完成用户”,注册页用户资料都在 `CompleteOnboarding` 原子提交。 + +```mermaid +sequenceDiagram + participant C as Client + participant G as gateway-service + participant U as user-service + participant DB as hyapp_user + + C->>G: POST /auth/third-party/login(provider, credential, optional invite_code) + G->>U: LoginThirdParty + U->>U: verify provider credential + alt new third-party identity + U->>DB: create users + own user_invite_codes + opt invite_code present + U->>DB: resolve invite code and insert user_invite_relations + end + U->>DB: create third_party_identity + auth_session + else existing identity + U->>DB: create auth_session only + end + U-->>G: token(profile_completed=false/true) + + C->>G: POST /users/me/onboarding/complete(username, avatar, country, optional invite_code) + G->>U: CompleteOnboarding + U->>DB: lock users row + opt no existing invite relation and invite_code present + U->>DB: resolve invite code and insert user_invite_relations + end + U->>DB: update profile_completed=true + U-->>G: new access_token(profile_completed=true) +``` + +## HTTP Contract + +### Login Or Register + +```http +POST /api/v1/auth/third-party/login +``` + +`invite_code` 仍然是可选字段。只有本次请求首次创建用户时,该字段才可能绑定邀请关系;已有用户登录时忽略该字段,不允许通过重新登录改归因。 + +### Complete Onboarding + +```http +POST /api/v1/users/me/onboarding/complete +Authorization: Bearer +``` + +Request: + +```json +{ + "username": "Alice", + "avatar": "https://cdn.example/avatar.png", + "gender": "female", + "country": "SG", + "invite_code": "A1B2C3" +} +``` + +Response: + +```json +{ + "user_id": "918274650129384448", + "profile_completed": true, + "invite": { + "bound": true, + "invite_code": "A1B2C3", + "inviter_user_id": "10001" + }, + "token": { + "access_token": "jwt_xxx", + "profile_completed": true + } +} +``` + +`invite` 可以首版只返回 `bound`,但服务端必须在库里保存 `inviter_user_id`。客户端不能根据返回内容自行计算邀请关系。 + +### My Invite Code + +首屏可以直接跟随 `GET /api/v1/users/me/overview` 返回: + +```json +{ + "invite": { + "my_invite_code": "A1B2C3", + "invite_enabled": true, + "invite_count": 12, + "valid_invite_count": 3, + "valid_invite_threshold_coin": 80000 + } +} +``` + +`invite_count` 和 `valid_invite_count` 必须来自预计算 read model。首版没有 read model 时返回 `0` 或不返回,不能为了首页展示实时 `COUNT(*)` 扫邀请关系或充值流水。`invited_count` 这个旧命名语义不清,后续对外接口应收敛为 `invite_count`。 + +## Data Model + +### `user_invite_codes` + +```sql +CREATE TABLE user_invite_codes ( + app_code VARCHAR(32) NOT NULL, + invite_code_id BIGINT NOT NULL, + code VARCHAR(64) NOT NULL, + owner_user_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL, + is_primary TINYINT(1) NOT NULL DEFAULT 1, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, invite_code_id), + UNIQUE KEY uk_user_invite_codes_code (app_code, code), + KEY idx_user_invite_codes_owner (app_code, owner_user_id, status) +); +``` + +字段规则: + +- `code` 入库前 trim、转大写,只允许 `A-Z0-9`,长度建议 6 到 12。 +- `status` 首版只需要 `active`、`disabled`。 +- 一个 owner 首版只创建一个 primary code。MySQL 无法跨状态表达复杂唯一时,先通过事务锁用户行和查询保证;后续可加 generated column 做 active primary 唯一。 +- code 不使用 `users.current_display_user_id`,避免靓号、短号过期或短号运营变更影响邀请关系。 + +### `user_invite_relations` + +```sql +CREATE TABLE user_invite_relations ( + app_code VARCHAR(32) NOT NULL, + invited_user_id BIGINT NOT NULL, + inviter_user_id BIGINT NOT NULL, + invite_code_id BIGINT NOT NULL, + invite_code VARCHAR(64) NOT NULL, + source VARCHAR(32) NOT NULL, + bound_at_ms BIGINT NOT NULL, + request_id VARCHAR(96) NOT NULL, + created_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, invited_user_id), + KEY idx_user_invite_relations_inviter (app_code, inviter_user_id, bound_at_ms), + KEY idx_user_invite_relations_code (app_code, invite_code, bound_at_ms) +); +``` + +字段规则: + +- `invited_user_id` 主键保证一个用户只能被归因一次。 +- `invite_code` 是绑定时的快照;即使后续 code 被禁用或换码,历史关系仍可解释。 +- `source` 首版可取 `login` 或 `onboarding`。 +- 历史关系不因为邀请人被封禁、改资料或换码而删除。 + +### `user_invite_validity_policies` + +有效邀请门槛必须由后台配置。默认策略是累计成功充值 `80000` `COIN` 后成为有效邀请。 + +```sql +CREATE TABLE user_invite_validity_policies ( + app_code VARCHAR(32) NOT NULL, + policy_id BIGINT NOT NULL, + policy_version VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + region_id BIGINT NOT NULL DEFAULT 0, + required_recharge_coin_amount BIGINT NOT NULL, + eligible_recharge_types JSON NOT NULL, + effective_from_ms BIGINT NOT NULL, + effective_to_ms BIGINT NULL, + created_by_user_id BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, policy_id), + UNIQUE KEY uk_user_invite_validity_policy_version (app_code, policy_version), + KEY idx_user_invite_validity_policy_active (app_code, region_id, status, effective_from_ms) +); +``` + +字段规则: + +- `required_recharge_coin_amount` 默认配置为 `80000`,单位是钱包 `COIN` 最小整数单位。 +- `region_id=0` 表示全局默认策略;有区域策略时按被邀请用户注册/当前业务区域选择,具体选择规则必须写入策略说明。 +- `eligible_recharge_types` 控制哪些充值类型可计入有效邀请,例如 `provider_iap`、`coin_seller_transfer`、`manual_recharge`。奖励、礼物收入、活动赠送和普通调账默认不计入。 +- 策略修改只能影响新达成有效邀请的判断;已达成的 `valid` 事实不自动重算。 + +### `user_invite_recharge_progress` + +该表保存每个被邀请用户距离有效邀请的累计进度。它不是钱包余额来源,只消费钱包充值事实。 + +```sql +CREATE TABLE user_invite_recharge_progress ( + app_code VARCHAR(32) NOT NULL, + invited_user_id BIGINT NOT NULL, + inviter_user_id BIGINT NOT NULL, + policy_id BIGINT NOT NULL, + policy_version VARCHAR(64) NOT NULL, + required_recharge_coin_amount BIGINT NOT NULL, + accumulated_recharge_coin_amount BIGINT NOT NULL, + status VARCHAR(32) NOT NULL, + valid_at_ms BIGINT NULL, + valid_transaction_id VARCHAR(96) NOT NULL DEFAULT '', + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, invited_user_id), + KEY idx_user_invite_progress_inviter_status (app_code, inviter_user_id, status, valid_at_ms), + KEY idx_user_invite_progress_updated (app_code, status, updated_at_ms) +); +``` + +字段规则: + +- `status` 首版使用 `pending` 和 `valid`。 +- `accumulated_recharge_coin_amount` 只累计符合策略的成功充值金币数量。 +- `valid_transaction_id` 保存让累计金额首次达到门槛的钱包交易 ID,便于审计。 +- 同一个 `invited_user_id` 只能有一条进度;达到 `valid` 后不再重复增加 `valid_invite_count`。 + +### `user_invite_event_consumption` + +钱包充值事件可能重投,邀请模块必须记录消费幂等。 + +```sql +CREATE TABLE user_invite_event_consumption ( + app_code VARCHAR(32) NOT NULL, + source VARCHAR(64) NOT NULL, + event_id VARCHAR(128) NOT NULL, + transaction_id VARCHAR(96) NOT NULL, + user_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL, + error_message VARCHAR(512) NOT NULL DEFAULT '', + consumed_at_ms BIGINT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, source, event_id), + KEY idx_user_invite_event_tx (app_code, transaction_id), + KEY idx_user_invite_event_status (app_code, status, updated_at_ms) +); +``` + +同一 `transaction_id` 如果通过不同事件重复到达,也不能重复累计;实现时应在同一事务中检查 `event_id` 和 `transaction_id` 的已消费状态。 + +### `user_invite_counters` + +我的页和后台列表不能实时扫关系表或钱包流水,应读取计数 read model。 + +```sql +CREATE TABLE user_invite_counters ( + app_code VARCHAR(32) NOT NULL, + inviter_user_id BIGINT NOT NULL, + invite_count BIGINT NOT NULL DEFAULT 0, + valid_invite_count BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, inviter_user_id) +); +``` + +字段规则: + +- `invite_count` 由 `UserInvited` 事实推进。 +- `valid_invite_count` 由 `UserInviteBecameValid` 事实推进。 +- 计数写入必须幂等;重复消费同一事件不能重复增加。 +- 如果计数异常,允许后台重算:`invite_count` 从 `user_invite_relations` 汇总,`valid_invite_count` 从 `user_invite_recharge_progress.status='valid'` 汇总。 + +### `users.invite_code` + +现有 `users.invite_code` 只保留“注册时提交的邀请码字符串快照”。它不能作为权威邀请关系来源,因为它没有 `inviter_user_id`,也无法处理 code 换绑、停用、跨 App 和历史审计。 + +新代码应优先读取 `user_invite_relations`;`users.invite_code` 可以继续保留用于兼容和排障。 + +## Transaction Rules + +新用户创建事务内建议顺序: + +1. 校验 provider credential。 +2. 锁定或分配 `user_id` 和默认短号。 +3. 创建 `users`。 +4. 创建用户自己的 `user_invite_codes`。 +5. 如果请求带 `invite_code`,解析并锁定 code owner 的 `users` 行。 +6. 校验 owner active、非自己、同 `app_code`。 +7. 插入 `user_invite_relations`。 +8. 创建 `third_party_identities` 和 `auth_sessions`。 +9. 写 `UserRegistered` / `UserInvited` outbox 事件。 + +`CompleteOnboarding` 绑定邀请码时: + +1. 锁当前 `users` 行。 +2. 如果 `profile_completed=true`,不允许再绑定邀请码。 +3. 如果已有 `user_invite_relations`,不修改原关系。 +4. 如果请求带非空 `invite_code`,解析 code,锁 inviter 用户行,插入关系。 +5. 更新资料、国家、区域和 `profile_completed`。 +6. 返回新的 access token。 + +关系插入和资料完成必须在同一事务内提交,避免用户看到注册完成但邀请关系丢失。 + +邀请关系绑定成功后,同事务或同一 outbox 消费链路需要初始化 `user_invite_recharge_progress(status=pending)` 并幂等增加 `user_invite_counters.invite_count`。如果计数更新失败,关系事实不能回滚;必须通过 `UserInvited` outbox 重试补偿计数。 + +## Effective Invite Flow + +有效邀请不在注册链路内计算。它由钱包充值事实异步推进: + +```mermaid +sequenceDiagram + participant W as wallet-service + participant WO as wallet_outbox + participant C as invite consumer + participant U as user-service invite module + participant DB as hyapp_user + + W->>W: commit successful COIN recharge + W->>WO: WalletRechargeRecorded(user_id, coin_amount, recharge_type) + C->>WO: consume recharge event + C->>U: ApplyInviteRecharge(user_id, transaction_id, coin_amount) + U->>DB: find user_invite_relations by invited_user_id + alt relation exists and recharge_type eligible + U->>DB: lock user_invite_recharge_progress + U->>DB: accumulate recharge coin amount + opt first time accumulated >= required threshold + U->>DB: mark status=valid and valid_at_ms + U->>DB: increment user_invite_counters.valid_invite_count + U->>DB: write UserInviteBecameValid outbox + end + else no relation or ineligible recharge + U-->>C: idempotent skipped + end +``` + +处理规则: + +- 钱包只负责产出充值事实,不理解邀请关系,也不直接增加邀请统计。 +- 邀请模块按 `app_code + transaction_id` 对充值事件做幂等消费,同一充值事件不能重复累计。 +- 被邀请用户未绑定邀请关系时,充值事件跳过,不创建关系。 +- 充值事件早于邀请关系绑定时不应补算,除非后台明确执行重算任务;正常链路要求先填邀请码、后充值才算有效邀请。 +- 达到门槛的判断使用累计充值金币数量,不使用当前 `COIN` 余额;用户充值后消费完仍然可以成为有效邀请。 +- 如果后续接入退款/拒付事件,需要新增负向充值处理策略;首版只处理正向成功充值。 + +## Code Generation + +邀请码生成建议: + +- 使用服务端随机生成,不让客户端提交自己的 code。 +- 字符集使用 `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`,去掉容易混淆的 `0/O/1/I`。 +- 首版 8 位即可;如果发生唯一冲突,最多重试 5 次,仍失败返回内部错误并告警。 +- 不把手机号、邮箱、设备号、短号直接作为邀请码。 + +## Caching + +注册量没有明显压力前,邀请码解析直接查 MySQL。 + +如果后续加入 Redis: + +| Key | Value | TTL | Rule | +| --- | --- | --- | --- | +| `invite_code:{app_code}:{code}` | `owner_user_id,status,version` | 5-30 min | 只做加速 | + +Redis miss 或异常时必须回 MySQL 或 fail-closed,不能把不存在当有效。禁用邀请码、封禁用户或换码时必须主动删除缓存;不能只依赖 TTL。 + +## Outbox And Rewards + +注册链路只写事实,不同步发奖励。后续奖励通过事件消费: + +- `UserRegistered` +- `UserInvited` +- `UserInviteBecameValid` + +`UserInvited` payload 至少包含: + +```json +{ + "app_code": "lalu", + "invited_user_id": "20001", + "inviter_user_id": "10001", + "invite_code": "A1B2C3", + "source": "onboarding", + "bound_at_ms": 1777996800000 +} +``` + +`UserInviteBecameValid` payload 至少包含: + +```json +{ + "app_code": "lalu", + "invited_user_id": "20001", + "inviter_user_id": "10001", + "policy_id": "9001", + "policy_version": "invite-valid-v1", + "required_recharge_coin_amount": 80000, + "accumulated_recharge_coin_amount": 80000, + "valid_transaction_id": "wtx_01", + "valid_at_ms": 1777996800000 +} +``` + +奖励消费者必须幂等,幂等键使用 `app_code + invited_user_id + reward_type`,不能因为 outbox 重投重复发放。需要按有效邀请发奖励时,只消费 `UserInviteBecameValid`,不要消费原始 `UserInvited`。 + +## Risk Controls + +- gateway 对带邀请码的注册请求继续使用 public auth 频控。 +- user-service 记录 `device_id`、IP、UA、country_by_ip,用于后续识别同设备互邀、批量注册和异常渠道。 +- 自己邀请自己必须拒绝。 +- 同设备大量互邀、同 IP 大量注册、邀请人短时间异常增长不阻断注册主链路,但要进入风控统计或后台审核。 +- 有效邀请达成前必须保留充值事件来源、充值类型和触发交易 ID,便于识别币商刷量、同设备互充、异常退款和人工充值滥用。 +- 后台可以冻结某个邀请人的新邀请资格或奖励资格,但不能删除历史 `user_invite_relations`。 +- 邀请码解析失败不要返回邀请人是否存在的细节;统一 `INVALID_INVITE_CODE`。 + +## Admin Requirements + +后台需要提供最小管理能力: + +- 查询用户自己的邀请码、邀请人数、有效邀请人数、被邀请用户列表和有效达成明细。 +- 启用/禁用某个用户的邀请码;禁用只影响新绑定,不删除历史关系。 +- 配置有效邀请策略:门槛金币数、适用区域、可计入充值类型、生效时间和停用时间。 +- 查看某个被邀请用户的充值累计进度和触发有效邀请的钱包交易 ID。 +- 标记异常邀请或撤销奖励资格;撤销奖励资格不能物理删除邀请关系和有效达成记录。 +- 触发计数重算任务,用于修复 `user_invite_counters` 与事实表不一致。 + +所有后台写操作必须经过 `hyapp-admin-server` 鉴权和审计,不能让 App 用户直接调用。 + +## Implementation Sequence + +1. 增加 `user_invite_codes` 和 `user_invite_relations` 表。 +2. 增加 `user_invite_validity_policies`、`user_invite_recharge_progress`、`user_invite_event_consumption`、`user_invite_counters` 和 `user_outbox`。 +3. 后台增加有效邀请策略配置,默认 `required_recharge_coin_amount=80000`。 +4. user-service 新用户创建时生成自己的邀请码。 +5. `LoginThirdParty` 首次创建用户时解析可选 `invite_code` 并绑定关系。 +6. `CompleteOnboarding` 增加可选 `invite_code`,仅在未完成资料且未绑定关系时处理。 +7. 绑定成功后写 `UserInvited`,并通过 outbox 幂等维护 `invite_count`。 +8. 消费钱包 `WalletRechargeRecorded` 事件,累计被邀请用户充值金币,达到策略门槛后写 `UserInviteBecameValid` 并维护 `valid_invite_count`。 +9. `GetUser` 或 `GetUserOverview` 增加当前用户的邀请码、邀请人数、有效邀请人数和当前有效门槛。 +10. gateway HTTP request/response 增加字段映射。 +11. 加测试和基础监控。 + +## Test Matrix + +| Scenario | Expected | +| --- | --- | +| 新用户不填邀请码 | 注册成功,无 `user_invite_relations` | +| 新用户填有效邀请码 | 注册成功,关系表写入 inviter | +| 新用户填不存在邀请码 | 返回 `INVALID_INVITE_CODE`,不创建完成关系 | +| 邀请码跨 App | 返回 `INVALID_INVITE_CODE` | +| 邀请人 disabled/banned | 返回 `INVALID_INVITE_CODE` | +| 用户尝试填自己的邀请码 | 返回 `INVALID_INVITE_CODE` | +| 已绑定关系后再次提交邀请码 | 原关系不变 | +| 已完成注册后提交邀请码 | 不修改原关系,不补填邀请人 | +| 三方身份再次登录带邀请码 | 登录成功但不修改邀请关系 | +| 并发提交两个邀请码 | 只有一条关系成功,另一条命中幂等或冲突 | +| 邀请码大小写/空格 | trim + uppercase 后解析 | +| 被邀请用户累计充值 79999 COIN | `invite_count` 已增加,`valid_invite_count` 不增加 | +| 被邀请用户累计充值达到 80000 COIN | 首次标记 `user_invite_recharge_progress.status=valid`,`valid_invite_count` 增加 1 | +| 被邀请用户一次充值超过门槛 | 只产生 1 个有效邀请 | +| 被邀请用户多次充值累计达到门槛 | 达标那笔交易触发有效邀请 | +| 同一充值事件重复消费 | 累计金额和有效邀请计数不重复增加 | +| 充值发生在绑定邀请码之前 | 默认不补算有效邀请,除非后台重算任务明确处理 | +| 后台把有效邀请门槛改为 100000 | 已经 valid 的历史事实不重算,新达成用户按新策略判断 | +| 我的页 overview | 返回当前用户自己的邀请码、`invite_count`、`valid_invite_count`,不实时扫关系或充值流水 | diff --git a/docs/service-foundation-technical-design.md b/docs/service-foundation-technical-design.md index 1c38614b..a4d76943 100644 --- a/docs/service-foundation-technical-design.md +++ b/docs/service-foundation-technical-design.md @@ -155,6 +155,14 @@ Auth 专用 reason: | `SESSION_EXPIRED` | `Unauthenticated` | `401` | | `SESSION_REVOKED` | `Unauthenticated` | `401` | +User 专用 reason: + +| Reason | gRPC Code | HTTP Mapping | +| --- | --- | --- | +| `INVALID_INVITE_CODE` | `InvalidArgument` | `400` | +| `COUNTRY_CHANGE_COOLDOWN` | `FailedPrecondition` | `409` | +| `USER_PROFILE_REQUIRED` | `FailedPrecondition` | `403` | + Wallet 专用 reason: | Reason | gRPC Code | HTTP Mapping | @@ -267,22 +275,43 @@ internal/transport/grpc ```text apps users +user_invite_codes +user_invite_relations +user_invite_validity_policies +user_invite_recharge_progress +user_invite_event_consumption +user_invite_counters password_accounts third_party_identities auth_sessions login_audit user_country_change_logs +user_outbox ``` 原则: - `apps` 是 App 注册表,保存 `package_name/platform -> app_code` 映射,由 gateway 解析入口使用。 - `users` 是用户主记录,包含注册资料和注册来源快照;不保存 credential、密码明文或 refresh token 原文。 +- `user_invite_codes` 保存用户自己的可分享邀请码,按 `app_code + code` 唯一。 +- `user_invite_relations` 保存注册邀请关系事实,按 `app_code + invited_user_id` 保证一个用户最多一个邀请人。 +- `user_invite_validity_policies` 保存有效邀请策略,默认累计充值 `80000` `COIN`,但必须允许后台按 App/区域配置。 +- `user_invite_recharge_progress` 保存被邀请用户累计充值进度和是否已成为有效邀请。 +- `user_invite_event_consumption` 保存钱包充值事件消费幂等,避免 wallet outbox 重投导致重复累计。 +- `user_invite_counters` 保存我的页和后台列表使用的邀请计数 read model,包含 `invite_count` 和 `valid_invite_count`。 - `password_accounts` 保存已登录用户手动设置后的密码 hash,不承载独立账号体系。 - `third_party_identities` 保存 provider 绑定。 - `auth_sessions` 保存 refresh token hash 和会话状态。 - `login_audit` 不影响主链路成功与否。 - `user_country_change_logs` 保存国家修改审计,并作为滚动 30 天冷却判断来源。 +- `user_outbox` 保存 `UserRegistered`、`UserInvited`、`UserInviteBecameValid` 等用户领域事件,供奖励、活动、消息和风控异步消费。 + +邀请统计规则: + +- `invite_count` 表示成功填写邀请码并绑定关系的人数,来源是 `user_invite_relations`。 +- `valid_invite_count` 表示被邀请用户累计成功充值金币达到有效邀请策略门槛的人数,默认门槛是 `80000` `COIN`。 +- 有效邀请只消费 `wallet-service` 的充值事实事件;钱包不理解邀请关系,user-service invite module 不读取钱包余额作为判断依据。 +- 后台修改有效邀请门槛只影响后续达成判断;已达成的有效邀请事实不自动重算。 ### Readiness diff --git a/docs/user-mic-online-time-architecture.md b/docs/user-mic-online-time-architecture.md new file mode 100644 index 00000000..9129ffbc --- /dev/null +++ b/docs/user-mic-online-time-architecture.md @@ -0,0 +1,424 @@ +# User Mic Online Time Architecture + +本文定义“用户麦上在线时长”的统一统计架构。这个指标属于所有用户,不属于主播专属体系。用户无论当时是不是主播,只要在麦上产生了有效在线时长,都应该累计到用户维度;后续用户成为主播、参与任务、参与活动或参与成长体系时,可以直接读取历史累计结果。 + +## Core Decision + +麦位时间先按用户维度沉淀,再被主播、任务、活动和工资等业务读取。 + +不能把麦位时间只写进 `host_daily_stats`,也不能在统计事件时判断“这个用户当时是不是主播”。主播身份是后置业务条件,不是麦位在线事实本身。 + +示例: + +- 用户 A 不是主播时累计麦上在线 10 小时。 +- 后来用户 A 申请并成为主播。 +- 如果任务规则是“主播累计麦上在线达到 10 小时”,该任务可以直接使用用户 A 已有的 10 小时完成。 + +## Goals + +- 所有用户共享同一套麦上在线时长事实。 +- 支持日统计、周期统计、 lifetime 累计和任务进度读取。 +- 支持用户成为主播前的历史时长被后续主播任务使用。 +- 统计来源必须可恢复、可重放、可幂等。 +- Redis 只能做缓存,不能作为时长事实来源。 +- 后续主播工资如需“只算成为主播后的有效工作时长”,必须在派生层另行按政策过滤,不能污染基础用户时长。 + +## Non-Goals + +- 不在 room-service 命令链路里同步计算任务进度。 +- 不在上麦接口里同步判断主播、Agency、BD 或任务状态。 +- 不把当前麦位状态当成历史统计。 +- 不用客户端上报时长作为权威事实。 +- 不把单个任务进度直接写进麦位会话表。 + +## Terminology + +| Term | Meaning | +| --- | --- | +| 麦位占用时长 | 从 `MicUp(action=up)` 到 `down/leave/kick/close` 的时间,包含 `pending_publish` | +| 麦上在线时长 | 用户真实完成发流确认后的麦上时间,默认从 `publish_state=publishing` 开始算 | +| `mic_session_id` | 单次上麦会话 ID,同一用户多次上下麦会产生多条 session | +| 基础用户时长 | 不区分用户身份、房间、Agency、BD 的用户级时长事实 | +| 派生业务时长 | 主播工资、活动任务等业务按自己的政策从基础用户时长或 session 明细派生 | + +## Which Time Counts + +首版建议同时保存两个值,但默认任务使用 `mic_online_ms`: + +| Metric | Start | End | Use | +| --- | --- | --- | --- | +| `seat_occupied_ms` | `RoomMicChanged.action=up` | `down/leave/kick/close` | 产品想看“占麦多久”时使用 | +| `mic_online_ms` | `RoomMicChanged.action=publish_confirmed` | `down/leave/kick/close` | 任务、成长、有效麦上在线默认使用 | + +原因: + +- `MicUp` 只表示业务占麦成功,用户可能没有切 RTC anchor、没有打开麦克风、网络失败或马上超时。 +- `publish_confirmed` 表示服务端已经接受客户端或 RTC webhook 的发流确认,更接近“用户真的在麦上在线”。 +- 如果后续有运营活动明确要求“只要占麦就算”,可以读 `seat_occupied_ms`,不需要改历史表。 + +## Source Events + +权威来源是 `room-service` 的 `room_outbox`: + +| Event | Action | Effect | +| --- | --- | --- | +| `RoomMicChanged` | `up` | 创建或开启一条用户麦位 session,记录 `seat_started_at_ms` | +| `RoomMicChanged` | `publish_confirmed` | 设置 `publishing_started_at_ms`,开始累计 `mic_online_ms` | +| `RoomMicChanged` | `change` | 不结束 session,只更新当前 seat;时长连续 | +| `RoomMicChanged` | `down` | 结束 session,结算 `seat_occupied_ms` 和 `mic_online_ms` | + +`LeaveRoom`、`KickUser`、`CloseRoom`、RTC `audio_stopped/room_exited` 和 `publish_timeout` 都必须在释放麦位时产生 `RoomMicChanged(action=down)`。user-service mic time consumer 只消费这个统一麦位事实,不从 `RoomUserLeft`、`RoomUserKicked` 或 `RoomClosed` 反推关闭。 + +当前释放原因约定: + +| Release Path | `RoomMicChanged.reason` | +| --- | --- | +| 主动离房 | `leave` | +| presence stale 清理 | 调用方传入的 stale reason,例如 `presence_stale` | +| 踢人 | `kick` | +| 关房 | `room_closed` | +| pending publish 超时 | `publish_timeout` | +| RTC 停止/退房 | gateway/RTC 事件传入的 reason | + +当前 `RoomMicChanged` 已经包含 `mic_session_id`、`publish_state`、`reason`、`publish_deadline_ms` 和 `publish_event_time_ms`。用户麦时长消费者必须优先使用这些字段,不能用 `room_id + user_id` 自行拼会话 ID。 + +事件兼容规则: + +- `mic_session_id` 为空的 `up/publish_confirmed/down` 事件不能进入正常累计;当前实现写入 `user_mic_event_consumption` 并标记 `skipped/incomplete_mic_event`。 +- `publish_confirmed` 必须只推进当前 open session;如果 session 已结束或事件时间早于已接受时间,按幂等或过期事件处理。 +- `change` 事件没有时长增量,只更新 seat 快照和 `last_event_id`。 +- `down` 的结束时间优先取 envelope `occurred_at_ms`;如果后续事件体提供更精确 RTC stop time,再使用事件体时间。 +- 消费者只消费 `room_outbox` 事实,不订阅腾讯 RTC webhook 原始事件,避免绕过 Room Cell 状态机。 + +## Service Boundary + +```mermaid +graph LR + Room["room-service\nRoom Cell"] --> Outbox[("room_outbox")] + Outbox --> Consumer["user-service\nmic time consumer"] + Consumer --> UserMicDB[("hyapp_user\nuser_mic_* tables")] + UserMicDB --> Task["activity/task service"] + UserMicDB --> Host["host/agency/bd module"] + UserMicDB --> Admin["admin salary/risk"] +``` + +首版建议把用户麦上时长放在 `user-service`,原因: + +- 统计主维度是 `user_id`,不是房间。 +- 任务、主播身份、Agency 和用户资料都需要读取用户侧统计。 +- `room-service` 应继续只拥有房间实时状态、快照、command log 和 outbox,不承载跨天统计。 + +如果后续活动系统独立承接所有任务,也可以由 `activity-service` 消费 room outbox,但基础事实表仍应按用户维度建模,并通过内部 RPC 提供给 host/admin 使用。 + +## Data Model + +### `user_mic_sessions` + +保存每一次上麦会话。它是可审计明细,不直接服务首页大查询。 + +```sql +user_mic_sessions( + app_code VARCHAR(32) NOT NULL, + mic_session_id VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL, + room_id VARCHAR(64) NOT NULL, + first_seat_no INT NOT NULL DEFAULT 0, + current_seat_no INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL, + seat_started_at_ms BIGINT NOT NULL, + publishing_started_at_ms BIGINT NULL, + ended_at_ms BIGINT NULL, + end_reason VARCHAR(64) NOT NULL DEFAULT '', + seat_occupied_ms BIGINT NOT NULL DEFAULT 0, + mic_online_ms BIGINT NOT NULL DEFAULT 0, + opened_event_id VARCHAR(128) NOT NULL, + publish_event_id VARCHAR(128) NOT NULL DEFAULT '', + closed_event_id VARCHAR(128) NOT NULL DEFAULT '', + last_event_id VARCHAR(128) NOT NULL, + last_room_version BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, mic_session_id), + KEY idx_user_mic_sessions_user_status (app_code, user_id, status, updated_at_ms), + KEY idx_user_mic_sessions_room_status (app_code, room_id, status), + KEY idx_user_mic_sessions_open_timeout (app_code, status, seat_started_at_ms) +); +``` + +`status`: + +| Status | Meaning | +| --- | --- | +| `pending_publish` | 已占麦,尚未确认发流 | +| `publishing` | 已确认发流,正在累计在线时长 | +| `ended` | 会话已结束 | +| `abnormal_closed` | 预留给后续补偿任务;当前基础消费者只写 `ended` | + +### `user_mic_daily_stats` + +按当前实现的 UTC 日期聚合,服务任务、活动和个人中心二级页。 + +```sql +user_mic_daily_stats( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + stat_date CHAR(10) NOT NULL, + seat_occupied_ms BIGINT NOT NULL DEFAULT 0, + mic_online_ms BIGINT NOT NULL DEFAULT 0, + session_count BIGINT NOT NULL DEFAULT 0, + room_count BIGINT NOT NULL DEFAULT 0, + first_mic_at_ms BIGINT NOT NULL DEFAULT 0, + last_mic_at_ms BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id, stat_date), + KEY idx_user_mic_daily_date (app_code, stat_date, mic_online_ms) +); +``` + +当前实现以 UTC 日期字符串切分 `stat_date`。跨天 session 必须按 UTC 日切分。例如 23:30 到 01:10,要拆成两天分别累计。后续如果产品需要按 App 运营时区切分,需要先新增明确时区配置和重算策略,不能在代码里隐式依赖机器本地时区。 + +### `user_mic_lifetime_stats` + +保存用户累计值,避免任务系统每次扫 daily 表。 + +```sql +user_mic_lifetime_stats( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + seat_occupied_ms BIGINT NOT NULL DEFAULT 0, + mic_online_ms BIGINT NOT NULL DEFAULT 0, + session_count BIGINT NOT NULL DEFAULT 0, + first_mic_at_ms BIGINT NOT NULL DEFAULT 0, + last_mic_at_ms BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id) +); +``` + +任务判断“累计麦上在线 10 小时”时优先读 `user_mic_lifetime_stats.mic_online_ms`。 + +### `user_mic_event_consumption` + +消费者幂等和跳过原因表。所有 room mic 事件先写入这张表,再推进 session/aggregate;重复 `event_id` 直接跳过,避免 `down` 重放重复累计。 + +```sql +user_mic_event_consumption( + app_code VARCHAR(32) NOT NULL, + event_id VARCHAR(128) NOT NULL, + event_type VARCHAR(64) NOT NULL, + mic_session_id VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + skip_reason VARCHAR(128) NOT NULL DEFAULT '', + created_at_ms BIGINT NOT NULL, + consumed_at_ms BIGINT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, event_id), + KEY idx_user_mic_event_session (app_code, mic_session_id, created_at_ms), + KEY idx_user_mic_event_user (app_code, user_id, created_at_ms) +); +``` + +## Event Processing Rules + +### `up` + +处理 `RoomMicChanged(action=up)`: + +1. 用 `app_code + event_id` 做幂等。 +2. 用 `app_code + mic_session_id` upsert `user_mic_sessions`。 +3. 设置 `status=pending_publish`。 +4. 设置 `seat_started_at_ms = envelope.occurred_at_ms`。 +5. 不增加 `mic_online_ms`。 + +### `publish_confirmed` + +处理 `RoomMicChanged(action=publish_confirmed)`: + +1. 找到 `mic_session_id` 对应 session。 +2. 如果 session 已经是 `publishing/ended`,按幂等处理。 +3. 设置 `publishing_started_at_ms`。 +4. 设置 `status=publishing`。 +5. 不立即增加 daily/lifetime,避免 open session 周期性重复累计。 + +`publishing_started_at_ms` 建议使用事件里的 `publish_event_time_ms`;如果为空,才使用 envelope 的 `occurred_at_ms`。 + +### `change` + +处理 `RoomMicChanged(action=change)`: + +- 不结束 session。 +- 更新 `current_seat_no`。 +- 不切断 `mic_online_ms`。 + +换麦是同一次上麦会话里的位置变化,不应把时长拆断。 + +### `down` + +处理 `RoomMicChanged(action=down)`: + +1. 找到 `mic_session_id` 对应 session。 +2. 设置 `ended_at_ms` 和 `end_reason`。 +3. 计算 `seat_occupied_ms = ended_at_ms - seat_started_at_ms`。 +4. 如果已 `publishing`,计算 `mic_online_ms = ended_at_ms - publishing_started_at_ms`。 +5. 按天切分写入 `user_mic_daily_stats`。 +6. 增量写入 `user_mic_lifetime_stats`。 +7. 设置 session `status=ended`。 + +如果没有 `publish_confirmed` 就下麦,`mic_online_ms=0`,但 `seat_occupied_ms` 仍可记录。 + +## Open Session Compensation + +需要一个补偿 worker 处理异常 open session: + +| Case | Action | +| --- | --- | +| `pending_publish` 超过 room-service publish timeout | 等 room-service `publish_timeout` down 事件;若长期没有事件,按异常关闭 | +| `publishing` 长时间没有 down,但用户 presence 已离开 | 使用 presence/room close 事实补偿关闭 | +| 房间关闭但仍有 open session | 以 `RoomClosed.occurred_at_ms` 关闭 | +| 消费者停机后恢复 | 从 `room_outbox` 或 MQ 位点继续消费;必要时用未结束 session 扫描补偿 | + +补偿关闭必须写 `end_reason=compensated_*`,不能伪装成用户主动下麦。 + +补偿必须设置最大可累计窗口,避免房间事件缺失时把异常 open session 算成超长在线。首版建议按房间关闭时间、presence 离开时间或 `publish_deadline_ms` 截断;仍无法确认时只关闭 session 并告警,不增加 `mic_online_ms`。 + +## Derived Events And Consumers + +用户麦时长消费者写入 MySQL 后,可以通过 user-service outbox 产出派生事件,供 host stats、任务、活动和 BI 消费。派生事件不能替代基础事实表,消费者失败不影响 `user_mic_sessions`、daily 和 lifetime 事实。 + +| Event | Trigger | Consumer | +| --- | --- | --- | +| `UserMicSessionClosed` | 一条 session 正常或补偿结束 | host stats、activity task、BI | +| `UserMicDailyStatsUpdated` | daily stats 有增量 | task progress、growth system | +| `UserMicLifetimeStatsUpdated` | lifetime stats 有增量 | lifetime task cache | + +host/Agency/BD 统计应该优先消费 `UserMicSessionClosed` 或读取 user mic facts 派生关系快照,不应该再直接消费 `RoomMicChanged` 重算同一套用户时长。 + +## Task Usage + +任务系统不要自己消费房间事件来算麦位时间,应该读取用户麦上时长事实。 + +示例任务: + +```text +task: become_host_online_10h +condition: + user must currently be host + user_mic_lifetime_stats.mic_online_ms >= 10 * 60 * 60 * 1000 +``` + +注意:任务判断时校验“当前是否主播”,但时长使用用户历史累计,不要求这些时长发生在成为主播之后。 + +如果某个任务明确要求“成为主播之后再累计 10 小时”,应该新增任务条件: + +```text +sum user_mic_daily_stats where stat_date/time >= host_profile.first_became_host_at_ms +``` + +这属于任务政策,不属于基础统计口径。 + +## Relationship To Host Salary + +用户麦上在线时长是基础事实;主播工资是派生事实。 + +| Use Case | Recommended Source | +| --- | --- | +| 用户成长任务 | `user_mic_lifetime_stats` | +| 主播入门任务 | `user_mic_lifetime_stats` + 当前 host 身份 | +| 主播周期任务 | `user_mic_daily_stats` 按周期汇总 | +| 主播工资 | 从 `user_mic_sessions` 或 `user_mic_daily_stats` 派生,并按工资政策决定是否只算 host 生效后的时间 | +| Agency/BD 归属工资 | 需要额外关系快照,不直接用基础用户累计 | + +因此后续 `host_daily_stats` 不应该是唯一时长来源。它可以从 `user_mic_sessions` 派生,并附带 Agency/BD 关系快照,用于工资和团队统计。 + +## API Suggestions + +### Internal Query + +```protobuf +message GetUserMicLifetimeStatsRequest { + RequestMeta meta = 1; + int64 user_id = 2; +} + +message UserMicLifetimeStats { + int64 user_id = 1; + int64 seat_occupied_ms = 2; + int64 mic_online_ms = 3; + int64 session_count = 4; + int64 first_mic_at_ms = 5; + int64 last_mic_at_ms = 6; + int64 updated_at_ms = 7; + string app_code = 8; +} + +message GetUserMicLifetimeStatsResponse { + UserMicLifetimeStats stats = 1; +} +``` + +当前 proto 已在 `UserService` 暴露 `GetUserMicLifetimeStats(user_id)`。区间查询后续再加,不把未稳定的 range 语义提前暴露给客户端或任务系统。 + +### App Query + +个人页首屏不要返回麦位明细。二级页可以提供: + +```http +GET /api/v1/users/me/mic-stats?range=lifetime +GET /api/v1/users/me/mic-stats?from_ms=...&to_ms=... +``` + +返回: + +```json +{ + "seat_occupied_ms": 39600000, + "mic_online_ms": 36000000, + "session_count": 18, + "first_mic_at_ms": 1777000000000, + "last_mic_at_ms": 1777996800000 +} +``` + +## Implementation Sequence + +1. 已增加 `user_mic_sessions`、`user_mic_daily_stats`、`user_mic_lifetime_stats`、`user_mic_event_consumption`。 +2. 已调整 room-service:释放麦位路径统一产出 `RoomMicChanged(action=down)`,包括 leave、kick、close、RTC stopped、publish timeout。 +3. 已增加 user-service mic time consumer,消费 `RoomMicChanged`。 +4. 已实现 session 状态机:`up -> publish_confirmed -> down`,支持 `change` 不切断。 +5. 已实现 daily/lifetime 聚合,跨 UTC 日切分。 +6. 已增加内部查询 RPC `GetUserMicLifetimeStats`,供任务、主播、后台读取 lifetime 聚合。 +7. 下一步增加补偿 worker,关闭异常 open session。 +8. 下一步增加 user mic outbox,产出 `UserMicSessionClosed`、`UserMicDailyStatsUpdated`、`UserMicLifetimeStatsUpdated`。 +9. 任务系统接入 `user_mic_lifetime_stats`,不要重复消费 room 事件。 +10. host stats 从 user mic facts 或 user mic outbox 派生,不直接重复处理 Room Cell 麦位事件。 + +## Verification Matrix + +| Scenario | Expected | +| --- | --- | +| 普通用户上麦并确认发流 10 分钟后下麦 | `mic_online_ms` 增加 10 分钟 | +| 普通用户只占麦但未发流,超时下麦 | `seat_occupied_ms` 有值,`mic_online_ms=0` | +| 用户换麦位 | session 不结束,时长连续 | +| 用户离房释放麦位 | session 结束并累计到 daily/lifetime | +| 用户被踢释放麦位 | session 结束,`end_reason=kick` | +| 房间关闭 | 所有 open session 结束 | +| 同一事件重复消费 | 不重复累计 | +| 用户跨天上麦 | daily stats 拆成两天 | +| 用户不是主播时累计 10 小时,后续成为主播 | 读取 lifetime 后主播任务可直接完成 | +| 工资只要求 host 期间时长 | 工资派生层按 host 生效时间过滤,不改基础累计 | +| `mic_session_id` 为空的麦位事件 | 不累计,进入异常队列或告警 | +| host stats 消费用户麦时长派生事件 | 不重复累计 room outbox 同一事件 | + +## Critical Rules + +- 基础麦位时长按用户累计,不按主播身份累计。 +- 任务判断可以检查当前身份,但不要把历史时长清零。 +- `pending_publish` 默认不算 `mic_online_ms`。 +- `change` 不结束 session。 +- Redis 不能作为时长事实来源。 +- 客户端上报时长不能作为权威,只能作为辅助诊断。 +- 所有统计写入必须带 `app_code`。 +- `mic_session_id` 是会话幂等和新旧事件隔离的核心字段,不能用用户当前是否在麦位替代。 +- host stats、任务和活动读取用户麦时长事实,不应各自重复消费 room 麦位事件。 diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index 6ab927ee..c156428e 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -67,6 +67,8 @@ const ( RegionCountryConflict Code = "REGION_COUNTRY_CONFLICT" // RegionDisabled 表示区域已经停用,不能继续配置国家。 RegionDisabled Code = "REGION_DISABLED" + // InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。 + InvalidInviteCode Code = "INVALID_INVITE_CODE" // InsufficientBalance 表示钱包余额不足。 InsufficientBalance Code = "INSUFFICIENT_BALANCE" diff --git a/pkg/xerr/grpc.go b/pkg/xerr/grpc.go index 51f96462..061070c3 100644 --- a/pkg/xerr/grpc.go +++ b/pkg/xerr/grpc.go @@ -14,7 +14,7 @@ const errorDomain = "hyapp" // gateway 后续只需要读取 gRPC code 和 ErrorInfo.reason 即可生成 HTTP envelope。 func GRPCCode(code Code) codes.Code { switch code { - case InvalidArgument, DisplayUserIDInvalid, InvalidSection, PageTokenInvalid: + case InvalidArgument, DisplayUserIDInvalid, InvalidInviteCode, InvalidSection, PageTokenInvalid: return codes.InvalidArgument case NotFound, DisplayUserIDNotFound, CountryNotFound, RegionNotFound, MessageNotFound, MessageRecalled: return codes.NotFound diff --git a/services/gateway-service/internal/transport/http/my_page_overview_handler.go b/services/gateway-service/internal/transport/http/my_page_overview_handler.go index f4a123de..36aaa2b3 100644 --- a/services/gateway-service/internal/transport/http/my_page_overview_handler.go +++ b/services/gateway-service/internal/transport/http/my_page_overview_handler.go @@ -21,6 +21,7 @@ var overviewAssetTypes = []string{"COIN", "DIAMOND", "USD_BALANCE"} type myOverviewData struct { Profile userProfileData `json:"profile"` Wallet overviewWalletData `json:"wallet"` + Invite overviewInviteData `json:"invite"` Roles overviewRolesData `json:"roles"` Badges overviewBadgesData `json:"badges"` Entries []overviewEntryData `json:"entries"` @@ -44,6 +45,14 @@ type overviewRolesData struct { Unavailable bool `json:"unavailable,omitempty"` } +type overviewInviteData struct { + MyInviteCode string `json:"my_invite_code"` + InviteEnabled bool `json:"invite_enabled"` + InviteCount int64 `json:"invite_count"` + ValidInviteCount int64 `json:"valid_invite_count"` + ValidInviteThresholdCoin int64 `json:"valid_invite_threshold_coin"` +} + type overviewBadgesData struct { UnreadSystemMessages int64 `json:"unread_system_messages"` UnreadActivityMessages int64 `json:"unread_activity_messages"` @@ -141,6 +150,7 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques writeOK(writer, request, myOverviewData{ Profile: profileData(profileResp.GetUser(), 0), Wallet: wallet, + Invite: overviewInvite(profileResp.GetUser().GetInvite()), Roles: roles, Badges: badges, Entries: overviewEntries(roles), @@ -148,6 +158,19 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques }) } +func overviewInvite(invite *userv1.InviteOverview) overviewInviteData { + if invite == nil { + return overviewInviteData{} + } + return overviewInviteData{ + MyInviteCode: invite.GetMyInviteCode(), + InviteEnabled: invite.GetInviteEnabled(), + InviteCount: invite.GetInviteCount(), + ValidInviteCount: invite.GetValidInviteCount(), + ValidInviteThresholdCoin: invite.GetValidInviteThresholdCoin(), + } +} + func (h *Handler) getOverviewRoles(ctx context.Context, request *http.Request, userID int64) (overviewRolesData, error) { if h.userHostClient == nil { return overviewRolesData{}, xerr.New(xerr.Unavailable, "user host service is not configured") diff --git a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go index 397e5425..324655ca 100644 --- a/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go +++ b/services/gateway-service/internal/transport/http/my_page_overview_handler_test.go @@ -71,6 +71,10 @@ func TestGetMyOverviewComposesProfileWalletRolesBadgesAndEntries(t *testing.T) { if wallet["recharge_enabled"] != true || wallet["diamond_exchange_enabled"] != true || wallet["withdraw_enabled"] != true { t.Fatalf("wallet feature flags mismatch: %+v", wallet) } + invite := data["invite"].(map[string]any) + if invite["my_invite_code"] != "A1B2C3" || invite["invite_enabled"] != true || invite["invite_count"] != float64(12) || invite["valid_invite_count"] != float64(3) || invite["valid_invite_threshold_coin"] != float64(80000) { + t.Fatalf("invite overview mismatch: %+v", invite) + } balances := wallet["balances"].([]any) if len(balances) != 3 { t.Fatalf("overview must return three fixed balances, got %+v", balances) diff --git a/services/gateway-service/internal/transport/http/response.go b/services/gateway-service/internal/transport/http/response.go index 81af1b6d..815430f4 100644 --- a/services/gateway-service/internal/transport/http/response.go +++ b/services/gateway-service/internal/transport/http/response.go @@ -105,7 +105,7 @@ func writeRPCError(writer http.ResponseWriter, request *http.Request, err error) func mapReasonToHTTP(reason xerr.Code) (int, string, string) { switch reason { - case xerr.InvalidArgument, xerr.DisplayUserIDInvalid, xerr.InvalidSection, xerr.PageTokenInvalid: + case xerr.InvalidArgument, xerr.DisplayUserIDInvalid, xerr.InvalidInviteCode, xerr.InvalidSection, xerr.PageTokenInvalid: return http.StatusBadRequest, string(reason), "invalid argument" case xerr.AuthFailed: return http.StatusUnauthorized, string(reason), "authentication failed" diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 77d9fd22..af76b00b 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -313,6 +313,13 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe RegionId: regionID, ProfileCompleted: true, OnboardingStatus: "completed", + Invite: &userv1.InviteOverview{ + MyInviteCode: "A1B2C3", + InviteEnabled: true, + InviteCount: 12, + ValidInviteCount: 3, + ValidInviteThresholdCoin: 80000, + }, }}, nil } @@ -359,6 +366,10 @@ func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv DisplayUserId: "100001", ProfileCompleted: true, OnboardingStatus: "completed", + }, Invite: &userv1.InviteBinding{ + Bound: req.GetInviteCode() != "", + InviteCode: req.GetInviteCode(), + InviterUserId: 10001, }}, nil } @@ -1960,7 +1971,7 @@ func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) { func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *testing.T) { profileClient := &fakeUserProfileClient{} router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) - body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"male","country":"SG"}`) + body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"male","country":"SG","invite_code":"A1B2C3"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/onboarding/complete", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false)) request.Header.Set("X-Request-ID", "req-onboarding") @@ -1974,7 +1985,7 @@ func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t * if profileClient.lastComplete == nil || profileClient.lastComplete.GetUserId() != 42 { t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastComplete) } - if profileClient.lastComplete.GetMeta().GetRequestId() != "req-onboarding" || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetGender() != "male" || profileClient.lastComplete.GetCountry() != "SG" { + if profileClient.lastComplete.GetMeta().GetRequestId() != "req-onboarding" || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetGender() != "male" || profileClient.lastComplete.GetCountry() != "SG" || profileClient.lastComplete.GetInviteCode() != "A1B2C3" { t.Fatalf("complete onboarding request mismatch: %+v", profileClient.lastComplete) } if profileClient.lastComplete.GetMeta().GetSessionId() != "sess-test" { @@ -1993,6 +2004,10 @@ func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t * if !ok || token["access_token"] != "access-completed" || token["profile_completed"] != true { t.Fatalf("complete onboarding must return replacement access token: %+v", data["token"]) } + invite, ok := data["invite"].(map[string]any) + if !ok || invite["bound"] != true || invite["invite_code"] != "A1B2C3" || invite["inviter_user_id"] != "10001" { + t.Fatalf("complete onboarding invite response mismatch: %+v", data["invite"]) + } } func TestProfileMutationEndpointsRequireCompletedProfileToken(t *testing.T) { diff --git a/services/gateway-service/internal/transport/http/user_handler.go b/services/gateway-service/internal/transport/http/user_handler.go index fca71d2f..1d42fd28 100644 --- a/services/gateway-service/internal/transport/http/user_handler.go +++ b/services/gateway-service/internal/transport/http/user_handler.go @@ -55,7 +55,14 @@ type userProfileData struct { // token.refresh_token 首版为空,客户端继续持有登录时的 refresh token,仅替换 access_token。 type completeOnboardingData struct { userProfileData - Token authTokenData `json:"token"` + Invite *inviteBindingData `json:"invite,omitempty"` + Token authTokenData `json:"token"` +} + +type inviteBindingData struct { + Bound bool `json:"bound"` + InviteCode string `json:"invite_code,omitempty"` + InviterUserID string `json:"inviter_user_id,omitempty"` } // countryData 是注册页国家列表的公开响应投影。 @@ -139,10 +146,11 @@ func (h *Handler) resolveDisplayUserID(writer http.ResponseWriter, request *http // gateway 只负责从 access token 取 user_id,资料校验和国家/区域解析必须由 user-service 同事务完成。 func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http.Request) { var body struct { - Username string `json:"username"` - Avatar string `json:"avatar"` - Gender string `json:"gender"` - Country string `json:"country"` + Username string `json:"username"` + Avatar string `json:"avatar"` + Gender string `json:"gender"` + Country string `json:"country"` + InviteCode string `json:"invite_code"` } if !decode(writer, request, &body) { return @@ -153,12 +161,13 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http } resp, err := h.userProfileClient.CompleteOnboarding(request.Context(), &userv1.CompleteOnboardingRequest{ - Meta: authRequestMeta(request, ""), - UserId: auth.UserIDFromContext(request.Context()), - Username: body.Username, - Avatar: body.Avatar, - Gender: body.Gender, - Country: body.Country, + Meta: authRequestMeta(request, ""), + UserId: auth.UserIDFromContext(request.Context()), + Username: body.Username, + Avatar: body.Avatar, + Gender: body.Gender, + Country: body.Country, + InviteCode: body.InviteCode, }) if err != nil { writeRPCError(writer, request, err) @@ -167,6 +176,7 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http writeOK(writer, request, completeOnboardingData{ userProfileData: profileData(resp.GetUser(), 0), + Invite: inviteBindingDataFromProto(resp.GetInvite()), Token: authData(resp.GetToken(), nil), }) } @@ -357,6 +367,17 @@ func profileData(user *userv1.User, nextCountryChangeAtMs int64) userProfileData } } +func inviteBindingDataFromProto(binding *userv1.InviteBinding) *inviteBindingData { + if binding == nil { + return nil + } + return &inviteBindingData{ + Bound: binding.GetBound(), + InviteCode: binding.GetInviteCode(), + InviterUserID: userIDString(binding.GetInviterUserId()), + } +} + func userIDString(userID int64) string { if userID > 0 { return strconv.FormatInt(userID, 10) diff --git a/services/room-service/internal/room/service/lifecycle.go b/services/room-service/internal/room/service/lifecycle.go index c45aeaf4..05dd854c 100644 --- a/services/room-service/internal/room/service/lifecycle.go +++ b/services/room-service/internal/room/service/lifecycle.go @@ -61,6 +61,22 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) ( return mutationResult{}, nil, err } + type occupiedMicSeat struct { + userID int64 + seatNo int32 + micSessionID string + } + occupiedSeats := make([]occupiedMicSeat, 0, len(current.MicSeats)) + for _, seat := range current.MicSeats { + if seat.UserID != 0 && seat.MicSessionID != "" { + occupiedSeats = append(occupiedSeats, occupiedMicSeat{ + userID: seat.UserID, + seatNo: seat.SeatNo, + micSessionID: seat.MicSessionID, + }) + } + } + current.Status = state.RoomStatusClosed current.OnlineUsers = map[int64]*state.RoomUserState{} for index := range current.MicSeats { @@ -75,6 +91,16 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) ( if err != nil { return mutationResult{}, nil, err } + records := make([]outbox.Record, 0, len(occupiedSeats)+1) + for _, seat := range occupiedSeats { + // 关房是 Room Cell 统一清场,给每个占麦 session 单独写 down 事件,保证用户时长能闭合。 + micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), seat.userID, seat.seatNo, seat.micSessionID, "room_closed") + if err != nil { + return mutationResult{}, nil, err + } + records = append(records, micEvent) + } + records = append(records, closedEvent) return mutationResult{ snapshot: current.ToProto(), @@ -89,7 +115,7 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) ( "reason": cmd.Reason, }, }, - }, []outbox.Record{closedEvent}, nil + }, records, nil }) if err != nil { return nil, err diff --git a/services/room-service/internal/room/service/mic_events.go b/services/room-service/internal/room/service/mic_events.go new file mode 100644 index 00000000..2fff62fb --- /dev/null +++ b/services/room-service/internal/room/service/mic_events.go @@ -0,0 +1,25 @@ +package service + +import ( + "time" + + roomeventsv1 "hyapp.local/api/proto/events/room/v1" + "hyapp/services/room-service/internal/room/outbox" + "hyapp/services/room-service/internal/room/state" +) + +// buildMicDownEventForSeat records every non-MicDown release path as the same +// RoomMicChanged/down fact, so downstream duration consumers never need to infer +// mic closure from leave/kick/close side effects. +func buildMicDownEventForSeat(roomID string, roomVersion int64, now time.Time, actorUserID int64, targetUserID int64, seatNo int32, micSessionID string, reason string) (outbox.Record, error) { + return outbox.Build(roomID, "RoomMicChanged", roomVersion, now, &roomeventsv1.RoomMicChanged{ + ActorUserId: actorUserID, + TargetUserId: targetUserID, + FromSeat: seatNo, + ToSeat: 0, + Action: "down", + MicSessionId: micSessionID, + PublishState: state.MicPublishIdle, + Reason: reason, + }) +} diff --git a/services/room-service/internal/room/service/moderation.go b/services/room-service/internal/room/service/moderation.go index d0c94833..b7e4d2ab 100644 --- a/services/room-service/internal/room/service/moderation.go +++ b/services/room-service/internal/room/service/moderation.go @@ -123,9 +123,13 @@ func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*r return mutationResult{snapshot: current.ToProto()}, nil, nil } + var releasedSeatNo int32 + var releasedMicSessionID string if seat, exists := current.SeatByUser(cmd.TargetUserID); exists { // 被踢用户如果在麦上,必须先释放麦位,避免恢复后出现幽灵占位。 index := current.SeatIndex(seat.SeatNo) + releasedSeatNo = seat.SeatNo + releasedMicSessionID = current.MicSeats[index].MicSessionID current.ClearMicSession(index) } @@ -142,6 +146,16 @@ func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*r if err != nil { return mutationResult{}, nil, err } + records := make([]outbox.Record, 0, 2) + if releasedMicSessionID != "" { + // 踢人释放麦位必须也生成 down 事实,时长聚合不能从 RoomUserKicked 反推麦位结束。 + micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.TargetUserID, releasedSeatNo, releasedMicSessionID, "kick") + if err != nil { + return mutationResult{}, nil, err + } + records = append(records, micEvent) + } + records = append(records, kickEvent) return mutationResult{ snapshot: current.ToProto(), @@ -153,7 +167,7 @@ func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*r TargetUserID: cmd.TargetUserID, RoomVersion: current.Version, }, - }, []outbox.Record{kickEvent}, nil + }, records, nil }) if err != nil { return nil, err diff --git a/services/room-service/internal/room/service/presence.go b/services/room-service/internal/room/service/presence.go index 2a68253e..acb174ae 100644 --- a/services/room-service/internal/room/service/presence.go +++ b/services/room-service/internal/room/service/presence.go @@ -165,15 +165,30 @@ func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason s } delete(current.OnlineUsers, cmd.ActorUserID()) + var micSessionID string if onSeat { - // 离房必须同步释放麦位,但对外只发 RoomUserLeft 主事件,避免客户端处理双事件竞态。 + // 离房必须同步释放麦位;同时写 RoomMicChanged/down,给时长聚合一个稳定闭合事件。 index := current.SeatIndex(seat.SeatNo) if index >= 0 { + micSessionID = current.MicSeats[index].MicSessionID current.ClearMicSession(index) } } current.Version++ + records := make([]outbox.Record, 0, 2) + if onSeat && micSessionID != "" { + micReason := reason + if micReason == "" { + micReason = "leave" + } + micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.ActorUserID(), seat.SeatNo, micSessionID, micReason) + if err != nil { + return mutationResult{}, nil, err + } + records = append(records, micEvent) + } + // 离房事件进入 outbox,并尝试同步通知腾讯云 IM;stale 清理会通过 attributes 标记原因。 leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{ UserId: cmd.ActorUserID(), @@ -181,6 +196,7 @@ func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason s if err != nil { return mutationResult{}, nil, err } + records = append(records, leftEvent) attributes := map[string]string{} if reason != "" { @@ -198,7 +214,7 @@ func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason s RoomVersion: current.Version, Attributes: attributes, }, - }, []outbox.Record{leftEvent}, nil + }, records, nil }) } diff --git a/services/room-service/internal/room/service/service_test.go b/services/room-service/internal/room/service/service_test.go index a986b371..86ac6e55 100644 --- a/services/room-service/internal/room/service/service_test.go +++ b/services/room-service/internal/room/service/service_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" @@ -622,6 +623,9 @@ func TestRoomLifecycleGiftAndGuards(t *testing.T) { if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" { t.Fatalf("unexpected presence guard response: %+v", presenceResp) } + if got := countPendingMicDownReason(t, repository, "kick"); got != 1 { + t.Fatalf("KickUser should write one RoomMicChanged/down reason=kick, got %d", got) + } } func TestRoomManagementPermissionsAndHostFlow(t *testing.T) { @@ -1180,6 +1184,9 @@ func TestCloseRoomClearsPresenceSeatsAndBlocksEntry(t *testing.T) { if seat := findSeat(closeResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" { t.Fatalf("closed room should release seats: %+v", seat) } + if got := countPendingMicDownReason(t, repository, "room_closed"); got != 1 { + t.Fatalf("CloseRoom should write one RoomMicChanged/down reason=room_closed, got %d", got) + } meta, exists, err := repository.GetRoomMeta(ctx, roomID) if err != nil || !exists || meta.Status != "closed" { t.Fatalf("room meta should be closed: exists=%v meta=%+v err=%v", exists, meta, err) @@ -1418,6 +1425,9 @@ func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) { if seat := findSeat(leaveResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 { t.Fatalf("leave should release seat: %+v", seat) } + if got := countPendingMicDownReason(t, repository, "leave"); got != 1 { + t.Fatalf("LeaveRoom should write one RoomMicChanged/down reason=leave, got %d", got) + } speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2}) if err != nil { @@ -1936,6 +1946,31 @@ func countPendingOutboxType(t *testing.T, repository roomservice.Repository, eve return count } +func countPendingMicDownReason(t *testing.T, repository roomservice.Repository, reason string) int { + t.Helper() + + records, err := repository.ListPendingOutbox(context.Background(), 0) + if err != nil { + t.Fatalf("ListPendingOutbox failed: %v", err) + } + + count := 0 + for _, record := range records { + if record.EventType != "RoomMicChanged" || record.Envelope == nil { + continue + } + var body roomeventsv1.RoomMicChanged + if err := proto.Unmarshal(record.Envelope.GetBody(), &body); err != nil { + t.Fatalf("decode RoomMicChanged failed: %v", err) + } + if body.GetAction() == "down" && body.GetReason() == reason { + count++ + } + } + + return count +} + func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser { for _, user := range snapshot.GetOnlineUsers() { if user.GetUserId() == userID { diff --git a/services/user-service/configs/config.docker.yaml b/services/user-service/configs/config.docker.yaml index 1df7a15a..b9d5aca6 100644 --- a/services/user-service/configs/config.docker.yaml +++ b/services/user-service/configs/config.docker.yaml @@ -20,6 +20,16 @@ region_rebuild_worker: poll_interval_sec: 60 lock_ttl_sec: 30 batch_size: 500 +invite_recharge_worker: + enabled: true + wallet_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local" + poll_interval_sec: 5 + batch_size: 100 +mic_time_worker: + enabled: true + room_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local" + poll_interval_sec: 5 + batch_size: 100 jwt: issuer: "hyapp" access_token_ttl_sec: 1800 diff --git a/services/user-service/configs/config.tencent.example.yaml b/services/user-service/configs/config.tencent.example.yaml index 1c6c2d6a..f02447da 100644 --- a/services/user-service/configs/config.tencent.example.yaml +++ b/services/user-service/configs/config.tencent.example.yaml @@ -20,6 +20,16 @@ region_rebuild_worker: poll_interval_sec: 60 lock_ttl_sec: 30 batch_size: 500 +invite_recharge_worker: + enabled: true + wallet_mysql_dsn: "${TENCENT_MYSQL_WALLET_DSN}" + poll_interval_sec: 5 + batch_size: 100 +mic_time_worker: + enabled: true + room_mysql_dsn: "${TENCENT_MYSQL_ROOM_DSN}" + poll_interval_sec: 5 + batch_size: 100 jwt: issuer: "hyapp" access_token_ttl_sec: 1800 diff --git a/services/user-service/configs/config.yaml b/services/user-service/configs/config.yaml index 6606fb73..cc3d2fc3 100644 --- a/services/user-service/configs/config.yaml +++ b/services/user-service/configs/config.yaml @@ -20,6 +20,16 @@ region_rebuild_worker: poll_interval_sec: 60 lock_ttl_sec: 30 batch_size: 500 +invite_recharge_worker: + enabled: true + wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local" + poll_interval_sec: 5 + batch_size: 100 +mic_time_worker: + enabled: true + room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local" + poll_interval_sec: 5 + batch_size: 100 jwt: issuer: "hyapp" access_token_ttl_sec: 1800 diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index f870a01b..5943de0f 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -190,6 +190,197 @@ CREATE TABLE IF NOT EXISTS user_push_tokens ( KEY idx_user_push_tokens_updated (app_code, status, updated_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS user_invite_codes ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + invite_code_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + owner_user_id BIGINT NOT NULL, + code VARCHAR(64) NOT NULL, + code_type VARCHAR(32) NOT NULL DEFAULT 'primary', + status VARCHAR(32) NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + UNIQUE KEY uk_user_invite_codes_code (app_code, code), + UNIQUE KEY uk_user_invite_codes_owner_primary (app_code, owner_user_id, code_type), + KEY idx_user_invite_codes_owner (app_code, owner_user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_invite_relations ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + relation_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + invited_user_id BIGINT NOT NULL, + inviter_user_id BIGINT NOT NULL, + invite_code_id BIGINT NOT NULL, + invite_code VARCHAR(64) NOT NULL, + source VARCHAR(64) NOT NULL, + request_id VARCHAR(96) NOT NULL DEFAULT '', + bound_at_ms BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + UNIQUE KEY uk_user_invite_relations_invited (app_code, invited_user_id), + KEY idx_user_invite_relations_inviter (app_code, inviter_user_id, bound_at_ms), + KEY idx_user_invite_relations_code (app_code, invite_code, bound_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_invite_validity_policies ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + policy_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + region_id BIGINT NOT NULL DEFAULT 0, + policy_version VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + required_recharge_coin_amount BIGINT NOT NULL, + eligible_recharge_types JSON NOT NULL, + effective_from_ms BIGINT NOT NULL, + effective_to_ms BIGINT NULL, + created_by_user_id BIGINT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + UNIQUE KEY uk_user_invite_policy_version (app_code, region_id, policy_version), + KEY idx_user_invite_policies_active (app_code, region_id, status, effective_from_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO user_invite_validity_policies ( + app_code, region_id, policy_version, status, required_recharge_coin_amount, + eligible_recharge_types, effective_from_ms, effective_to_ms, created_by_user_id, created_at_ms, updated_at_ms +) VALUES ( + 'lalu', 0, 'invite-valid-default-v1', 'active', 80000, + JSON_ARRAY('coin_seller_transfer'), 0, NULL, NULL, 0, 0 +) ON DUPLICATE KEY UPDATE + status = VALUES(status), + required_recharge_coin_amount = VALUES(required_recharge_coin_amount), + eligible_recharge_types = VALUES(eligible_recharge_types), + effective_from_ms = VALUES(effective_from_ms), + effective_to_ms = VALUES(effective_to_ms), + updated_at_ms = VALUES(updated_at_ms); + +CREATE TABLE IF NOT EXISTS user_invite_recharge_progress ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + invited_user_id BIGINT NOT NULL, + inviter_user_id BIGINT NOT NULL, + policy_id BIGINT NOT NULL, + policy_version VARCHAR(64) NOT NULL, + required_recharge_coin_amount BIGINT NOT NULL, + accumulated_recharge_coin_amount BIGINT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL, + valid_at_ms BIGINT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, invited_user_id), + KEY idx_user_invite_progress_inviter_status (app_code, inviter_user_id, status, updated_at_ms), + KEY idx_user_invite_progress_policy (app_code, policy_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_invite_event_consumption ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + event_id VARCHAR(128) NOT NULL, + event_type VARCHAR(64) NOT NULL, + transaction_id VARCHAR(96) NOT NULL, + invited_user_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL, + skip_reason VARCHAR(128) NOT NULL DEFAULT '', + consumed_at_ms BIGINT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, event_id), + UNIQUE KEY uk_user_invite_event_tx (app_code, transaction_id, event_type), + KEY idx_user_invite_event_user (app_code, invited_user_id, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_invite_counters ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + user_id BIGINT NOT NULL, + invite_count BIGINT NOT NULL DEFAULT 0, + valid_invite_count BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_outbox ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + event_id VARCHAR(128) NOT NULL, + event_type VARCHAR(64) NOT NULL, + aggregate_type VARCHAR(64) NOT NULL, + aggregate_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL, + payload_json JSON NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, event_id), + KEY idx_user_outbox_status_created (app_code, status, created_at_ms), + KEY idx_user_outbox_aggregate (app_code, aggregate_type, aggregate_id, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_mic_sessions ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + mic_session_id VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL, + room_id VARCHAR(64) NOT NULL, + first_seat_no INT NOT NULL, + current_seat_no INT NOT NULL, + status VARCHAR(32) NOT NULL, + seat_started_at_ms BIGINT NOT NULL, + publishing_started_at_ms BIGINT NULL, + ended_at_ms BIGINT NULL, + end_reason VARCHAR(64) NOT NULL DEFAULT '', + seat_occupied_ms BIGINT NOT NULL DEFAULT 0, + mic_online_ms BIGINT NOT NULL DEFAULT 0, + opened_event_id VARCHAR(128) NOT NULL, + publish_event_id VARCHAR(128) NOT NULL DEFAULT '', + closed_event_id VARCHAR(128) NOT NULL DEFAULT '', + last_event_id VARCHAR(128) NOT NULL, + last_room_version BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, mic_session_id), + KEY idx_user_mic_sessions_user_status (app_code, user_id, status, updated_at_ms), + KEY idx_user_mic_sessions_room_status (app_code, room_id, status), + KEY idx_user_mic_sessions_open_timeout (app_code, status, seat_started_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_mic_daily_stats ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + user_id BIGINT NOT NULL, + stat_date CHAR(10) NOT NULL, + seat_occupied_ms BIGINT NOT NULL DEFAULT 0, + mic_online_ms BIGINT NOT NULL DEFAULT 0, + session_count BIGINT NOT NULL DEFAULT 0, + room_count BIGINT NOT NULL DEFAULT 0, + first_mic_at_ms BIGINT NOT NULL DEFAULT 0, + last_mic_at_ms BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id, stat_date), + KEY idx_user_mic_daily_date (app_code, stat_date, mic_online_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_mic_lifetime_stats ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + user_id BIGINT NOT NULL, + seat_occupied_ms BIGINT NOT NULL DEFAULT 0, + mic_online_ms BIGINT NOT NULL DEFAULT 0, + session_count BIGINT NOT NULL DEFAULT 0, + first_mic_at_ms BIGINT NOT NULL DEFAULT 0, + last_mic_at_ms BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, user_id), + KEY idx_user_mic_lifetime_mic_online (app_code, mic_online_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_mic_event_consumption ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', + event_id VARCHAR(128) NOT NULL, + event_type VARCHAR(64) NOT NULL, + mic_session_id VARCHAR(128) NOT NULL, + user_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + skip_reason VARCHAR(128) NOT NULL DEFAULT '', + created_at_ms BIGINT NOT NULL, + consumed_at_ms BIGINT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, event_id), + KEY idx_user_mic_event_session (app_code, mic_session_id, created_at_ms), + KEY idx_user_mic_event_user (app_code, user_id, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS host_profiles ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', user_id BIGINT NOT NULL PRIMARY KEY, diff --git a/services/user-service/internal/app/app.go b/services/user-service/internal/app/app.go index f9310275..3f8420f8 100644 --- a/services/user-service/internal/app/app.go +++ b/services/user-service/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "context" + "database/sql" "errors" "net" "sync" @@ -15,8 +16,12 @@ import ( "hyapp/services/user-service/internal/config" authservice "hyapp/services/user-service/internal/service/auth" hostservice "hyapp/services/user-service/internal/service/host" + inviteservice "hyapp/services/user-service/internal/service/invite" + mictimeservice "hyapp/services/user-service/internal/service/mictime" userservice "hyapp/services/user-service/internal/service/user" mysqlstorage "hyapp/services/user-service/internal/storage/mysql" + invitestorage "hyapp/services/user-service/internal/storage/mysql/invite" + mictimestorage "hyapp/services/user-service/internal/storage/mysql/mictime" grpcserver "hyapp/services/user-service/internal/transport/grpc" "google.golang.org/grpc" @@ -33,13 +38,21 @@ type App struct { health *grpchealth.ServingChecker // mysqlRepo 持有用户主数据、认证身份、session 和短号事务的唯一运行时存储。 mysqlRepo *mysqlstorage.Repository + // walletDB 只用于读取 wallet_outbox 充值事实,实际账务状态仍由 wallet-service owner。 + walletDB *sql.DB + // roomDB 只用于读取 room_outbox 麦位事实,Room Cell 状态仍由 room-service owner。 + roomDB *sql.DB // userSvc 持有用户主数据用例,后台 region rebuild worker 复用它消费任务。 userSvc *userservice.Service + // inviteSvc 消费 wallet recharge outbox,维护有效邀请 read model。 + inviteSvc *inviteservice.Service + // micTimeSvc 消费 room mic outbox,维护用户维度麦上时长 read model。 + micTimeSvc *mictimeservice.Service // cfg 保存 worker 运行参数,避免 Run 阶段重新读配置。 cfg config.Config // workerCtx 统一控制后台 worker 生命周期。 workerCtx context.Context - // workerCancel 停止 region rebuild worker。 + // workerCancel 停止 region rebuild、invite recharge 和 mic time worker。 workerCancel context.CancelFunc // workerWG 等待后台 worker 当前批次安全退出。 workerWG sync.WaitGroup @@ -83,6 +96,8 @@ func New(cfg config.Config) (*App, error) { regionRepo := mysqlRepo.RegionRepository() deviceRepo := mysqlRepo.DeviceRepository() hostRepo := mysqlRepo.HostRepository() + inviteRepo := mysqlRepo.InviteRepository() + micTimeRepo := mysqlRepo.MicTimeRepository() // auth service 负责登录、session 和 token;用户主数据和短号事务仍通过各自领域存储完成。 authSvc := authservice.New(authservice.Config{ @@ -115,7 +130,48 @@ func New(cfg config.Config) (*App, error) { hostSvc := hostservice.New(hostRepo, hostservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)), ) + var walletDB *sql.DB + var inviteSvc *inviteservice.Service + if cfg.InviteRechargeWorker.Enabled { + walletDB, err = sql.Open("mysql", cfg.InviteRechargeWorker.WalletMySQLDSN) + if err != nil { + _ = listener.Close() + _ = mysqlRepo.Close() + return nil, err + } + if err := walletDB.PingContext(startupCtx); err != nil { + _ = walletDB.Close() + _ = listener.Close() + _ = mysqlRepo.Close() + return nil, err + } + inviteSvc = inviteservice.New(inviteRepo, invitestorage.NewWalletOutboxReader(walletDB)) + } + var roomDB *sql.DB + micTimeSvc := mictimeservice.New(micTimeRepo, nil) + if cfg.MicTimeWorker.Enabled { + roomDB, err = sql.Open("mysql", cfg.MicTimeWorker.RoomMySQLDSN) + if err != nil { + if walletDB != nil { + _ = walletDB.Close() + } + _ = listener.Close() + _ = mysqlRepo.Close() + return nil, err + } + if err := roomDB.PingContext(startupCtx); err != nil { + _ = roomDB.Close() + if walletDB != nil { + _ = walletDB.Close() + } + _ = listener.Close() + _ = mysqlRepo.Close() + return nil, err + } + micTimeSvc = mictimeservice.New(micTimeRepo, mictimestorage.NewRoomOutboxReader(roomDB)) + } userServer := grpcserver.NewServer(authSvc, userSvc, hostSvc) + userServer.SetMicTimeService(micTimeSvc) // 多个 protobuf service 共用一个 Server 适配器,领域逻辑仍拆在 auth/user/host service。 userv1.RegisterAuthServiceServer(server, userServer) userv1.RegisterUserServiceServer(server, userServer) @@ -138,7 +194,11 @@ func New(cfg config.Config) (*App, error) { listener: listener, health: health, mysqlRepo: mysqlRepo, + walletDB: walletDB, + roomDB: roomDB, userSvc: userSvc, + inviteSvc: inviteSvc, + micTimeSvc: micTimeSvc, cfg: cfg, workerCtx: workerCtx, workerCancel: workerCancel, @@ -167,6 +227,28 @@ func (a *App) Run() error { }) }() } + if a.cfg.InviteRechargeWorker.Enabled && a.inviteSvc != nil { + a.workerWG.Add(1) + go func() { + defer a.workerWG.Done() + a.inviteSvc.RunWalletRechargeWorker(a.workerCtx, inviteservice.WorkerOptions{ + WorkerID: a.cfg.NodeID, + PollInterval: time.Duration(a.cfg.InviteRechargeWorker.PollIntervalSec) * time.Second, + BatchSize: a.cfg.InviteRechargeWorker.BatchSize, + }) + }() + } + if a.cfg.MicTimeWorker.Enabled && a.micTimeSvc != nil { + a.workerWG.Add(1) + go func() { + defer a.workerWG.Done() + a.micTimeSvc.RunRoomMicWorker(a.workerCtx, mictimeservice.WorkerOptions{ + WorkerID: a.cfg.NodeID, + PollInterval: time.Duration(a.cfg.MicTimeWorker.PollIntervalSec) * time.Second, + BatchSize: a.cfg.MicTimeWorker.BatchSize, + }) + }() + } err := a.server.Serve(a.listener) a.health.MarkStopped() if errors.Is(err, grpc.ErrServerStopped) { @@ -192,5 +274,11 @@ func (a *App) Close() { // MySQL 连接池最后关闭,保证 GracefulStop 期间 repository 仍可用。 _ = a.mysqlRepo.Close() } + if a.walletDB != nil { + _ = a.walletDB.Close() + } + if a.roomDB != nil { + _ = a.roomDB.Close() + } }) } diff --git a/services/user-service/internal/config/config.go b/services/user-service/internal/config/config.go index be139526..a79ef90a 100644 --- a/services/user-service/internal/config/config.go +++ b/services/user-service/internal/config/config.go @@ -23,6 +23,10 @@ type Config struct { ThirdParty ThirdPartyConfig `yaml:"third_party"` // RegionRebuildWorker 控制历史用户 region_id 快照后台重算。 RegionRebuildWorker RegionRebuildWorkerConfig `yaml:"region_rebuild_worker"` + // InviteRechargeWorker 控制邀请有效人数的 wallet recharge outbox 消费。 + InviteRechargeWorker InviteRechargeWorkerConfig `yaml:"invite_recharge_worker"` + // MicTimeWorker 控制 room-service 麦位事件消费和用户麦上时长聚合。 + MicTimeWorker MicTimeWorkerConfig `yaml:"mic_time_worker"` // JWT 控制 access token 和 refresh session 的签发参数。 JWT JWTConfig `yaml:"jwt"` } @@ -71,6 +75,30 @@ type RegionRebuildWorkerConfig struct { BatchSize int `yaml:"batch_size"` } +// InviteRechargeWorkerConfig 保存钱包充值事实消费策略。 +type InviteRechargeWorkerConfig struct { + // Enabled 控制是否启动钱包充值事件轮询。 + Enabled bool `yaml:"enabled"` + // WalletMySQLDSN 是 wallet-service outbox 只读连接串;本地可复用同一个 MySQL 实例。 + WalletMySQLDSN string `yaml:"wallet_mysql_dsn"` + // PollIntervalSec 是没有积压时两轮扫描间隔秒数。 + PollIntervalSec int64 `yaml:"poll_interval_sec"` + // BatchSize 是单轮最多消费的 wallet_outbox 事件数。 + BatchSize int `yaml:"batch_size"` +} + +// MicTimeWorkerConfig 保存房间麦位事件消费策略。 +type MicTimeWorkerConfig struct { + // Enabled 控制是否启动 room_outbox 轮询;关闭后查询仍可读取已聚合数据。 + Enabled bool `yaml:"enabled"` + // RoomMySQLDSN 是 room-service outbox 只读连接串。 + RoomMySQLDSN string `yaml:"room_mysql_dsn"` + // PollIntervalSec 是没有积压时两轮扫描间隔秒数。 + PollIntervalSec int64 `yaml:"poll_interval_sec"` + // BatchSize 是单轮最多消费的 RoomMicChanged 事件数。 + BatchSize int `yaml:"batch_size"` +} + // JWTConfig 保存 access token 和 refresh token 的签发策略。 type JWTConfig struct { // Issuer 写入 access token iss claim。 @@ -113,6 +141,18 @@ func Default() Config { LockTTLSec: 30, BatchSize: 500, }, + InviteRechargeWorker: InviteRechargeWorkerConfig{ + Enabled: true, + WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local", + PollIntervalSec: 5, + BatchSize: 100, + }, + MicTimeWorker: MicTimeWorkerConfig{ + Enabled: true, + RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local", + PollIntervalSec: 5, + BatchSize: 100, + }, JWT: JWTConfig{ Issuer: "hyapp", AccessTokenTTLSec: 1800, diff --git a/services/user-service/internal/domain/invite/invite.go b/services/user-service/internal/domain/invite/invite.go new file mode 100644 index 00000000..1abedd0f --- /dev/null +++ b/services/user-service/internal/domain/invite/invite.go @@ -0,0 +1,102 @@ +// Package invite defines user-service invitation facts and read models. +package invite + +import ( + "regexp" + "strings" +) + +const ( + // CodeStatusActive means a code can be used by new registrations. + CodeStatusActive = "active" + // CodeTypePrimary is the shareable per-user permanent invite code. + CodeTypePrimary = "primary" + // ProgressStatusPending means the invited user has not reached the configured recharge threshold. + ProgressStatusPending = "pending" + // ProgressStatusValid means the invited user has reached the threshold and counted once. + ProgressStatusValid = "valid" + // EventTypeUserInvited is emitted when an invitation relation is first bound. + EventTypeUserInvited = "UserInvited" + // EventTypeUserInviteBecameValid is emitted when recharge progress first crosses the policy threshold. + EventTypeUserInviteBecameValid = "UserInviteBecameValid" + // RechargeEventWalletRecorded is the wallet outbox event that invitation validity consumes. + RechargeEventWalletRecorded = "WalletRechargeRecorded" + // RechargeTypeCoinSellerTransfer is the current recharge fact produced by wallet-service coin seller transfer. + RechargeTypeCoinSellerTransfer = "coin_seller_transfer" +) + +const ( + // CodeMinLength keeps user-entered invite codes short enough for mobile manual entry. + CodeMinLength = 6 + // CodeMaxLength follows the current SQL VARCHAR boundary for invite code snapshots. + CodeMaxLength = 64 +) + +var codePattern = regexp.MustCompile(`^[A-Z0-9]{6,64}$`) + +// Overview is the read model returned with user profile for my-page aggregation. +type Overview struct { + MyInviteCode string + InviteEnabled bool + InviteCount int64 + ValidInviteCount int64 + ValidInviteThresholdCoin int64 +} + +// Binding is the result of one registration/onboarding invitation bind attempt. +type Binding struct { + Bound bool + InviteCode string + InviterUserID int64 +} + +// BindCommand describes one optional invitation attribution write. +type BindCommand struct { + AppCode string + InvitedUserID int64 + InvitedRegionID int64 + InviteCode string + Source string + RequestID string + BoundAtMs int64 +} + +// RechargeEvent is the wallet recharge fact consumed by invitation validity. +type RechargeEvent struct { + AppCode string + EventID string + EventType string + TransactionID string + CommandID string + InvitedUserID int64 + AssetType string + RechargeCoinAmount int64 + RechargeType string + OccurredAtMs int64 + PayloadJSON string +} + +// RechargeApplyResult summarizes idempotent invitation recharge processing. +type RechargeApplyResult struct { + Consumed bool + Skipped bool + Valid bool + Reason string +} + +// WalletRechargeCursor lets a worker scan wallet_outbox in stable creation order. +type WalletRechargeCursor struct { + CreatedAtMs int64 + EventID string +} + +// NormalizeCode trims and uppercases user-provided invite code text. +func NormalizeCode(code string) string { + return strings.ToUpper(strings.TrimSpace(code)) +} + +// ValidCode checks only syntax; ownership and active status are repository facts. +func ValidCode(code string) bool { + code = NormalizeCode(code) + return len(code) >= CodeMinLength && len(code) <= CodeMaxLength && codePattern.MatchString(code) +} diff --git a/services/user-service/internal/domain/mictime/mictime.go b/services/user-service/internal/domain/mictime/mictime.go new file mode 100644 index 00000000..d0051404 --- /dev/null +++ b/services/user-service/internal/domain/mictime/mictime.go @@ -0,0 +1,77 @@ +// Package mictime defines user-scoped microphone duration facts. +package mictime + +const ( + // RoomMicChangedEventType is the room-service outbox event consumed by this read model. + RoomMicChangedEventType = "RoomMicChanged" + + // ActionUp opens a mic session and starts seat occupation time. + ActionUp = "up" + // ActionPublishConfirmed starts effective microphone online time for an open session. + ActionPublishConfirmed = "publish_confirmed" + // ActionChange moves the occupied seat without splitting duration. + ActionChange = "change" + // ActionDown closes a session and rolls duration into daily/lifetime aggregates. + ActionDown = "down" + + // PublishStatePending means the user occupies a business mic seat but has not confirmed RTC audio. + PublishStatePending = "pending_publish" + // PublishStatePublishing means RTC audio has been confirmed for the session. + PublishStatePublishing = "publishing" + // PublishStateIdle means the seat has been released. + PublishStateIdle = "idle" + + // SessionStatusPendingPublish is persisted before RTC audio confirmation. + SessionStatusPendingPublish = "pending_publish" + // SessionStatusPublishing is persisted after publish_confirmed. + SessionStatusPublishing = "publishing" + // SessionStatusEnded is terminal and must never be reopened by replayed events. + SessionStatusEnded = "ended" +) + +// RoomMicEvent is the decoded RoomMicChanged fact used by user-service duration aggregation. +type RoomMicEvent struct { + AppCode string + EventID string + EventType string + RoomID string + RoomVersion int64 + OutboxCreatedAtMs int64 + OccurredAtMs int64 + ActorUserID int64 + TargetUserID int64 + FromSeat int32 + ToSeat int32 + Action string + MicSessionID string + PublishState string + Reason string + PublishDeadlineMs int64 + PublishEventTimeMs int64 +} + +// RoomOutboxCursor lets the worker scan room_outbox in stable creation order. +type RoomOutboxCursor struct { + CreatedAtMs int64 + EventID string +} + +// ApplyResult describes one idempotent event application. +type ApplyResult struct { + Consumed bool + Skipped bool + Closed bool + Reason string +} + +// LifetimeStats is the user-wide aggregate consumed by host tasks and profile views. +type LifetimeStats struct { + AppCode string + UserID int64 + SeatOccupiedMs int64 + MicOnlineMs int64 + SessionCount int64 + FirstMicAtMs int64 + LastMicAtMs int64 + UpdatedAtMs int64 +} diff --git a/services/user-service/internal/domain/user/user.go b/services/user-service/internal/domain/user/user.go index f8a9a8aa..1fadfe6d 100644 --- a/services/user-service/internal/domain/user/user.go +++ b/services/user-service/internal/domain/user/user.go @@ -1,7 +1,11 @@ // Package user 定义 user-service 的用户主数据、展示短号和临时靓号领域模型。 package user -import "regexp" +import ( + "regexp" + + invitedomain "hyapp/services/user-service/internal/domain/invite" +) // Status 是用户主状态,业务服务只应该依赖这里的稳定语义。 type Status string @@ -111,6 +115,10 @@ type User struct { RegionName string // InviteCode 是注册时使用的邀请码,空值表示未使用邀请关系。 InviteCode string + // InviteOverview 是我的页读取的邀请码和计数 read model,不参与用户主状态决策。 + InviteOverview invitedomain.Overview + // InviteBinding 是注册完成请求本次邀请归因结果,只服务入口响应,不作为关系事实来源。 + InviteBinding invitedomain.Binding // RegisterIP 是注册请求携带或 gateway 观察到的注册 IP 快照。 RegisterIP string // RegisterUserAgent 是 gateway 从 HTTP header 观察到的注册 UA 快照。 @@ -229,6 +237,10 @@ type CompleteOnboardingCommand struct { Country string // RegionID 是按 Country 当前 active 映射解析出的区域快照,0 表示 GLOBAL 兜底桶。 RegionID int64 + // InviteCode 是注册页可选的邀请码;非空时 repository 必须解析并绑定关系。 + InviteCode string + // RequestID 是入口请求 ID,写入邀请关系和 outbox 便于排查。 + RequestID string // CompletedAtMs 是资料完成时间,同时作为 updated_at_ms。 CompletedAtMs int64 } diff --git a/services/user-service/internal/service/auth/service.go b/services/user-service/internal/service/auth/service.go index 805dfa9f..961e6b25 100644 --- a/services/user-service/internal/service/auth/service.go +++ b/services/user-service/internal/service/auth/service.go @@ -7,6 +7,7 @@ import ( "hyapp/pkg/idgen" authdomain "hyapp/services/user-service/internal/domain/auth" + invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" userservice "hyapp/services/user-service/internal/service/user" ) @@ -54,7 +55,7 @@ type AuthRepository interface { // FindThirdPartyIdentity 查找 provider + subject 的绑定。 FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error) // CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。 - CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error + CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand) error // RecordLoginAudit 记录登录审计,主链路不会因为审计失败而失败。 RecordLoginAudit(ctx context.Context, audit authdomain.LoginAudit) error } diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index b7d538a1..b86229ce 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -208,7 +208,7 @@ func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) { } now = time.UnixMilli(2000) - if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG"); err != nil { + if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil { t.Fatalf("CompleteOnboarding failed: %v", err) } @@ -236,7 +236,7 @@ func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotati t.Fatalf("LoginThirdParty failed: %v", err) } now = time.UnixMilli(2000) - if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG"); err != nil { + if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "male", "SG", "", ""); err != nil { t.Fatalf("CompleteOnboarding failed: %v", err) } @@ -318,11 +318,35 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) { seedCountry(t, repository, "CN") now := time.UnixMilli(1000) svc := newAuthService(repository, &now, []int64{900003}, []string{"100003"}) + if err := repository.CreateUserWithIdentity(ctx, userdomain.User{ + AppCode: "lalu", + UserID: 800001, + DefaultDisplayUserID: "188001", + CurrentDisplayUserID: "188001", + CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault, + Status: userdomain.StatusActive, + OnboardingStatus: userdomain.OnboardingStatusProfileRequired, + CreatedAtMs: now.UnixMilli(), + UpdatedAtMs: now.UnixMilli(), + }, userdomain.Identity{ + AppCode: "lalu", + UserID: 800001, + DisplayUserID: "188001", + DefaultDisplayUserID: "188001", + DisplayUserIDKind: userdomain.DisplayUserIDKindDefault, + Status: userdomain.DisplayUserIDStatusActive, + }); err != nil { + t.Fatalf("seed inviter failed: %v", err) + } + inviter, err := repository.GetUser(ctx, 800001) + if err != nil { + t.Fatalf("get inviter failed: %v", err) + } registration := authdomain.ThirdPartyRegistration{ Username: " hy ", Gender: "male", Country: "CN", - InviteCode: "INV-1", + InviteCode: inviter.InviteOverview.MyInviteCode, IP: "198.51.100.9", DeviceID: " dev-1 ", Device: "iPhone 15", @@ -351,7 +375,7 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) { if err != nil { t.Fatalf("GetUser failed: %v", err) } - if user.Username != "hy" || user.Gender != "male" || user.Country != "CN" || user.InviteCode != "INV-1" { + if user.Username != "hy" || user.Gender != "male" || user.Country != "CN" || user.InviteCode != inviter.InviteOverview.MyInviteCode { t.Fatalf("profile fields mismatch: %+v", user) } if user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "CN" { diff --git a/services/user-service/internal/service/auth/third_party.go b/services/user-service/internal/service/auth/third_party.go index 11077e3b..3a518f69 100644 --- a/services/user-service/internal/service/auth/third_party.go +++ b/services/user-service/internal/service/auth/third_party.go @@ -11,6 +11,7 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/xerr" authdomain "hyapp/services/user-service/internal/domain/auth" + invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" ) @@ -147,7 +148,16 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro UpdatedAtMs: user.UpdatedAtMs, } - err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session) + inviteBind := invitedomain.BindCommand{ + AppCode: registration.AppCode, + InvitedUserID: user.UserID, + InvitedRegionID: registration.RegionID, + InviteCode: registration.InviteCode, + Source: "third_party_registration", + RequestID: meta.RequestID, + BoundAtMs: user.CreatedAtMs, + } + err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session, inviteBind) if err == nil { // repository 事务成功后,用户、默认短号、三方绑定和首个 session 同时存在。 s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "") @@ -184,7 +194,7 @@ func normalizeThirdPartyRegistration(registration authdomain.ThirdPartyRegistrat registration.Username = strings.TrimSpace(registration.Username) registration.Gender = strings.TrimSpace(registration.Gender) registration.Country = strings.ToUpper(strings.TrimSpace(registration.Country)) - registration.InviteCode = strings.TrimSpace(registration.InviteCode) + registration.InviteCode = invitedomain.NormalizeCode(registration.InviteCode) // 注册 IP 和 UA 只能来自 gateway 观察到的请求上下文,不能信任公网 JSON body。 registration.IP = strings.TrimSpace(meta.ClientIP) registration.UserAgent = strings.TrimSpace(meta.UserAgent) @@ -214,6 +224,10 @@ func validateThirdPartyRegistration(registration authdomain.ThirdPartyRegistrati // 用户选择国家只接受 2 到 3 位大写英文国家码,是否可选由 countries 表决定。 return xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters") } + if registration.InviteCode != "" && !invitedomain.ValidCode(registration.InviteCode) { + // 邀请码错误统一返回 INVALID_INVITE_CODE,避免客户端通过错误细节枚举邀请码。 + return xerr.New(xerr.InvalidInviteCode, "invalid invite code") + } if registration.BirthDate != "" { if !userdomain.ValidProfileBirthDate(registration.BirthDate) { // 生日只保存日期,不接受带时区或非零填充格式,避免跨端解析不一致。 diff --git a/services/user-service/internal/service/invite/service.go b/services/user-service/internal/service/invite/service.go new file mode 100644 index 00000000..cf787f8d --- /dev/null +++ b/services/user-service/internal/service/invite/service.go @@ -0,0 +1,87 @@ +// Package invite runs asynchronous invite validity consumers. +package invite + +import ( + "context" + "log" + "time" + + invitedomain "hyapp/services/user-service/internal/domain/invite" +) + +// Repository applies wallet recharge events to user-service invite progress. +type Repository interface { + ApplyRechargeEvent(ctx context.Context, event invitedomain.RechargeEvent) (invitedomain.RechargeApplyResult, error) +} + +// WalletOutboxReader scans wallet recharge facts in creation order. +type WalletOutboxReader interface { + ListRechargeEventsAfter(ctx context.Context, cursor invitedomain.WalletRechargeCursor, batchSize int) ([]invitedomain.RechargeEvent, error) +} + +// Service owns the invite validity event consumer. +type Service struct { + repository Repository + reader WalletOutboxReader +} + +// WorkerOptions controls local wallet outbox polling. +type WorkerOptions struct { + WorkerID string + PollInterval time.Duration + BatchSize int +} + +// New creates an invite service with persistence and wallet outbox reader dependencies. +func New(repository Repository, reader WalletOutboxReader) *Service { + return &Service{repository: repository, reader: reader} +} + +// RunWalletRechargeWorker consumes WalletRechargeRecorded facts and advances valid_invite_count. +func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOptions) { + if s == nil || s.repository == nil || s.reader == nil { + log.Printf("component=user_invite op=wallet_recharge_worker status=disabled reason=missing_dependency") + return + } + if options.PollInterval <= 0 { + options.PollInterval = 5 * time.Second + } + if options.BatchSize <= 0 { + options.BatchSize = 100 + } + + var cursor invitedomain.WalletRechargeCursor + for { + processed, err := s.processBatch(ctx, &cursor, options.BatchSize) + if err != nil { + log.Printf("component=user_invite op=wallet_recharge_worker worker_id=%s status=failed error=%q", options.WorkerID, err.Error()) + } + if ctx.Err() != nil { + return + } + if processed >= options.BatchSize { + // 有积压时不 sleep,直到追上 wallet_outbox 当前末尾。 + continue + } + select { + case <-ctx.Done(): + return + case <-time.After(options.PollInterval): + } + } +} + +func (s *Service) processBatch(ctx context.Context, cursor *invitedomain.WalletRechargeCursor, batchSize int) (int, error) { + events, err := s.reader.ListRechargeEventsAfter(ctx, *cursor, batchSize) + if err != nil { + return 0, err + } + for _, event := range events { + if _, err := s.repository.ApplyRechargeEvent(ctx, event); err != nil { + return 0, err + } + cursor.CreatedAtMs = event.OccurredAtMs + cursor.EventID = event.EventID + } + return len(events), nil +} diff --git a/services/user-service/internal/service/mictime/service.go b/services/user-service/internal/service/mictime/service.go new file mode 100644 index 00000000..7efd858e --- /dev/null +++ b/services/user-service/internal/service/mictime/service.go @@ -0,0 +1,97 @@ +// Package mictime runs room mic outbox consumers and exposes mic duration queries. +package mictime + +import ( + "context" + "log" + "time" + + "hyapp/pkg/xerr" + mictimedomain "hyapp/services/user-service/internal/domain/mictime" +) + +// Repository applies room mic events and returns user duration aggregates. +type Repository interface { + ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) + GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error) +} + +// RoomOutboxReader scans room-service RoomMicChanged facts in creation order. +type RoomOutboxReader interface { + ListRoomMicEventsAfter(ctx context.Context, cursor mictimedomain.RoomOutboxCursor, batchSize int) ([]mictimedomain.RoomMicEvent, error) +} + +// Service owns the user mic duration read model. +type Service struct { + repository Repository + reader RoomOutboxReader +} + +// WorkerOptions controls local room outbox polling. +type WorkerOptions struct { + WorkerID string + PollInterval time.Duration + BatchSize int +} + +// New creates a mic time service with persistence and optional room outbox reader. +func New(repository Repository, reader RoomOutboxReader) *Service { + return &Service{repository: repository, reader: reader} +} + +// GetLifetimeStats returns user-wide accumulated mic duration. +func (s *Service) GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error) { + if s == nil || s.repository == nil { + return mictimedomain.LifetimeStats{}, xerr.New(xerr.Unavailable, "mic time repository is not configured") + } + return s.repository.GetLifetimeStats(ctx, appCode, userID) +} + +// RunRoomMicWorker consumes RoomMicChanged facts from room-service outbox. +func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) { + if s == nil || s.repository == nil || s.reader == nil { + log.Printf("component=user_mic_time op=room_mic_worker status=disabled reason=missing_dependency") + return + } + if options.PollInterval <= 0 { + options.PollInterval = 5 * time.Second + } + if options.BatchSize <= 0 { + options.BatchSize = 100 + } + + var cursor mictimedomain.RoomOutboxCursor + for { + processed, err := s.processBatch(ctx, &cursor, options.BatchSize) + if err != nil { + log.Printf("component=user_mic_time op=room_mic_worker worker_id=%s status=failed error=%q", options.WorkerID, err.Error()) + } + if ctx.Err() != nil { + return + } + if processed >= options.BatchSize { + // Backlog is still present; continue without sleeping until the cursor catches up. + continue + } + select { + case <-ctx.Done(): + return + case <-time.After(options.PollInterval): + } + } +} + +func (s *Service) processBatch(ctx context.Context, cursor *mictimedomain.RoomOutboxCursor, batchSize int) (int, error) { + events, err := s.reader.ListRoomMicEventsAfter(ctx, *cursor, batchSize) + if err != nil { + return 0, err + } + for _, event := range events { + if _, err := s.repository.ProcessRoomMicEvent(ctx, event); err != nil { + return 0, err + } + cursor.CreatedAtMs = event.OutboxCreatedAtMs + cursor.EventID = event.EventID + } + return len(events), nil +} diff --git a/services/user-service/internal/service/user/profile.go b/services/user-service/internal/service/user/profile.go index e3b08161..f0d73296 100644 --- a/services/user-service/internal/service/user/profile.go +++ b/services/user-service/internal/service/user/profile.go @@ -6,6 +6,7 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/xerr" + invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" ) @@ -76,11 +77,12 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username // CompleteOnboarding 是注册页唯一提交入口。 // 它必须一次性校验并写入 username/avatar/gender/country/region_id/profile_completed,避免客户端拆接口拼出半完成状态。 -func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username string, avatar string, gender string, country string) (userdomain.User, error) { +func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username string, avatar string, gender string, country string, inviteCode string, requestID string) (userdomain.User, error) { username = strings.TrimSpace(username) avatar = strings.TrimSpace(avatar) gender = strings.TrimSpace(gender) country = userdomain.NormalizeCountryCode(country) + inviteCode = invitedomain.NormalizeCode(inviteCode) if userID <= 0 { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required") } @@ -111,6 +113,10 @@ func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username if !userdomain.ValidCountryCode(country) { return userdomain.User{}, xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters") } + if inviteCode != "" && !invitedomain.ValidCode(inviteCode) { + // 邀请码不存在、格式错误或不可用都统一落到 INVALID_INVITE_CODE,避免邀请码枚举。 + return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code") + } if s.userRepository == nil { return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured") } @@ -141,6 +147,8 @@ func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username Gender: gender, Country: resolvedCountry.CountryCode, RegionID: regionID, + InviteCode: inviteCode, + RequestID: strings.TrimSpace(requestID), CompletedAtMs: s.now().UnixMilli(), }) } diff --git a/services/user-service/internal/service/user/service_test.go b/services/user-service/internal/service/user/service_test.go index 848778ba..3bd744ad 100644 --- a/services/user-service/internal/service/user/service_test.go +++ b/services/user-service/internal/service/user/service_test.go @@ -322,7 +322,7 @@ func TestCompleteOnboardingWritesProfileRegionAndSkipsCountryCooldown(t *testing userservice.WithCountryChangeCooldown(time.Hour), ) - user, err := svc.CompleteOnboarding(ctx, 10001, " hy ", " https://cdn.example/a.png ", " male ", " sg ") + user, err := svc.CompleteOnboarding(ctx, 10001, " hy ", " https://cdn.example/a.png ", " male ", " sg ", "", "") if err != nil { t.Fatalf("CompleteOnboarding failed: %v", err) } @@ -362,7 +362,7 @@ func TestCountryWithoutExplicitRegionFallsBackToGlobal(t *testing.T) { t.Fatalf("country without mapping should resolve to GLOBAL: ok=%t region=%+v", ok, region) } - user, err := svc.CompleteOnboarding(ctx, 10001, "hy", "https://cdn.example/a.png", "male", "ZZ") + user, err := svc.CompleteOnboarding(ctx, 10001, "hy", "https://cdn.example/a.png", "male", "ZZ", "", "") if err != nil { t.Fatalf("CompleteOnboarding failed: %v", err) } @@ -396,7 +396,7 @@ func TestCompleteOnboardingValidation(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, err := svc.CompleteOnboarding(ctx, 10001, tc.username, tc.avatar, tc.gender, tc.country) + _, err := svc.CompleteOnboarding(ctx, 10001, tc.username, tc.avatar, tc.gender, tc.country, "", "") if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("expected INVALID_ARGUMENT, got %v", err) } diff --git a/services/user-service/internal/storage/mysql/auth/third_party.go b/services/user-service/internal/storage/mysql/auth/third_party.go index 2951a3da..49f744d1 100644 --- a/services/user-service/internal/storage/mysql/auth/third_party.go +++ b/services/user-service/internal/storage/mysql/auth/third_party.go @@ -7,7 +7,9 @@ import ( "hyapp/pkg/appcode" "hyapp/pkg/xerr" authdomain "hyapp/services/user-service/internal/domain/auth" + invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" + invitestorage "hyapp/services/user-service/internal/storage/mysql/invite" "hyapp/services/user-service/internal/storage/mysql/shared" userstorage "hyapp/services/user-service/internal/storage/mysql/user" ) @@ -32,7 +34,7 @@ func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string // CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。 -func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error { +func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, inviteBind invitedomain.BindCommand) error { // 三方首次登录注册必须同时写用户、默认短号、三方绑定和首个 session。 tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -44,6 +46,12 @@ func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.U // 默认短号冲突由 service 重试。 return mapAuthDuplicateError(err) } + if _, err := invitestorage.EnsurePrimaryCodeForUser(ctx, tx, user.AppCode, user.UserID, user.CreatedAtMs); err != nil { + return err + } + if _, err := invitestorage.BindRelation(ctx, tx, inviteBind); err != nil { + return err + } _, err = tx.ExecContext(ctx, ` INSERT INTO third_party_identities (app_code, user_id, provider, provider_subject, provider_union_id, created_at_ms, updated_at_ms) diff --git a/services/user-service/internal/storage/mysql/invite/repository.go b/services/user-service/internal/storage/mysql/invite/repository.go new file mode 100644 index 00000000..6c081b10 --- /dev/null +++ b/services/user-service/internal/storage/mysql/invite/repository.go @@ -0,0 +1,536 @@ +// Package invite stores invitation codes, relations, counters and recharge progress in MySQL. +package invite + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + mysqldriver "github.com/go-sql-driver/mysql" + "hyapp/pkg/appcode" + "hyapp/pkg/idgen" + "hyapp/pkg/xerr" + invitedomain "hyapp/services/user-service/internal/domain/invite" + "hyapp/services/user-service/internal/storage/mysql/shared" +) + +const generatedCodeLength = 8 + +const inviteCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// Repository owns invitation persistence inside the user-service database. +type Repository struct { + db *sql.DB +} + +// New creates an invitation repository backed by the shared user-service MySQL pool. +func New(db *sql.DB) *Repository { + return &Repository{db: db} +} + +// EnsurePrimaryCodeForUser creates the current user's own shareable invite code inside the caller transaction. +func EnsurePrimaryCodeForUser(ctx context.Context, tx *sql.Tx, appCode string, ownerUserID int64, nowMs int64) (string, error) { + code, ok, err := primaryCodeForUpdate(ctx, tx, appCode, ownerUserID) + if err != nil { + return "", err + } + if ok { + return code, nil + } + + for attempt := 0; attempt < 16; attempt++ { + candidate := generateInviteCode(generatedCodeLength) + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_invite_codes (app_code, owner_user_id, code, code_type, status, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, appcode.Normalize(appCode), ownerUserID, candidate, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive, nowMs, nowMs) + if err == nil { + return candidate, nil + } + if !isDuplicate(err) { + return "", err + } + // Owner-level duplicate means another code was created earlier in this transaction path; reuse it. + if code, ok, lookupErr := primaryCodeForUpdate(ctx, tx, appCode, ownerUserID); lookupErr != nil { + return "", lookupErr + } else if ok { + return code, nil + } + } + + return "", xerr.New(xerr.Internal, "invite code allocation failed") +} + +// BindRelation validates an optional invite code and binds the invited user once inside the caller transaction. +func BindRelation(ctx context.Context, tx *sql.Tx, command invitedomain.BindCommand) (invitedomain.Binding, error) { + command.AppCode = appcode.Normalize(command.AppCode) + command.InviteCode = invitedomain.NormalizeCode(command.InviteCode) + command.Source = strings.TrimSpace(command.Source) + command.RequestID = strings.TrimSpace(command.RequestID) + if command.InviteCode == "" { + return invitedomain.Binding{}, nil + } + if command.InvitedUserID <= 0 || !invitedomain.ValidCode(command.InviteCode) { + return invitedomain.Binding{}, invalidInviteCode() + } + if command.BoundAtMs <= 0 { + command.BoundAtMs = time.Now().UnixMilli() + } + + code, err := resolveActiveCodeForUpdate(ctx, tx, command.AppCode, command.InviteCode) + if err != nil { + return invitedomain.Binding{}, err + } + if code.OwnerUserID == command.InvitedUserID || code.OwnerStatus != "active" { + return invitedomain.Binding{}, invalidInviteCode() + } + + existing, exists, err := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID) + if err != nil { + return invitedomain.Binding{}, err + } + if exists { + // 邀请归因只能首次绑定;重复提交不改写原邀请人,也不重复增加计数。 + return invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}, nil + } + + policy, err := currentPolicyForUpdate(ctx, tx, command.AppCode, command.InvitedRegionID, command.BoundAtMs) + if err != nil { + return invitedomain.Binding{}, err + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO user_invite_relations ( + app_code, invited_user_id, inviter_user_id, invite_code_id, invite_code, source, request_id, bound_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, command.AppCode, command.InvitedUserID, code.OwnerUserID, code.InviteCodeID, code.Code, command.Source, command.RequestID, command.BoundAtMs, command.BoundAtMs, command.BoundAtMs) + if err != nil { + if isDuplicate(err) { + existing, exists, lookupErr := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID) + if lookupErr != nil { + return invitedomain.Binding{}, lookupErr + } + if exists { + return invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}, nil + } + return invitedomain.Binding{}, nil + } + return invitedomain.Binding{}, err + } + if err := insertProgress(ctx, tx, command, code.OwnerUserID, policy); err != nil { + return invitedomain.Binding{}, err + } + if err := incrementInviteCount(ctx, tx, command.AppCode, code.OwnerUserID, command.BoundAtMs); err != nil { + return invitedomain.Binding{}, err + } + if err := insertUserOutbox(ctx, tx, command.AppCode, invitedomain.EventTypeUserInvited, "user_invite_relation", command.InvitedUserID, command.BoundAtMs, map[string]any{ + "invited_user_id": command.InvitedUserID, + "inviter_user_id": code.OwnerUserID, + "invite_code": code.Code, + "request_id": command.RequestID, + "source": command.Source, + "bound_at_ms": command.BoundAtMs, + }); err != nil { + return invitedomain.Binding{}, err + } + + return invitedomain.Binding{Bound: true, InviteCode: code.Code, InviterUserID: code.OwnerUserID}, nil +} + +// GetOverview returns the precomputed invite read model for my-page aggregation. +func (r *Repository) GetOverview(ctx context.Context, appCode string, userID int64, regionID int64, nowMs int64) (invitedomain.Overview, error) { + if r == nil || r.db == nil { + return invitedomain.Overview{}, xerr.New(xerr.Unavailable, "invite repository is not configured") + } + if nowMs <= 0 { + nowMs = time.Now().UnixMilli() + } + + var overview invitedomain.Overview + err := r.db.QueryRowContext(ctx, ` + SELECT code + FROM user_invite_codes + WHERE app_code = ? AND owner_user_id = ? AND code_type = ? AND status = ? + ORDER BY invite_code_id DESC + LIMIT 1 + `, appcode.Normalize(appCode), userID, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive).Scan(&overview.MyInviteCode) + if err != nil && err != sql.ErrNoRows { + return invitedomain.Overview{}, err + } + overview.InviteEnabled = overview.MyInviteCode != "" + + err = r.db.QueryRowContext(ctx, ` + SELECT invite_count, valid_invite_count + FROM user_invite_counters + WHERE app_code = ? AND user_id = ? + `, appcode.Normalize(appCode), userID).Scan(&overview.InviteCount, &overview.ValidInviteCount) + if err != nil && err != sql.ErrNoRows { + return invitedomain.Overview{}, err + } + + policy, err := currentPolicy(ctx, r.db, appCode, regionID, nowMs) + if err == nil { + overview.ValidInviteThresholdCoin = policy.RequiredRechargeCoinAmount + } else if err != sql.ErrNoRows { + return invitedomain.Overview{}, err + } + + return overview, nil +} + +// ApplyRechargeEvent consumes one wallet recharge fact and advances invite validity idempotently. +func (r *Repository) ApplyRechargeEvent(ctx context.Context, event invitedomain.RechargeEvent) (invitedomain.RechargeApplyResult, error) { + if r == nil || r.db == nil { + return invitedomain.RechargeApplyResult{}, xerr.New(xerr.Unavailable, "invite repository is not configured") + } + event.AppCode = appcode.Normalize(event.AppCode) + event.EventID = strings.TrimSpace(event.EventID) + event.EventType = strings.TrimSpace(event.EventType) + event.TransactionID = strings.TrimSpace(event.TransactionID) + event.AssetType = strings.TrimSpace(event.AssetType) + event.RechargeType = strings.TrimSpace(event.RechargeType) + if event.RechargeType == "" { + event.RechargeType = invitedomain.RechargeTypeCoinSellerTransfer + } + if event.EventID == "" || event.TransactionID == "" || event.InvitedUserID <= 0 { + return invitedomain.RechargeApplyResult{}, xerr.New(xerr.InvalidArgument, "wallet recharge event identity is required") + } + if event.OccurredAtMs <= 0 { + event.OccurredAtMs = time.Now().UnixMilli() + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return invitedomain.RechargeApplyResult{}, err + } + defer tx.Rollback() + + if err := insertEventConsumption(ctx, tx, event); err != nil { + if isDuplicate(err) { + return invitedomain.RechargeApplyResult{Skipped: true, Reason: "already_consumed"}, nil + } + return invitedomain.RechargeApplyResult{}, err + } + if event.EventType != invitedomain.RechargeEventWalletRecorded || event.AssetType != "COIN" || event.RechargeCoinAmount <= 0 { + if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "not_eligible_wallet_event", event.OccurredAtMs); err != nil { + return invitedomain.RechargeApplyResult{}, err + } + return invitedomain.RechargeApplyResult{Skipped: true, Reason: "not_eligible_wallet_event"}, tx.Commit() + } + + progress, exists, err := progressForInvitedForUpdate(ctx, tx, event.AppCode, event.InvitedUserID) + if err != nil { + return invitedomain.RechargeApplyResult{}, err + } + if !exists { + if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "no_invite_relation", event.OccurredAtMs); err != nil { + return invitedomain.RechargeApplyResult{}, err + } + return invitedomain.RechargeApplyResult{Skipped: true, Reason: "no_invite_relation"}, tx.Commit() + } + if progress.Status == invitedomain.ProgressStatusValid { + if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "already_valid", event.OccurredAtMs); err != nil { + return invitedomain.RechargeApplyResult{}, err + } + return invitedomain.RechargeApplyResult{Skipped: true, Reason: "already_valid"}, tx.Commit() + } + if !eligibleRechargeType(progress.EligibleRechargeTypes, event.RechargeType) { + if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "ineligible_recharge_type", event.OccurredAtMs); err != nil { + return invitedomain.RechargeApplyResult{}, err + } + return invitedomain.RechargeApplyResult{Skipped: true, Reason: "ineligible_recharge_type"}, tx.Commit() + } + + newAccumulated := progress.AccumulatedRechargeCoinAmount + event.RechargeCoinAmount + becameValid := newAccumulated >= progress.RequiredRechargeCoinAmount + status := invitedomain.ProgressStatusPending + var validAt any + if becameValid { + status = invitedomain.ProgressStatusValid + validAt = event.OccurredAtMs + } + _, err = tx.ExecContext(ctx, ` + UPDATE user_invite_recharge_progress + SET accumulated_recharge_coin_amount = ?, status = ?, valid_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND invited_user_id = ? + `, newAccumulated, status, validAt, event.OccurredAtMs, event.AppCode, event.InvitedUserID) + if err != nil { + return invitedomain.RechargeApplyResult{}, err + } + if becameValid { + if err := incrementValidInviteCount(ctx, tx, event.AppCode, progress.InviterUserID, event.OccurredAtMs); err != nil { + return invitedomain.RechargeApplyResult{}, err + } + if err := insertUserOutbox(ctx, tx, event.AppCode, invitedomain.EventTypeUserInviteBecameValid, "user_invite_relation", event.InvitedUserID, event.OccurredAtMs, map[string]any{ + "invited_user_id": event.InvitedUserID, + "inviter_user_id": progress.InviterUserID, + "transaction_id": event.TransactionID, + "accumulated_recharge_coin_amount": newAccumulated, + "required_recharge_coin_amount": progress.RequiredRechargeCoinAmount, + "valid_at_ms": event.OccurredAtMs, + }); err != nil { + return invitedomain.RechargeApplyResult{}, err + } + } + if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "consumed", "", event.OccurredAtMs); err != nil { + return invitedomain.RechargeApplyResult{}, err + } + + return invitedomain.RechargeApplyResult{Consumed: true, Valid: becameValid}, tx.Commit() +} + +type inviteCodeRow struct { + InviteCodeID int64 + Code string + OwnerUserID int64 + OwnerStatus string +} + +type relationRow struct { + InviterUserID int64 + InviteCode string +} + +type policyRow struct { + PolicyID int64 + PolicyVersion string + RequiredRechargeCoinAmount int64 + EligibleRechargeTypes []string +} + +type progressRow struct { + InviterUserID int64 + PolicyID int64 + PolicyVersion string + RequiredRechargeCoinAmount int64 + AccumulatedRechargeCoinAmount int64 + Status string + EligibleRechargeTypes []string +} + +func primaryCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, ownerUserID int64) (string, bool, error) { + var code string + err := tx.QueryRowContext(ctx, ` + SELECT code + FROM user_invite_codes + WHERE app_code = ? AND owner_user_id = ? AND code_type = ? AND status = ? + ORDER BY invite_code_id DESC + LIMIT 1 + FOR UPDATE + `, appcode.Normalize(appCode), ownerUserID, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive).Scan(&code) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, err + } + return code, true, nil +} + +func resolveActiveCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, code string) (inviteCodeRow, error) { + var row inviteCodeRow + err := tx.QueryRowContext(ctx, ` + SELECT c.invite_code_id, c.code, c.owner_user_id, u.status + FROM user_invite_codes c + INNER JOIN users u ON u.app_code = c.app_code AND u.user_id = c.owner_user_id + WHERE c.app_code = ? AND c.code = ? AND c.status = ? + LIMIT 1 + FOR UPDATE + `, appCode, code, invitedomain.CodeStatusActive).Scan(&row.InviteCodeID, &row.Code, &row.OwnerUserID, &row.OwnerStatus) + if err == sql.ErrNoRows { + return inviteCodeRow{}, invalidInviteCode() + } + if err != nil { + return inviteCodeRow{}, err + } + return row, nil +} + +func relationForInvitedForUpdate(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (relationRow, bool, error) { + var row relationRow + err := tx.QueryRowContext(ctx, ` + SELECT inviter_user_id, invite_code + FROM user_invite_relations + WHERE app_code = ? AND invited_user_id = ? + FOR UPDATE + `, appCode, invitedUserID).Scan(&row.InviterUserID, &row.InviteCode) + if err == sql.ErrNoRows { + return relationRow{}, false, nil + } + if err != nil { + return relationRow{}, false, err + } + return row, true, nil +} + +func currentPolicyForUpdate(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, nowMs int64) (policyRow, error) { + policy, err := currentPolicy(ctx, tx, appCode, regionID, nowMs) + if err == sql.ErrNoRows { + return policyRow{}, xerr.New(xerr.Internal, "invite validity policy is not configured") + } + return policy, err +} + +func currentPolicy(ctx context.Context, q shared.Queryer, appCode string, regionID int64, nowMs int64) (policyRow, error) { + var policy policyRow + var eligibleJSON string + err := q.QueryRowContext(ctx, ` + SELECT policy_id, policy_version, required_recharge_coin_amount, CAST(eligible_recharge_types AS CHAR) + FROM user_invite_validity_policies + WHERE app_code = ? + AND status = 'active' + AND (region_id = ? OR region_id = 0) + AND effective_from_ms <= ? + AND (effective_to_ms IS NULL OR effective_to_ms > ?) + ORDER BY CASE WHEN region_id = ? THEN 0 ELSE 1 END, effective_from_ms DESC, policy_id DESC + LIMIT 1 + `, appcode.Normalize(appCode), nullableRegion(regionID), nowMs, nowMs, nullableRegion(regionID)).Scan(&policy.PolicyID, &policy.PolicyVersion, &policy.RequiredRechargeCoinAmount, &eligibleJSON) + if err != nil { + return policyRow{}, err + } + policy.EligibleRechargeTypes = parseEligibleTypes(eligibleJSON) + return policy, nil +} + +func insertProgress(ctx context.Context, tx *sql.Tx, command invitedomain.BindCommand, inviterUserID int64, policy policyRow) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_invite_recharge_progress ( + app_code, invited_user_id, inviter_user_id, policy_id, policy_version, required_recharge_coin_amount, + accumulated_recharge_coin_amount, status, valid_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?) + ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms + `, command.AppCode, command.InvitedUserID, inviterUserID, policy.PolicyID, policy.PolicyVersion, policy.RequiredRechargeCoinAmount, invitedomain.ProgressStatusPending, command.BoundAtMs, command.BoundAtMs) + return err +} + +func incrementInviteCount(ctx context.Context, tx *sql.Tx, appCode string, inviterUserID int64, nowMs int64) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_invite_counters (app_code, user_id, invite_count, valid_invite_count, updated_at_ms) + VALUES (?, ?, 1, 0, ?) + ON DUPLICATE KEY UPDATE invite_count = invite_count + 1, updated_at_ms = VALUES(updated_at_ms) + `, appCode, inviterUserID, nowMs) + return err +} + +func incrementValidInviteCount(ctx context.Context, tx *sql.Tx, appCode string, inviterUserID int64, nowMs int64) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_invite_counters (app_code, user_id, invite_count, valid_invite_count, updated_at_ms) + VALUES (?, ?, 0, 1, ?) + ON DUPLICATE KEY UPDATE valid_invite_count = valid_invite_count + 1, updated_at_ms = VALUES(updated_at_ms) + `, appCode, inviterUserID, nowMs) + return err +} + +func insertUserOutbox(ctx context.Context, tx *sql.Tx, appCode string, eventType string, aggregateType string, aggregateID int64, nowMs int64, payload any) error { + payloadJSON, err := json.Marshal(payload) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, ` + INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?) + `, appcode.Normalize(appCode), inviteEventID(eventType, aggregateID), eventType, aggregateType, aggregateID, string(payloadJSON), nowMs, nowMs) + return err +} + +func insertEventConsumption(ctx context.Context, tx *sql.Tx, event invitedomain.RechargeEvent) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_invite_event_consumption ( + app_code, event_id, event_type, transaction_id, invited_user_id, status, skip_reason, consumed_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, 'processing', '', NULL, ?, ?) + `, event.AppCode, event.EventID, event.EventType, event.TransactionID, event.InvitedUserID, event.OccurredAtMs, event.OccurredAtMs) + return err +} + +func finishConsumption(ctx context.Context, tx *sql.Tx, appCode string, eventID string, status string, reason string, nowMs int64) error { + _, err := tx.ExecContext(ctx, ` + UPDATE user_invite_event_consumption + SET status = ?, skip_reason = ?, consumed_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND event_id = ? + `, status, reason, nowMs, nowMs, appCode, eventID) + return err +} + +func progressForInvitedForUpdate(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (progressRow, bool, error) { + var row progressRow + var eligibleJSON string + err := tx.QueryRowContext(ctx, ` + SELECT p.inviter_user_id, p.policy_id, p.policy_version, p.required_recharge_coin_amount, + p.accumulated_recharge_coin_amount, p.status, CAST(pol.eligible_recharge_types AS CHAR) + FROM user_invite_recharge_progress p + INNER JOIN user_invite_validity_policies pol ON pol.app_code = p.app_code AND pol.policy_id = p.policy_id + WHERE p.app_code = ? AND p.invited_user_id = ? + FOR UPDATE + `, appCode, invitedUserID).Scan(&row.InviterUserID, &row.PolicyID, &row.PolicyVersion, &row.RequiredRechargeCoinAmount, &row.AccumulatedRechargeCoinAmount, &row.Status, &eligibleJSON) + if err == sql.ErrNoRows { + return progressRow{}, false, nil + } + if err != nil { + return progressRow{}, false, err + } + row.EligibleRechargeTypes = parseEligibleTypes(eligibleJSON) + return row, true, nil +} + +func parseEligibleTypes(raw string) []string { + var types []string + if err := json.Unmarshal([]byte(raw), &types); err != nil || len(types) == 0 { + return []string{invitedomain.RechargeTypeCoinSellerTransfer} + } + for index := range types { + types[index] = strings.TrimSpace(types[index]) + } + return types +} + +func eligibleRechargeType(types []string, eventType string) bool { + for _, item := range types { + if item == eventType { + return true + } + } + return false +} + +func inviteEventID(eventType string, aggregateID int64) string { + return fmt.Sprintf("%s:%d", eventType, aggregateID) +} + +func invalidInviteCode() error { + return xerr.New(xerr.InvalidInviteCode, "invalid invite code") +} + +func nullableRegion(regionID int64) int64 { + if regionID <= 0 { + return 0 + } + return regionID +} + +func generateInviteCode(length int) string { + if length <= 0 { + length = generatedCodeLength + } + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + fallback := strings.ToUpper(strings.ReplaceAll(idgen.New("INV"), "_", "")) + if len(fallback) >= length { + return fallback[len(fallback)-length:] + } + return fmt.Sprintf("%0*s", length, fallback) + } + for index, value := range buf { + buf[index] = inviteCodeAlphabet[int(value)%len(inviteCodeAlphabet)] + } + return string(buf) +} + +func isDuplicate(err error) bool { + var mysqlErr *mysqldriver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 +} diff --git a/services/user-service/internal/storage/mysql/invite/repository_test.go b/services/user-service/internal/storage/mysql/invite/repository_test.go new file mode 100644 index 00000000..4c691b8b --- /dev/null +++ b/services/user-service/internal/storage/mysql/invite/repository_test.go @@ -0,0 +1,136 @@ +package invite_test + +import ( + "context" + "testing" + + invitedomain "hyapp/services/user-service/internal/domain/invite" + userdomain "hyapp/services/user-service/internal/domain/user" + "hyapp/services/user-service/internal/testutil/mysqltest" +) + +func TestInviteRelationCountersAndRechargeValidity(t *testing.T) { + repo := mysqltest.NewRepository(t) + ctx := context.Background() + + createUser(t, repo, 10001, "163001", 1000) + createUser(t, repo, 10002, "163002", 1001) + + inviter, err := repo.GetUser(ctx, 10001) + if err != nil { + t.Fatalf("get inviter failed: %v", err) + } + if inviter.InviteOverview.MyInviteCode == "" || !inviter.InviteOverview.InviteEnabled || inviter.InviteOverview.ValidInviteThresholdCoin != 80000 { + t.Fatalf("inviter should have active code and default policy threshold: %+v", inviter.InviteOverview) + } + + invited, err := repo.CompleteOnboarding(ctx, userdomain.CompleteOnboardingCommand{ + AppCode: "lalu", + UserID: 10002, + Username: "alice", + Avatar: "https://cdn.example/a.png", + Gender: "female", + Country: "SG", + InviteCode: inviter.InviteOverview.MyInviteCode, + RequestID: "req-invite-bind", + CompletedAtMs: 2000, + }) + if err != nil { + t.Fatalf("complete onboarding with invite failed: %v", err) + } + if !invited.InviteBinding.Bound || invited.InviteBinding.InviterUserID != 10001 { + t.Fatalf("invite binding mismatch: %+v", invited.InviteBinding) + } + + inviter, err = repo.GetUser(ctx, 10001) + if err != nil { + t.Fatalf("get inviter after bind failed: %v", err) + } + if inviter.InviteOverview.InviteCount != 1 || inviter.InviteOverview.ValidInviteCount != 0 { + t.Fatalf("invite counter after bind mismatch: %+v", inviter.InviteOverview) + } + + inviteRepo := repo.Repository.InviteRepository() + first, err := inviteRepo.ApplyRechargeEvent(ctx, invitedomain.RechargeEvent{ + AppCode: "lalu", + EventID: "evt-recharge-1", + EventType: invitedomain.RechargeEventWalletRecorded, + TransactionID: "tx-recharge-1", + CommandID: "cmd-recharge-1", + InvitedUserID: 10002, + AssetType: "COIN", + RechargeCoinAmount: 79999, + RechargeType: invitedomain.RechargeTypeCoinSellerTransfer, + OccurredAtMs: 3000, + }) + if err != nil || !first.Consumed || first.Valid { + t.Fatalf("first recharge result mismatch: result=%+v err=%v", first, err) + } + inviter, _ = repo.GetUser(ctx, 10001) + if inviter.InviteOverview.ValidInviteCount != 0 { + t.Fatalf("79999 coin must not count as valid invite: %+v", inviter.InviteOverview) + } + + second, err := inviteRepo.ApplyRechargeEvent(ctx, invitedomain.RechargeEvent{ + AppCode: "lalu", + EventID: "evt-recharge-2", + EventType: invitedomain.RechargeEventWalletRecorded, + TransactionID: "tx-recharge-2", + CommandID: "cmd-recharge-2", + InvitedUserID: 10002, + AssetType: "COIN", + RechargeCoinAmount: 1, + RechargeType: invitedomain.RechargeTypeCoinSellerTransfer, + OccurredAtMs: 4000, + }) + if err != nil || !second.Consumed || !second.Valid { + t.Fatalf("second recharge should make invite valid: result=%+v err=%v", second, err) + } + duplicate, err := inviteRepo.ApplyRechargeEvent(ctx, invitedomain.RechargeEvent{ + AppCode: "lalu", + EventID: "evt-recharge-2", + EventType: invitedomain.RechargeEventWalletRecorded, + TransactionID: "tx-recharge-2", + CommandID: "cmd-recharge-2", + InvitedUserID: 10002, + AssetType: "COIN", + RechargeCoinAmount: 1, + RechargeType: invitedomain.RechargeTypeCoinSellerTransfer, + OccurredAtMs: 4000, + }) + if err != nil || !duplicate.Skipped || duplicate.Reason != "already_consumed" { + t.Fatalf("duplicate recharge should be idempotently skipped: result=%+v err=%v", duplicate, err) + } + + inviter, err = repo.GetUser(ctx, 10001) + if err != nil { + t.Fatalf("get inviter after valid failed: %v", err) + } + if inviter.InviteOverview.InviteCount != 1 || inviter.InviteOverview.ValidInviteCount != 1 { + t.Fatalf("valid invite counter mismatch: %+v", inviter.InviteOverview) + } +} + +func createUser(t *testing.T, repo *mysqltest.Repository, userID int64, displayUserID string, nowMs int64) { + t.Helper() + if err := repo.CreateUserWithIdentity(context.Background(), userdomain.User{ + AppCode: "lalu", + UserID: userID, + DefaultDisplayUserID: displayUserID, + CurrentDisplayUserID: displayUserID, + CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault, + Status: userdomain.StatusActive, + OnboardingStatus: userdomain.OnboardingStatusProfileRequired, + CreatedAtMs: nowMs, + UpdatedAtMs: nowMs, + }, userdomain.Identity{ + AppCode: "lalu", + UserID: userID, + DisplayUserID: displayUserID, + DefaultDisplayUserID: displayUserID, + DisplayUserIDKind: userdomain.DisplayUserIDKindDefault, + Status: userdomain.DisplayUserIDStatusActive, + }); err != nil { + t.Fatalf("create user %d failed: %v", userID, err) + } +} diff --git a/services/user-service/internal/storage/mysql/invite/wallet_outbox_reader.go b/services/user-service/internal/storage/mysql/invite/wallet_outbox_reader.go new file mode 100644 index 00000000..437d6cae --- /dev/null +++ b/services/user-service/internal/storage/mysql/invite/wallet_outbox_reader.go @@ -0,0 +1,68 @@ +package invite + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + invitedomain "hyapp/services/user-service/internal/domain/invite" +) + +// WalletOutboxReader reads recharge facts from wallet-service outbox without mutating wallet state. +type WalletOutboxReader struct { + db *sql.DB +} + +// NewWalletOutboxReader creates a read-only wallet outbox scanner. +func NewWalletOutboxReader(db *sql.DB) *WalletOutboxReader { + return &WalletOutboxReader{db: db} +} + +// ListRechargeEventsAfter returns wallet recharge events after the caller's in-memory cursor. +func (r *WalletOutboxReader) ListRechargeEventsAfter(ctx context.Context, cursor invitedomain.WalletRechargeCursor, batchSize int) ([]invitedomain.RechargeEvent, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "wallet outbox reader is not configured") + } + if batchSize <= 0 { + batchSize = 100 + } + rows, err := r.db.QueryContext(ctx, ` + SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, available_delta, + COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms + FROM wallet_outbox + WHERE event_type = ? + AND (created_at_ms > ? OR (created_at_ms = ? AND event_id > ?)) + ORDER BY created_at_ms ASC, event_id ASC + LIMIT ? + `, invitedomain.RechargeEventWalletRecorded, cursor.CreatedAtMs, cursor.CreatedAtMs, cursor.EventID, batchSize) + if err != nil { + return nil, err + } + defer rows.Close() + + events := make([]invitedomain.RechargeEvent, 0, batchSize) + for rows.Next() { + var event invitedomain.RechargeEvent + if err := rows.Scan(&event.AppCode, &event.EventID, &event.EventType, &event.TransactionID, &event.CommandID, &event.InvitedUserID, &event.AssetType, &event.RechargeCoinAmount, &event.PayloadJSON, &event.OccurredAtMs); err != nil { + return nil, err + } + event.AppCode = appcode.Normalize(event.AppCode) + event.RechargeType = rechargeTypeFromPayload(event.PayloadJSON) + events = append(events, event) + } + return events, rows.Err() +} + +func rechargeTypeFromPayload(payload string) string { + var decoded map[string]any + if err := json.Unmarshal([]byte(payload), &decoded); err == nil { + if value, ok := decoded["recharge_type"].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + // 当前 wallet-service 的 WalletRechargeRecorded 来源是币商转账充值事实。 + return invitedomain.RechargeTypeCoinSellerTransfer +} diff --git a/services/user-service/internal/storage/mysql/mictime/repository.go b/services/user-service/internal/storage/mysql/mictime/repository.go new file mode 100644 index 00000000..713383da --- /dev/null +++ b/services/user-service/internal/storage/mysql/mictime/repository.go @@ -0,0 +1,471 @@ +// Package mictime stores user-scoped microphone online duration read models in MySQL. +package mictime + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + mysqldriver "github.com/go-sql-driver/mysql" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + mictimedomain "hyapp/services/user-service/internal/domain/mictime" +) + +// Repository owns mic session state and user duration aggregates inside user-service MySQL. +type Repository struct { + db *sql.DB +} + +// New creates a mic duration repository backed by the shared user-service connection pool. +func New(db *sql.DB) *Repository { + return &Repository{db: db} +} + +// ProcessRoomMicEvent idempotently applies one RoomMicChanged fact. +func (r *Repository) ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { + if r == nil || r.db == nil { + return mictimedomain.ApplyResult{}, xerr.New(xerr.Unavailable, "mic time repository is not configured") + } + event = normalizeRoomMicEvent(event) + if event.EventID == "" || event.EventType == "" { + return mictimedomain.ApplyResult{}, xerr.New(xerr.InvalidArgument, "room mic event identity is required") + } + if event.OccurredAtMs <= 0 { + event.OccurredAtMs = time.Now().UnixMilli() + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return mictimedomain.ApplyResult{}, err + } + defer tx.Rollback() + + if err := insertEventConsumption(ctx, tx, event); err != nil { + if isDuplicate(err) { + return mictimedomain.ApplyResult{Skipped: true, Reason: "already_consumed"}, nil + } + return mictimedomain.ApplyResult{}, err + } + + if event.EventType != mictimedomain.RoomMicChangedEventType { + if err := finishEventConsumption(ctx, tx, event, "skipped", "not_room_mic_changed"); err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Skipped: true, Reason: "not_room_mic_changed"}, tx.Commit() + } + if event.MicSessionID == "" || event.TargetUserID <= 0 { + if err := finishEventConsumption(ctx, tx, event, "skipped", "incomplete_mic_event"); err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Skipped: true, Reason: "incomplete_mic_event"}, tx.Commit() + } + + var result mictimedomain.ApplyResult + switch event.Action { + case mictimedomain.ActionUp: + result, err = applyUp(ctx, tx, event) + case mictimedomain.ActionPublishConfirmed: + result, err = applyPublishConfirmed(ctx, tx, event) + case mictimedomain.ActionChange: + result, err = applyChange(ctx, tx, event) + case mictimedomain.ActionDown: + result, err = applyDown(ctx, tx, event) + default: + result = mictimedomain.ApplyResult{Skipped: true, Reason: "unsupported_mic_action"} + } + if err != nil { + return mictimedomain.ApplyResult{}, err + } + if result.Reason == "" && result.Skipped { + result.Reason = "skipped" + } + status := "consumed" + if result.Skipped { + status = "skipped" + } + if err := finishEventConsumption(ctx, tx, event, status, result.Reason); err != nil { + return mictimedomain.ApplyResult{}, err + } + if err := tx.Commit(); err != nil { + return mictimedomain.ApplyResult{}, err + } + if !result.Skipped { + result.Consumed = true + } + return result, nil +} + +// GetLifetimeStats returns the current user-wide mic duration aggregate. +func (r *Repository) GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error) { + if r == nil || r.db == nil { + return mictimedomain.LifetimeStats{}, xerr.New(xerr.Unavailable, "mic time repository is not configured") + } + if userID <= 0 { + return mictimedomain.LifetimeStats{}, xerr.New(xerr.InvalidArgument, "user_id is required") + } + stats := mictimedomain.LifetimeStats{AppCode: appcode.Normalize(appCode), UserID: userID} + err := r.db.QueryRowContext(ctx, ` + SELECT seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms + FROM user_mic_lifetime_stats + WHERE app_code = ? AND user_id = ? + `, stats.AppCode, userID).Scan(&stats.SeatOccupiedMs, &stats.MicOnlineMs, &stats.SessionCount, &stats.FirstMicAtMs, &stats.LastMicAtMs, &stats.UpdatedAtMs) + if err == sql.ErrNoRows { + return stats, nil + } + if err != nil { + return mictimedomain.LifetimeStats{}, err + } + return stats, nil +} + +func applyUp(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { + seatNo := event.ToSeat + if seatNo <= 0 { + seatNo = event.FromSeat + } + if seatNo <= 0 { + return mictimedomain.ApplyResult{Skipped: true, Reason: "missing_seat_no"}, nil + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_mic_sessions ( + app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, + seat_started_at_ms, publishing_started_at_ms, ended_at_ms, end_reason, + seat_occupied_ms, mic_online_ms, opened_event_id, publish_event_id, closed_event_id, + last_event_id, last_room_version, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, '', 0, 0, ?, '', '', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + current_seat_no = CASE WHEN status = ? THEN current_seat_no ELSE VALUES(current_seat_no) END, + last_event_id = VALUES(last_event_id), + last_room_version = GREATEST(last_room_version, VALUES(last_room_version)), + updated_at_ms = VALUES(updated_at_ms) + `, event.AppCode, event.MicSessionID, event.TargetUserID, event.RoomID, seatNo, seatNo, mictimedomain.SessionStatusPendingPublish, + event.OccurredAtMs, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.OccurredAtMs, mictimedomain.SessionStatusEnded) + if err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Consumed: true}, nil +} + +func applyPublishConfirmed(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { + session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID) + if err != nil || !exists { + if err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil + } + if session.UserID != event.TargetUserID { + return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil + } + if session.Status == mictimedomain.SessionStatusEnded { + return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil + } + publishAt := event.PublishEventTimeMs + if publishAt <= 0 { + publishAt = event.OccurredAtMs + } + if publishAt < session.SeatStartedAtMs { + // RTC/client clocks can arrive slightly earlier than server MicUp time; clamp to avoid negative online windows. + publishAt = session.SeatStartedAtMs + } + if session.PublishingStartedAtMs.Valid && publishAt <= session.PublishingStartedAtMs.Int64 { + if err := updateSessionLastEvent(ctx, tx, event); err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Skipped: true, Reason: "publish_already_confirmed"}, nil + } + _, err = tx.ExecContext(ctx, ` + UPDATE user_mic_sessions + SET status = ?, publishing_started_at_ms = ?, publish_event_id = ?, last_event_id = ?, + last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ? + WHERE app_code = ? AND mic_session_id = ? AND status <> ? + `, mictimedomain.SessionStatusPublishing, publishAt, event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, + event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded) + if err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Consumed: true}, nil +} + +func applyChange(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { + session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID) + if err != nil || !exists { + if err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil + } + if session.UserID != event.TargetUserID { + return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil + } + if session.Status == mictimedomain.SessionStatusEnded { + return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil + } + if event.ToSeat <= 0 { + return mictimedomain.ApplyResult{Skipped: true, Reason: "missing_target_seat"}, nil + } + _, err = tx.ExecContext(ctx, ` + UPDATE user_mic_sessions + SET current_seat_no = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ? + WHERE app_code = ? AND mic_session_id = ? AND status <> ? + `, event.ToSeat, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded) + if err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Consumed: true}, nil +} + +func applyDown(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) { + session, exists, err := sessionForUpdate(ctx, tx, event.AppCode, event.MicSessionID) + if err != nil || !exists { + if err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Skipped: true, Reason: "session_not_found"}, nil + } + if session.UserID != event.TargetUserID { + return mictimedomain.ApplyResult{Skipped: true, Reason: "target_user_mismatch"}, nil + } + if session.Status == mictimedomain.SessionStatusEnded { + return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil + } + + endAt := event.OccurredAtMs + if endAt < session.SeatStartedAtMs { + endAt = session.SeatStartedAtMs + } + seatOccupiedMs := maxInt64(0, endAt-session.SeatStartedAtMs) + micOnlineMs := int64(0) + if session.PublishingStartedAtMs.Valid { + publishStart := session.PublishingStartedAtMs.Int64 + if publishStart < session.SeatStartedAtMs { + publishStart = session.SeatStartedAtMs + } + micOnlineMs = maxInt64(0, endAt-publishStart) + } + endReason := strings.TrimSpace(event.Reason) + if endReason == "" { + endReason = "down" + } + + _, err = tx.ExecContext(ctx, ` + UPDATE user_mic_sessions + SET status = ?, ended_at_ms = ?, end_reason = ?, seat_occupied_ms = ?, mic_online_ms = ?, + closed_event_id = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ? + WHERE app_code = ? AND mic_session_id = ? AND status <> ? + `, mictimedomain.SessionStatusEnded, endAt, endReason, seatOccupiedMs, micOnlineMs, + event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded) + if err != nil { + return mictimedomain.ApplyResult{}, err + } + if err := incrementLifetime(ctx, tx, session, event, endAt, seatOccupiedMs, micOnlineMs); err != nil { + return mictimedomain.ApplyResult{}, err + } + if err := incrementDaily(ctx, tx, session, event, endAt); err != nil { + return mictimedomain.ApplyResult{}, err + } + return mictimedomain.ApplyResult{Consumed: true, Closed: true}, nil +} + +func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, event mictimedomain.RoomMicEvent, endAt int64, seatMs int64, micMs int64) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_mic_lifetime_stats ( + app_code, user_id, seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, 1, ?, ?, ?) + ON DUPLICATE KEY UPDATE + seat_occupied_ms = seat_occupied_ms + VALUES(seat_occupied_ms), + mic_online_ms = mic_online_ms + VALUES(mic_online_ms), + session_count = session_count + 1, + first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END, + last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)), + updated_at_ms = VALUES(updated_at_ms) + `, event.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, endAt, event.OccurredAtMs) + return err +} + +func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, event mictimedomain.RoomMicEvent, endAt int64) error { + seatParts := splitByUTCDay(session.SeatStartedAtMs, endAt) + micParts := map[string]dayDuration{} + if session.PublishingStartedAtMs.Valid { + publishStart := session.PublishingStartedAtMs.Int64 + if publishStart < session.SeatStartedAtMs { + publishStart = session.SeatStartedAtMs + } + micParts = splitByUTCDay(publishStart, endAt) + } + dates := map[string]struct{}{} + for date := range seatParts { + dates[date] = struct{}{} + } + for date := range micParts { + dates[date] = struct{}{} + } + for date := range dates { + seatPart := seatParts[date] + micPart := micParts[date] + firstAt := minPositive(seatPart.FirstAtMs, micPart.FirstAtMs) + lastAt := maxInt64(seatPart.LastAtMs, micPart.LastAtMs) + if firstAt == 0 || lastAt == 0 { + continue + } + sessionCount := int64(0) + roomCount := int64(0) + if seatPart.DurationMs > 0 { + // A cross-day session is visible on every day it occupied a seat. + sessionCount = 1 + roomCount = 1 + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_mic_daily_stats ( + app_code, user_id, stat_date, seat_occupied_ms, mic_online_ms, session_count, room_count, + first_mic_at_ms, last_mic_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + seat_occupied_ms = seat_occupied_ms + VALUES(seat_occupied_ms), + mic_online_ms = mic_online_ms + VALUES(mic_online_ms), + session_count = session_count + VALUES(session_count), + room_count = room_count + VALUES(room_count), + first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END, + last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)), + updated_at_ms = VALUES(updated_at_ms) + `, event.AppCode, session.UserID, date, seatPart.DurationMs, micPart.DurationMs, sessionCount, roomCount, firstAt, lastAt, event.OccurredAtMs) + if err != nil { + return err + } + } + return nil +} + +type micSessionRow struct { + UserID int64 + RoomID string + FirstSeatNo int32 + CurrentSeatNo int32 + Status string + SeatStartedAtMs int64 + PublishingStartedAtMs sql.NullInt64 + EndedAtMs sql.NullInt64 + LastRoomVersion int64 +} + +func sessionForUpdate(ctx context.Context, tx *sql.Tx, appCode string, micSessionID string) (micSessionRow, bool, error) { + var session micSessionRow + err := tx.QueryRowContext(ctx, ` + SELECT user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms, + publishing_started_at_ms, ended_at_ms, last_room_version + FROM user_mic_sessions + WHERE app_code = ? AND mic_session_id = ? + FOR UPDATE + `, appCode, micSessionID).Scan(&session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status, + &session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion) + if err == sql.ErrNoRows { + return micSessionRow{}, false, nil + } + if err != nil { + return micSessionRow{}, false, err + } + return session, true, nil +} + +func updateSessionLastEvent(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) error { + _, err := tx.ExecContext(ctx, ` + UPDATE user_mic_sessions + SET last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ? + WHERE app_code = ? AND mic_session_id = ? + `, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID) + return err +} + +func insertEventConsumption(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO user_mic_event_consumption ( + app_code, event_id, event_type, mic_session_id, user_id, action, status, skip_reason, + created_at_ms, consumed_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, 'processing', '', ?, NULL, ?) + `, event.AppCode, event.EventID, event.EventType, event.MicSessionID, event.TargetUserID, event.Action, event.OccurredAtMs, event.OccurredAtMs) + return err +} + +func finishEventConsumption(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent, status string, reason string) error { + _, err := tx.ExecContext(ctx, ` + UPDATE user_mic_event_consumption + SET status = ?, skip_reason = ?, consumed_at_ms = ?, updated_at_ms = ? + WHERE app_code = ? AND event_id = ? + `, status, strings.TrimSpace(reason), event.OccurredAtMs, event.OccurredAtMs, event.AppCode, event.EventID) + return err +} + +func normalizeRoomMicEvent(event mictimedomain.RoomMicEvent) mictimedomain.RoomMicEvent { + event.AppCode = appcode.Normalize(event.AppCode) + event.EventID = strings.TrimSpace(event.EventID) + event.EventType = strings.TrimSpace(event.EventType) + event.RoomID = strings.TrimSpace(event.RoomID) + event.Action = strings.TrimSpace(event.Action) + event.MicSessionID = strings.TrimSpace(event.MicSessionID) + event.PublishState = strings.TrimSpace(event.PublishState) + event.Reason = strings.TrimSpace(event.Reason) + return event +} + +type dayDuration struct { + DurationMs int64 + FirstAtMs int64 + LastAtMs int64 +} + +func splitByUTCDay(startMs int64, endMs int64) map[string]dayDuration { + parts := map[string]dayDuration{} + if endMs <= startMs { + return parts + } + for cursor := startMs; cursor < endMs; { + cursorTime := time.UnixMilli(cursor).UTC() + dayStart := time.Date(cursorTime.Year(), cursorTime.Month(), cursorTime.Day(), 0, 0, 0, 0, time.UTC) + nextDayMs := dayStart.AddDate(0, 0, 1).UnixMilli() + segmentEnd := minInt64(endMs, nextDayMs) + date := dayStart.Format("2006-01-02") + part := parts[date] + if part.FirstAtMs == 0 || cursor < part.FirstAtMs { + part.FirstAtMs = cursor + } + if segmentEnd > part.LastAtMs { + part.LastAtMs = segmentEnd + } + part.DurationMs += segmentEnd - cursor + parts[date] = part + cursor = segmentEnd + } + return parts +} + +func isDuplicate(err error) bool { + var mysqlErr *mysqldriver.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 +} + +func minPositive(a int64, b int64) int64 { + if a <= 0 { + return b + } + if b <= 0 || a < b { + return a + } + return b +} + +func minInt64(a int64, b int64) int64 { + if a < b { + return a + } + return b +} + +func maxInt64(a int64, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/services/user-service/internal/storage/mysql/mictime/repository_test.go b/services/user-service/internal/storage/mysql/mictime/repository_test.go new file mode 100644 index 00000000..6f909e71 --- /dev/null +++ b/services/user-service/internal/storage/mysql/mictime/repository_test.go @@ -0,0 +1,115 @@ +package mictime_test + +import ( + "context" + "testing" + "time" + + mictimedomain "hyapp/services/user-service/internal/domain/mictime" + "hyapp/services/user-service/internal/testutil/mysqltest" +) + +func TestProcessRoomMicEventsAggregatesLifetimeAndDeduplicates(t *testing.T) { + repo := mysqltest.NewRepository(t).Repository.MicTimeRepository() + ctx := context.Background() + base := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC).UnixMilli() + micSessionID := "mic_session_normal" + + mustApply(t, repo, roomMicEvent("evt-up", micSessionID, mictimedomain.ActionUp, 10001, 0, 1, base)) + mustApply(t, repo, roomMicEvent("evt-confirm", micSessionID, mictimedomain.ActionPublishConfirmed, 10001, 1, 1, base+60_000)) + mustApply(t, repo, roomMicEvent("evt-change", micSessionID, mictimedomain.ActionChange, 10001, 1, 2, base+5*60_000)) + mustApply(t, repo, roomMicEvent("evt-down", micSessionID, mictimedomain.ActionDown, 10001, 2, 0, base+11*60_000)) + + stats, err := repo.GetLifetimeStats(ctx, "lalu", 10001) + if err != nil { + t.Fatalf("GetLifetimeStats failed: %v", err) + } + if stats.SeatOccupiedMs != 11*60_000 || stats.MicOnlineMs != 10*60_000 || stats.SessionCount != 1 { + t.Fatalf("normal mic duration mismatch: %+v", stats) + } + + duplicate, err := repo.ProcessRoomMicEvent(ctx, roomMicEvent("evt-down", micSessionID, mictimedomain.ActionDown, 10001, 2, 0, base+11*60_000)) + if err != nil || !duplicate.Skipped || duplicate.Reason != "already_consumed" { + t.Fatalf("duplicate down should skip: result=%+v err=%v", duplicate, err) + } + stats, _ = repo.GetLifetimeStats(ctx, "lalu", 10001) + if stats.SeatOccupiedMs != 11*60_000 || stats.MicOnlineMs != 10*60_000 || stats.SessionCount != 1 { + t.Fatalf("duplicate down must not double count: %+v", stats) + } +} + +func TestProcessPendingPublishTimeoutCountsSeatButNotMicOnline(t *testing.T) { + repo := mysqltest.NewRepository(t).Repository.MicTimeRepository() + ctx := context.Background() + base := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC).UnixMilli() + micSessionID := "mic_session_pending_timeout" + + mustApply(t, repo, roomMicEvent("evt-pending-up", micSessionID, mictimedomain.ActionUp, 10002, 0, 1, base)) + down := roomMicEvent("evt-pending-down", micSessionID, mictimedomain.ActionDown, 10002, 1, 0, base+2*60_000) + down.Reason = "publish_timeout" + mustApply(t, repo, down) + + stats, err := repo.GetLifetimeStats(ctx, "lalu", 10002) + if err != nil { + t.Fatalf("GetLifetimeStats failed: %v", err) + } + if stats.SeatOccupiedMs != 2*60_000 || stats.MicOnlineMs != 0 || stats.SessionCount != 1 { + t.Fatalf("pending timeout duration mismatch: %+v", stats) + } +} + +func TestProcessCrossDayMicSessionKeepsLifetimeTotals(t *testing.T) { + repo := mysqltest.NewRepository(t).Repository.MicTimeRepository() + ctx := context.Background() + base := time.Date(2026, 5, 8, 23, 59, 0, 0, time.UTC).UnixMilli() + micSessionID := "mic_session_cross_day" + + mustApply(t, repo, roomMicEvent("evt-cross-up", micSessionID, mictimedomain.ActionUp, 10003, 0, 1, base)) + mustApply(t, repo, roomMicEvent("evt-cross-confirm", micSessionID, mictimedomain.ActionPublishConfirmed, 10003, 1, 1, base+30_000)) + mustApply(t, repo, roomMicEvent("evt-cross-down", micSessionID, mictimedomain.ActionDown, 10003, 1, 0, base+2*60_000)) + + stats, err := repo.GetLifetimeStats(ctx, "lalu", 10003) + if err != nil { + t.Fatalf("GetLifetimeStats failed: %v", err) + } + if stats.SeatOccupiedMs != 2*60_000 || stats.MicOnlineMs != 90_000 || stats.SessionCount != 1 { + t.Fatalf("cross-day lifetime mismatch: %+v", stats) + } +} + +func mustApply(t *testing.T, repo interface { + ProcessRoomMicEvent(context.Context, mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) +}, event mictimedomain.RoomMicEvent) { + t.Helper() + result, err := repo.ProcessRoomMicEvent(context.Background(), event) + if err != nil || result.Skipped { + t.Fatalf("ProcessRoomMicEvent failed: event=%+v result=%+v err=%v", event, result, err) + } +} + +func roomMicEvent(eventID string, micSessionID string, action string, userID int64, fromSeat int32, toSeat int32, occurredAtMs int64) mictimedomain.RoomMicEvent { + event := mictimedomain.RoomMicEvent{ + AppCode: "lalu", + EventID: eventID, + EventType: mictimedomain.RoomMicChangedEventType, + RoomID: "room-mic-time-test", + RoomVersion: occurredAtMs, + OccurredAtMs: occurredAtMs, + ActorUserID: userID, + TargetUserID: userID, + FromSeat: fromSeat, + ToSeat: toSeat, + Action: action, + MicSessionID: micSessionID, + PublishState: mictimedomain.PublishStatePending, + PublishDeadlineMs: occurredAtMs + 60_000, + } + if action == mictimedomain.ActionPublishConfirmed { + event.PublishState = mictimedomain.PublishStatePublishing + event.PublishEventTimeMs = occurredAtMs + } + if action == mictimedomain.ActionDown { + event.PublishState = mictimedomain.PublishStateIdle + } + return event +} diff --git a/services/user-service/internal/storage/mysql/mictime/room_outbox_reader.go b/services/user-service/internal/storage/mysql/mictime/room_outbox_reader.go new file mode 100644 index 00000000..a428fd0f --- /dev/null +++ b/services/user-service/internal/storage/mysql/mictime/room_outbox_reader.go @@ -0,0 +1,88 @@ +package mictime + +import ( + "context" + "database/sql" + "time" + + "google.golang.org/protobuf/proto" + roomeventsv1 "hyapp.local/api/proto/events/room/v1" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + mictimedomain "hyapp/services/user-service/internal/domain/mictime" +) + +// RoomOutboxReader scans room-service outbox without mutating room-service delivery state. +type RoomOutboxReader struct { + db *sql.DB +} + +// NewRoomOutboxReader creates a read-only scanner for RoomMicChanged events. +func NewRoomOutboxReader(db *sql.DB) *RoomOutboxReader { + return &RoomOutboxReader{db: db} +} + +// ListRoomMicEventsAfter returns RoomMicChanged events after the caller's in-memory cursor. +func (r *RoomOutboxReader) ListRoomMicEventsAfter(ctx context.Context, cursor mictimedomain.RoomOutboxCursor, batchSize int) ([]mictimedomain.RoomMicEvent, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "room outbox reader is not configured") + } + if batchSize <= 0 { + batchSize = 100 + } + createdAfter := time.UnixMilli(cursor.CreatedAtMs) + rows, err := r.db.QueryContext(ctx, ` + SELECT event_id, envelope, created_at + FROM room_outbox + WHERE event_type = ? + AND (created_at > ? OR (created_at = ? AND event_id > ?)) + ORDER BY created_at ASC, event_id ASC + LIMIT ? + `, mictimedomain.RoomMicChangedEventType, createdAfter, createdAfter, cursor.EventID, batchSize) + if err != nil { + return nil, err + } + defer rows.Close() + + events := make([]mictimedomain.RoomMicEvent, 0, batchSize) + for rows.Next() { + var eventID string + var envelopeBytes []byte + var createdAt time.Time + if err := rows.Scan(&eventID, &envelopeBytes, &createdAt); err != nil { + return nil, err + } + var envelope roomeventsv1.EventEnvelope + if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil { + return nil, err + } + var body roomeventsv1.RoomMicChanged + if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { + return nil, err + } + decodedEventID := envelope.GetEventId() + if decodedEventID == "" { + decodedEventID = eventID + } + events = append(events, mictimedomain.RoomMicEvent{ + AppCode: appcode.Normalize(envelope.GetAppCode()), + EventID: decodedEventID, + EventType: envelope.GetEventType(), + RoomID: envelope.GetRoomId(), + RoomVersion: envelope.GetRoomVersion(), + OutboxCreatedAtMs: createdAt.UnixMilli(), + OccurredAtMs: envelope.GetOccurredAtMs(), + ActorUserID: body.GetActorUserId(), + TargetUserID: body.GetTargetUserId(), + FromSeat: body.GetFromSeat(), + ToSeat: body.GetToSeat(), + Action: body.GetAction(), + MicSessionID: body.GetMicSessionId(), + PublishState: body.GetPublishState(), + Reason: body.GetReason(), + PublishDeadlineMs: body.GetPublishDeadlineMs(), + PublishEventTimeMs: body.GetPublishEventTimeMs(), + }) + } + return events, rows.Err() +} diff --git a/services/user-service/internal/storage/mysql/repositories.go b/services/user-service/internal/storage/mysql/repositories.go index 0472802e..f55c2fc0 100644 --- a/services/user-service/internal/storage/mysql/repositories.go +++ b/services/user-service/internal/storage/mysql/repositories.go @@ -6,6 +6,8 @@ import ( devicestorage "hyapp/services/user-service/internal/storage/mysql/device" hoststorage "hyapp/services/user-service/internal/storage/mysql/host" identitystorage "hyapp/services/user-service/internal/storage/mysql/identity" + invitestorage "hyapp/services/user-service/internal/storage/mysql/invite" + mictimestorage "hyapp/services/user-service/internal/storage/mysql/mictime" regionstorage "hyapp/services/user-service/internal/storage/mysql/region" userstorage "hyapp/services/user-service/internal/storage/mysql/user" ) @@ -65,3 +67,19 @@ func (r *Repository) HostRepository() *hoststorage.Repository { } return hoststorage.New(r.db) } + +// 基于 user-service 共享 MySQL 连接池返回邀请关系和计数存储对象。 +func (r *Repository) InviteRepository() *invitestorage.Repository { + if r == nil { + return nil + } + return invitestorage.New(r.db) +} + +// 基于 user-service 共享 MySQL 连接池返回用户麦上时长存储对象。 +func (r *Repository) MicTimeRepository() *mictimestorage.Repository { + if r == nil { + return nil + } + return mictimestorage.New(r.db) +} diff --git a/services/user-service/internal/storage/mysql/user/common.go b/services/user-service/internal/storage/mysql/user/common.go index 590ea9d2..5a92893f 100644 --- a/services/user-service/internal/storage/mysql/user/common.go +++ b/services/user-service/internal/storage/mysql/user/common.go @@ -236,7 +236,7 @@ func InsertUserIdentity(ctx context.Context, tx *sql.Tx, user userdomain.User, i VALUES ( ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ? ) `, appcode.Normalize(user.AppCode), user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), shared.NullableString(user.Username), shared.NullableString(user.Gender), shared.NullableString(user.Country), shared.NullableRegionID(user.RegionID), shared.NullableString(user.InviteCode), shared.NullableString(user.RegisterIP), shared.NullableString(user.RegisterUserAgent), shared.NullableString(user.CountryByIP), shared.NullableString(user.RegisterDeviceID), shared.NullableString(user.RegisterDevice), shared.NullableString(user.RegisterOSVersion), shared.NullableString(user.Avatar), shared.NullableString(user.BirthDate), shared.NullableString(user.RegisterAppVersion), shared.NullableString(user.RegisterBuildNumber), shared.NullableString(user.RegisterSource), shared.NullableString(user.RegisterInstallChannel), shared.NullableString(user.RegisterCampaign), shared.NullableString(user.RegisterPlatform), shared.NullableString(user.Language), shared.NullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, onboardingStatusForInsert(user), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs) if err != nil { diff --git a/services/user-service/internal/storage/mysql/user/repository.go b/services/user-service/internal/storage/mysql/user/repository.go index b88b1d29..c30ff8ae 100644 --- a/services/user-service/internal/storage/mysql/user/repository.go +++ b/services/user-service/internal/storage/mysql/user/repository.go @@ -5,10 +5,13 @@ import ( "database/sql" "fmt" "strings" + "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" + invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" + invitestorage "hyapp/services/user-service/internal/storage/mysql/invite" "hyapp/services/user-service/internal/storage/mysql/shared" ) @@ -22,6 +25,9 @@ func (r *Repository) GetUser(ctx context.Context, userID int64) (userdomain.User if err != nil { return userdomain.User{}, err } + if err := r.hydrateInviteOverview(ctx, &user); err != nil { + return userdomain.User{}, err + } return user, nil } @@ -112,6 +118,9 @@ func (r *Repository) CreateUserWithIdentity(ctx context.Context, user userdomain // 唯一键冲突转换成 display_user_id exists,供 service 有限重试。 return shared.MapDisplayDuplicateError(err) } + if _, err := invitestorage.EnsurePrimaryCodeForUser(ctx, tx, user.AppCode, user.UserID, user.CreatedAtMs); err != nil { + return err + } return tx.Commit() } @@ -186,6 +195,17 @@ func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain. } return r.GetUser(ctx, command.UserID) } + var inviteBindingErr error + inviteSnapshot := command.InviteCode + if command.InviteCode != "" { + user.InviteBinding, inviteBindingErr = invitestorage.BindRelation(ctx, tx, invitestorageBindCommand(command)) + if inviteBindingErr != nil { + return userdomain.User{}, inviteBindingErr + } + if user.InviteBinding.InviteCode != "" { + inviteSnapshot = user.InviteBinding.InviteCode + } + } _, err = tx.ExecContext(ctx, ` UPDATE users @@ -194,13 +214,14 @@ func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain. gender = ?, country = ?, region_id = ?, + invite_code = CASE WHEN ? = '' THEN invite_code ELSE ? END, profile_completed = ?, profile_completed_at_ms = ?, onboarding_status = ?, updated_at_ms = ? WHERE user_id = ? AND app_code = ? - `, command.Username, command.Avatar, command.Gender, command.Country, shared.NullableRegionID(command.RegionID), true, command.CompletedAtMs, string(userdomain.OnboardingStatusCompleted), command.CompletedAtMs, command.UserID, appcode.Normalize(command.AppCode)) + `, command.Username, command.Avatar, command.Gender, command.Country, shared.NullableRegionID(command.RegionID), inviteSnapshot, inviteSnapshot, true, command.CompletedAtMs, string(userdomain.OnboardingStatusCompleted), command.CompletedAtMs, command.UserID, appcode.Normalize(command.AppCode)) if err != nil { return userdomain.User{}, err } @@ -208,7 +229,9 @@ func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain. return userdomain.User{}, err } - return r.GetUser(ctx, command.UserID) + updated, err := r.GetUser(ctx, command.UserID) + updated.InviteBinding = user.InviteBinding + return updated, err } // ChangeUserCountry 修改国家并写日志;冷却期检查和日志写入必须在同一事务内完成。 @@ -285,4 +308,32 @@ func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.C return updated, command.ChangedAtMs + command.CooldownMs, err } +func (r *Repository) hydrateInviteOverview(ctx context.Context, user *userdomain.User) error { + if user == nil || user.UserID <= 0 { + return nil + } + overview, err := invitestorage.New(r.db).GetOverview(ctx, user.AppCode, user.UserID, user.RegionID, timeNowMs()) + if err != nil { + return err + } + user.InviteOverview = overview + return nil +} + +func invitestorageBindCommand(command userdomain.CompleteOnboardingCommand) invitedomain.BindCommand { + return invitedomain.BindCommand{ + AppCode: command.AppCode, + InvitedUserID: command.UserID, + InvitedRegionID: command.RegionID, + InviteCode: command.InviteCode, + Source: "complete_onboarding", + RequestID: command.RequestID, + BoundAtMs: command.CompletedAtMs, + } +} + +func timeNowMs() int64 { + return time.Now().UnixMilli() +} + // QueryUser 读取 users 快照字段;既服务普通查询,也服务短号事务中的 FOR UPDATE 读。 diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index f97c8bb2..1624c92f 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -10,6 +10,7 @@ import ( "hyapp/pkg/appcode" authdomain "hyapp/services/user-service/internal/domain/auth" hostdomain "hyapp/services/user-service/internal/domain/host" + invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" mysqlstorage "hyapp/services/user-service/internal/storage/mysql" "hyapp/services/user-service/internal/testutil/mysqlschema" @@ -217,8 +218,8 @@ func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string } // CreateThirdPartyUser 让测试 wrapper 继续满足认证接口。 -func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session) error { - return r.Repository.AuthRepository().CreateThirdPartyUser(ctx, user, identity, thirdParty, session) +func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand) error { + return r.Repository.AuthRepository().CreateThirdPartyUser(ctx, user, identity, thirdParty, session, invite) } // RecordLoginAudit 让测试 wrapper 继续满足认证接口。 diff --git a/services/user-service/internal/transport/grpc/convert.go b/services/user-service/internal/transport/grpc/convert.go index 630a0a51..4142f6e5 100644 --- a/services/user-service/internal/transport/grpc/convert.go +++ b/services/user-service/internal/transport/grpc/convert.go @@ -5,6 +5,8 @@ import ( userv1 "hyapp.local/api/proto/user/v1" authdomain "hyapp/services/user-service/internal/domain/auth" hostdomain "hyapp/services/user-service/internal/domain/host" + invitedomain "hyapp/services/user-service/internal/domain/invite" + mictimedomain "hyapp/services/user-service/internal/domain/mictime" userdomain "hyapp/services/user-service/internal/domain/user" authservice "hyapp/services/user-service/internal/service/auth" ) @@ -74,6 +76,44 @@ func toProtoUser(user userdomain.User) *userv1.User { IsoNumeric: user.ISONumeric, PhoneCountryCode: user.PhoneCountryCode, CountryEnabled: user.CountryEnabled, + Invite: toProtoInviteOverview(user.InviteOverview), + } +} + +func toProtoInviteOverview(overview invitedomain.Overview) *userv1.InviteOverview { + if overview.MyInviteCode == "" && !overview.InviteEnabled && overview.InviteCount == 0 && overview.ValidInviteCount == 0 && overview.ValidInviteThresholdCoin == 0 { + return nil + } + return &userv1.InviteOverview{ + MyInviteCode: overview.MyInviteCode, + InviteEnabled: overview.InviteEnabled, + InviteCount: overview.InviteCount, + ValidInviteCount: overview.ValidInviteCount, + ValidInviteThresholdCoin: overview.ValidInviteThresholdCoin, + } +} + +func toProtoInviteBinding(binding invitedomain.Binding) *userv1.InviteBinding { + if !binding.Bound && binding.InviteCode == "" && binding.InviterUserID == 0 { + return nil + } + return &userv1.InviteBinding{ + Bound: binding.Bound, + InviteCode: binding.InviteCode, + InviterUserId: binding.InviterUserID, + } +} + +func toProtoMicLifetimeStats(stats mictimedomain.LifetimeStats) *userv1.UserMicLifetimeStats { + return &userv1.UserMicLifetimeStats{ + AppCode: stats.AppCode, + UserId: stats.UserID, + SeatOccupiedMs: stats.SeatOccupiedMs, + MicOnlineMs: stats.MicOnlineMs, + SessionCount: stats.SessionCount, + FirstMicAtMs: stats.FirstMicAtMs, + LastMicAtMs: stats.LastMicAtMs, + UpdatedAtMs: stats.UpdatedAtMs, } } diff --git a/services/user-service/internal/transport/grpc/server.go b/services/user-service/internal/transport/grpc/server.go index 75d11acd..959ada0f 100644 --- a/services/user-service/internal/transport/grpc/server.go +++ b/services/user-service/internal/transport/grpc/server.go @@ -11,6 +11,7 @@ import ( 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" ) @@ -43,6 +44,8 @@ type Server struct { userSvc *userservice.Service // hostSvc 承载 user-service 内 host/Agency/BD 事实用例。 hostSvc *hostservice.Service + // micTimeSvc 承载用户维度麦位在线时长聚合查询。 + micTimeSvc *mictimeservice.Service } // NewServer 创建 user-service gRPC server。 @@ -59,6 +62,11 @@ func NewServer(authSvc *authservice.Service, userSvc *userservice.Service, hostS 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()) @@ -193,6 +201,23 @@ func (s *Server) ListUserIDs(ctx context.Context, req *userv1.ListUserIDsRequest 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()) @@ -229,7 +254,7 @@ func (s *Server) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnb 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()) + 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) } @@ -245,6 +270,7 @@ func (s *Server) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnb ProfileCompletedAtMs: user.ProfileCompletedAtMs, OnboardingStatus: string(user.OnboardingStatus), Token: toProtoToken(token), + Invite: toProtoInviteBinding(user.InviteBinding), }, nil } diff --git a/services/user-service/internal/transport/grpc/server_test.go b/services/user-service/internal/transport/grpc/server_test.go index 97007834..5eaa48da 100644 --- a/services/user-service/internal/transport/grpc/server_test.go +++ b/services/user-service/internal/transport/grpc/server_test.go @@ -72,6 +72,30 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) { authservice.WithIdentityRepository(repository), authservice.WithCountryRegionRepository(repository), authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"}))) server := grpcserver.NewServer(authSvc, newUserService(repository)) + if err := repository.CreateUserWithIdentity(ctx, userdomain.User{ + AppCode: "lalu", + UserID: 800001, + DefaultDisplayUserID: "188001", + CurrentDisplayUserID: "188001", + CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault, + Status: userdomain.StatusActive, + OnboardingStatus: userdomain.OnboardingStatusProfileRequired, + CreatedAtMs: 1000, + UpdatedAtMs: 1000, + }, userdomain.Identity{ + AppCode: "lalu", + UserID: 800001, + DisplayUserID: "188001", + DefaultDisplayUserID: "188001", + DisplayUserIDKind: userdomain.DisplayUserIDKindDefault, + Status: userdomain.DisplayUserIDStatusActive, + }); err != nil { + t.Fatalf("seed inviter failed: %v", err) + } + inviter, err := repository.GetUser(ctx, 800001) + if err != nil { + t.Fatalf("get inviter failed: %v", err) + } resp, err := server.LoginThirdParty(ctx, &userv1.LoginThirdPartyRequest{ Meta: &userv1.RequestMeta{RequestId: "req-third", DeviceId: "meta-device", ClientIp: "203.0.113.10", UserAgent: "hyapp-test/1.0", CountryByIp: "US"}, @@ -80,7 +104,7 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) { Username: "hy", Gender: "male", Country: "CN", - InviteCode: "INV-1", + InviteCode: inviter.InviteOverview.MyInviteCode, DeviceId: "req-device", Device: "iPhone 15", OsVersion: "iOS 18.1", @@ -103,7 +127,7 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) { if err != nil { t.Fatalf("GetUser failed: %v", err) } - if user.Username != "hy" || user.InviteCode != "INV-1" || user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "US" || user.RegisterDeviceID != "req-device" { + if user.Username != "hy" || user.InviteCode != inviter.InviteOverview.MyInviteCode || user.RegisterIP != "203.0.113.10" || user.RegisterUserAgent != "hyapp-test/1.0" || user.CountryByIP != "US" || user.RegisterDeviceID != "req-device" { t.Fatalf("registration fields were not mapped: %+v", user) } if user.RegisterDevice != "iPhone 15" || user.RegisterOSVersion != "iOS 18.1" || user.BirthDate != "2000-01-02" || user.RegisterAppVersion != "1.2.3" || user.RegisterBuildNumber != "123" || user.RegisterInstallChannel != "app_store" || user.RegisterCampaign != "spring_launch" || user.RegisterPlatform != "ios" || user.Timezone != "America/Los_Angeles" {