Merge branch 'main' into test

This commit is contained in:
zhx 2026-06-30 11:39:23 +08:00
commit ce4f1dc4c1
72 changed files with 7226 additions and 4046 deletions

View File

@ -1448,6 +1448,122 @@ func (x *CheckLoginRiskIPWhitelistResponse) GetWhitelisted() bool {
return false
}
// RefreshLoginRiskWhitelistCacheRequest 要求 user-service 从 MySQL 重建登录风控白名单缓存。
type RefreshLoginRiskWhitelistCacheRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
}
func (x *RefreshLoginRiskWhitelistCacheRequest) Reset() {
*x = RefreshLoginRiskWhitelistCacheRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RefreshLoginRiskWhitelistCacheRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RefreshLoginRiskWhitelistCacheRequest) ProtoMessage() {}
func (x *RefreshLoginRiskWhitelistCacheRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[18]
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 RefreshLoginRiskWhitelistCacheRequest.ProtoReflect.Descriptor instead.
func (*RefreshLoginRiskWhitelistCacheRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{18}
}
func (x *RefreshLoginRiskWhitelistCacheRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
// RefreshLoginRiskWhitelistCacheResponse 返回本次刷新后的白名单规模,便于后台保存后确认缓存已更新。
type RefreshLoginRiskWhitelistCacheResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Refreshed bool `protobuf:"varint,1,opt,name=refreshed,proto3" json:"refreshed,omitempty"`
IpWhitelistCount int32 `protobuf:"varint,2,opt,name=ip_whitelist_count,json=ipWhitelistCount,proto3" json:"ip_whitelist_count,omitempty"`
UserWhitelistCount int32 `protobuf:"varint,3,opt,name=user_whitelist_count,json=userWhitelistCount,proto3" json:"user_whitelist_count,omitempty"`
UserCacheExpiresAtMs int64 `protobuf:"varint,4,opt,name=user_cache_expires_at_ms,json=userCacheExpiresAtMs,proto3" json:"user_cache_expires_at_ms,omitempty"`
}
func (x *RefreshLoginRiskWhitelistCacheResponse) Reset() {
*x = RefreshLoginRiskWhitelistCacheResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RefreshLoginRiskWhitelistCacheResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RefreshLoginRiskWhitelistCacheResponse) ProtoMessage() {}
func (x *RefreshLoginRiskWhitelistCacheResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[19]
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 RefreshLoginRiskWhitelistCacheResponse.ProtoReflect.Descriptor instead.
func (*RefreshLoginRiskWhitelistCacheResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{19}
}
func (x *RefreshLoginRiskWhitelistCacheResponse) GetRefreshed() bool {
if x != nil {
return x.Refreshed
}
return false
}
func (x *RefreshLoginRiskWhitelistCacheResponse) GetIpWhitelistCount() int32 {
if x != nil {
return x.IpWhitelistCount
}
return 0
}
func (x *RefreshLoginRiskWhitelistCacheResponse) GetUserWhitelistCount() int32 {
if x != nil {
return x.UserWhitelistCount
}
return 0
}
func (x *RefreshLoginRiskWhitelistCacheResponse) GetUserCacheExpiresAtMs() int64 {
if x != nil {
return x.UserCacheExpiresAtMs
}
return 0
}
// RegisterRiskConfig 是注册链路读取的风控配置。
type RegisterRiskConfig struct {
state protoimpl.MessageState
@ -1462,7 +1578,7 @@ type RegisterRiskConfig struct {
func (x *RegisterRiskConfig) Reset() {
*x = RegisterRiskConfig{}
mi := &file_proto_user_v1_auth_proto_msgTypes[18]
mi := &file_proto_user_v1_auth_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1474,7 +1590,7 @@ func (x *RegisterRiskConfig) String() string {
func (*RegisterRiskConfig) ProtoMessage() {}
func (x *RegisterRiskConfig) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[18]
mi := &file_proto_user_v1_auth_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1487,7 +1603,7 @@ func (x *RegisterRiskConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use RegisterRiskConfig.ProtoReflect.Descriptor instead.
func (*RegisterRiskConfig) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{18}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{20}
}
func (x *RegisterRiskConfig) GetAppCode() string {
@ -1529,7 +1645,7 @@ type GetRegisterRiskConfigRequest struct {
func (x *GetRegisterRiskConfigRequest) Reset() {
*x = GetRegisterRiskConfigRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[19]
mi := &file_proto_user_v1_auth_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1541,7 +1657,7 @@ func (x *GetRegisterRiskConfigRequest) String() string {
func (*GetRegisterRiskConfigRequest) ProtoMessage() {}
func (x *GetRegisterRiskConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[19]
mi := &file_proto_user_v1_auth_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1554,7 +1670,7 @@ func (x *GetRegisterRiskConfigRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetRegisterRiskConfigRequest.ProtoReflect.Descriptor instead.
func (*GetRegisterRiskConfigRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{19}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{21}
}
func (x *GetRegisterRiskConfigRequest) GetMeta() *RequestMeta {
@ -1574,7 +1690,7 @@ type GetRegisterRiskConfigResponse struct {
func (x *GetRegisterRiskConfigResponse) Reset() {
*x = GetRegisterRiskConfigResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[20]
mi := &file_proto_user_v1_auth_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1586,7 +1702,7 @@ func (x *GetRegisterRiskConfigResponse) String() string {
func (*GetRegisterRiskConfigResponse) ProtoMessage() {}
func (x *GetRegisterRiskConfigResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[20]
mi := &file_proto_user_v1_auth_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1599,7 +1715,7 @@ func (x *GetRegisterRiskConfigResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetRegisterRiskConfigResponse.ProtoReflect.Descriptor instead.
func (*GetRegisterRiskConfigResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{20}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{22}
}
func (x *GetRegisterRiskConfigResponse) GetConfig() *RegisterRiskConfig {
@ -1622,7 +1738,7 @@ type UpdateRegisterRiskConfigRequest struct {
func (x *UpdateRegisterRiskConfigRequest) Reset() {
*x = UpdateRegisterRiskConfigRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[21]
mi := &file_proto_user_v1_auth_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1634,7 +1750,7 @@ func (x *UpdateRegisterRiskConfigRequest) String() string {
func (*UpdateRegisterRiskConfigRequest) ProtoMessage() {}
func (x *UpdateRegisterRiskConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[21]
mi := &file_proto_user_v1_auth_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1647,7 +1763,7 @@ func (x *UpdateRegisterRiskConfigRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateRegisterRiskConfigRequest.ProtoReflect.Descriptor instead.
func (*UpdateRegisterRiskConfigRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{21}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{23}
}
func (x *UpdateRegisterRiskConfigRequest) GetMeta() *RequestMeta {
@ -1681,7 +1797,7 @@ type UpdateRegisterRiskConfigResponse struct {
func (x *UpdateRegisterRiskConfigResponse) Reset() {
*x = UpdateRegisterRiskConfigResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[22]
mi := &file_proto_user_v1_auth_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1693,7 +1809,7 @@ func (x *UpdateRegisterRiskConfigResponse) String() string {
func (*UpdateRegisterRiskConfigResponse) ProtoMessage() {}
func (x *UpdateRegisterRiskConfigResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[22]
mi := &file_proto_user_v1_auth_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1706,7 +1822,7 @@ func (x *UpdateRegisterRiskConfigResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateRegisterRiskConfigResponse.ProtoReflect.Descriptor instead.
func (*UpdateRegisterRiskConfigResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{22}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{24}
}
func (x *UpdateRegisterRiskConfigResponse) GetConfig() *RegisterRiskConfig {
@ -1729,7 +1845,7 @@ type AppHeartbeatRequest struct {
func (x *AppHeartbeatRequest) Reset() {
*x = AppHeartbeatRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[23]
mi := &file_proto_user_v1_auth_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1741,7 +1857,7 @@ func (x *AppHeartbeatRequest) String() string {
func (*AppHeartbeatRequest) ProtoMessage() {}
func (x *AppHeartbeatRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[23]
mi := &file_proto_user_v1_auth_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1754,7 +1870,7 @@ func (x *AppHeartbeatRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use AppHeartbeatRequest.ProtoReflect.Descriptor instead.
func (*AppHeartbeatRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{23}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{25}
}
func (x *AppHeartbeatRequest) GetMeta() *RequestMeta {
@ -1792,7 +1908,7 @@ type AppHeartbeatResponse struct {
func (x *AppHeartbeatResponse) Reset() {
*x = AppHeartbeatResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[24]
mi := &file_proto_user_v1_auth_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1804,7 +1920,7 @@ func (x *AppHeartbeatResponse) String() string {
func (*AppHeartbeatResponse) ProtoMessage() {}
func (x *AppHeartbeatResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[24]
mi := &file_proto_user_v1_auth_proto_msgTypes[26]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1817,7 +1933,7 @@ func (x *AppHeartbeatResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use AppHeartbeatResponse.ProtoReflect.Descriptor instead.
func (*AppHeartbeatResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{24}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{26}
}
func (x *AppHeartbeatResponse) GetAccepted() bool {
@ -2073,152 +2189,181 @@ var file_proto_user_v1_auth_proto_rawDesc = []byte{
0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x49, 0x50, 0x57, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73,
0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x77, 0x68, 0x69,
0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b,
0x77, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x64, 0x22, 0xb9, 0x01, 0x0a, 0x12,
0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x35, 0x0a,
0x17, 0x6d, 0x61, 0x78, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x5f, 0x70, 0x65,
0x72, 0x5f, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x14,
0x6d, 0x61, 0x78, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x50, 0x65, 0x72, 0x44, 0x65,
0x76, 0x69, 0x63, 0x65, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f,
0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64,
0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x2d, 0x0a, 0x13, 0x75, 0x70, 0x64, 0x61,
0x74, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x5f, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x5f, 0x69, 0x64, 0x18,
0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79,
0x41, 0x64, 0x6d, 0x69, 0x6e, 0x49, 0x64, 0x22, 0x4e, 0x0a, 0x1c, 0x47, 0x65, 0x74, 0x52, 0x65,
0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 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, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74,
0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x5a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x52, 0x65,
0x77, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x64, 0x22, 0x57, 0x0a, 0x25, 0x52,
0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x57,
0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 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, 0xde, 0x01, 0x0a, 0x26, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68,
0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x57, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69,
0x73, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x65, 0x64, 0x12, 0x2c, 0x0a,
0x12, 0x69, 0x70, 0x5f, 0x77, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x63, 0x6f,
0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x10, 0x69, 0x70, 0x57, 0x68, 0x69,
0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x30, 0x0a, 0x14, 0x75,
0x73, 0x65, 0x72, 0x5f, 0x77, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x63, 0x6f,
0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x12, 0x75, 0x73, 0x65, 0x72, 0x57,
0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x36, 0x0a,
0x18, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x65, 0x78, 0x70, 0x69,
0x72, 0x65, 0x73, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
0x14, 0x75, 0x73, 0x65, 0x72, 0x43, 0x61, 0x63, 0x68, 0x65, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65,
0x73, 0x41, 0x74, 0x4d, 0x73, 0x22, 0xb9, 0x01, 0x0a, 0x12, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74,
0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x19, 0x0a, 0x08,
0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x78, 0x5f, 0x61,
0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x5f, 0x70, 0x65, 0x72, 0x5f, 0x64, 0x65, 0x76, 0x69,
0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x14, 0x6d, 0x61, 0x78, 0x41, 0x63, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x73, 0x50, 0x65, 0x72, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x12, 0x22,
0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74,
0x4d, 0x73, 0x12, 0x2d, 0x0a, 0x13, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x62, 0x79,
0x5f, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
0x10, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x42, 0x79, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x49,
0x64, 0x22, 0x4e, 0x0a, 0x1c, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72,
0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 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, 0x2e,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74,
0x61, 0x22, 0x5a, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72,
0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x39, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xb4, 0x01,
0x0a, 0x1f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72,
0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 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, 0x2e,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74,
0x61, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x78, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x73, 0x5f, 0x70, 0x65, 0x72, 0x5f, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x05, 0x52, 0x14, 0x6d, 0x61, 0x78, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x50,
0x65, 0x72, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x6f, 0x70, 0x65, 0x72,
0x61, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x41, 0x64, 0x6d,
0x69, 0x6e, 0x49, 0x64, 0x22, 0x5d, 0x0a, 0x20, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65,
0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65,
0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x22, 0xb4, 0x01, 0x0a, 0x1f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65,
0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 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, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74,
0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x78, 0x5f, 0x61,
0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x5f, 0x70, 0x65, 0x72, 0x5f, 0x64, 0x65, 0x76, 0x69,
0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x14, 0x6d, 0x61, 0x78, 0x41, 0x63, 0x63,
0x6f, 0x75, 0x6e, 0x74, 0x73, 0x50, 0x65, 0x72, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2a,
0x0a, 0x11, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x64, 0x6d, 0x69, 0x6e,
0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x6f, 0x70, 0x65, 0x72, 0x61,
0x74, 0x6f, 0x72, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x49, 0x64, 0x22, 0x5d, 0x0a, 0x20, 0x55, 0x70,
0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39,
0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x7d, 0x0a, 0x13, 0x41, 0x70, 0x70,
0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 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, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73,
0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73,
0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0xb0, 0x01, 0x0a, 0x14, 0x41, 0x70, 0x70,
0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x26, 0x0a,
0x0f, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73,
0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61,
0x74, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f,
0x74, 0x69, 0x6d, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73,
0x65, 0x72, 0x76, 0x65, 0x72, 0x54, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x12, 0x2e, 0x0a, 0x05, 0x74,
0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 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, 0x32, 0xaa, 0x0a, 0x0a, 0x0b,
0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x0d, 0x4c,
0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x23, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67,
0x69, 0x6e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55,
0x0a, 0x0f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74,
0x79, 0x12, 0x25, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74,
0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x12, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65,
0x72, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x12, 0x28, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69,
0x73, 0x74, 0x65, 0x72, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x7e, 0x0a, 0x19, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x54, 0x68, 0x69, 0x72, 0x64,
0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x12,
0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x66, 0x69, 0x67, 0x22, 0x7d, 0x0a, 0x13, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62,
0x65, 0x61, 0x74, 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, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
0x49, 0x64, 0x22, 0xb0, 0x01, 0x0a, 0x14, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62,
0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x61,
0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61,
0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x68, 0x65, 0x61, 0x72, 0x74,
0x62, 0x65, 0x61, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0d, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x12,
0x24, 0x0a, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x6d,
0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x54,
0x69, 0x6d, 0x65, 0x4d, 0x73, 0x12, 0x2e, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04,
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, 0x32, 0xba, 0x0b, 0x0a, 0x0b, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65,
0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61,
0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75,
0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61, 0x73, 0x73,
0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x0f, 0x4c, 0x6f, 0x67, 0x69,
0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x12, 0x25, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69,
0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x5b, 0x0a, 0x12, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x54, 0x68, 0x69, 0x72, 0x64,
0x50, 0x61, 0x72, 0x74, 0x79, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x54, 0x68,
0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7e, 0x0a, 0x19,
0x43, 0x68, 0x65, 0x63, 0x6b, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52,
0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31,
0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79,
0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x54, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72,
0x64, 0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x2e, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65,
0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x12, 0x51, 0x75, 0x69, 0x63,
0x6b, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x28,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x51,
0x75, 0x69, 0x63, 0x6b, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e,
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x12, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x54,
0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65,
0x72, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b,
0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74,
0x65, 0x72, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0b,
0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x21, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x50,
0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53,
0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x69, 0x0a, 0x12, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x43, 0x72, 0x65, 0x61, 0x74,
0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x43, 0x72,
0x65, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0c, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f,
0x6b, 0x65, 0x6e, 0x12, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54,
0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x06,
0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75,
0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65,
0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67,
0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64,
0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72,
0x65, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x63,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a,
0x0c, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x22, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65,
0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74,
0x12, 0x1c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31,
0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c,
0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a,
0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63,
0x6b, 0x65, 0x64, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42,
0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7e,
0x0a, 0x19, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b,
0x49, 0x50, 0x57, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x12, 0x2f, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63,
0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65,
0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7e, 0x0a, 0x19, 0x43, 0x68, 0x65, 0x63,
0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x49, 0x50, 0x57, 0x68, 0x69, 0x74,
0x65, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65,
0x63, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x49, 0x50, 0x57, 0x68, 0x69,
0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x72,
0x0a, 0x15, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73,
0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73,
0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65,
0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72,
0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x7b, 0x0a, 0x18, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69,
0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2e,
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, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73,
0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f,
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, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73,
0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x57, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x12,
0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72,
0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74,
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,
0x65, 0x6c, 0x69, 0x73, 0x74, 0x12, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
0x52, 0x69, 0x73, 0x6b, 0x49, 0x50, 0x57, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75,
0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x6f, 0x67, 0x69,
0x6e, 0x52, 0x69, 0x73, 0x6b, 0x49, 0x50, 0x57, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x8d, 0x01, 0x0a, 0x1e, 0x52, 0x65, 0x66,
0x72, 0x65, 0x73, 0x68, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x57, 0x68, 0x69,
0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x12, 0x34, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66, 0x72,
0x65, 0x73, 0x68, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x57, 0x68, 0x69, 0x74,
0x65, 0x6c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x35, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69,
0x73, 0x6b, 0x57, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x43, 0x61, 0x63, 0x68, 0x65,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x72, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x52,
0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73,
0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x47,
0x65, 0x74, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7b, 0x0a, 0x18,
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69,
0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2e, 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, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 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, 0x73, 0x74, 0x65, 0x72, 0x52, 0x69, 0x73, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0c, 0x41, 0x70, 0x70,
0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x12, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61,
0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70,
0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 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 (
@ -2233,87 +2378,92 @@ func file_proto_user_v1_auth_proto_rawDescGZIP() []byte {
return file_proto_user_v1_auth_proto_rawDescData
}
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 25)
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
var file_proto_user_v1_auth_proto_goTypes = []any{
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
(*RegisterThirdPartyRequest)(nil), // 2: hyapp.user.v1.RegisterThirdPartyRequest
(*CheckThirdPartyRegisteredRequest)(nil), // 3: hyapp.user.v1.CheckThirdPartyRegisteredRequest
(*CheckThirdPartyRegisteredResponse)(nil), // 4: hyapp.user.v1.CheckThirdPartyRegisteredResponse
(*AuthResponse)(nil), // 5: hyapp.user.v1.AuthResponse
(*SetPasswordRequest)(nil), // 6: hyapp.user.v1.SetPasswordRequest
(*SetPasswordResponse)(nil), // 7: hyapp.user.v1.SetPasswordResponse
(*QuickCreateAccountRequest)(nil), // 8: hyapp.user.v1.QuickCreateAccountRequest
(*QuickCreateAccountResponse)(nil), // 9: hyapp.user.v1.QuickCreateAccountResponse
(*RefreshTokenRequest)(nil), // 10: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 11: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 12: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 13: hyapp.user.v1.LogoutResponse
(*RecordLoginBlockedRequest)(nil), // 14: hyapp.user.v1.RecordLoginBlockedRequest
(*RecordLoginBlockedResponse)(nil), // 15: hyapp.user.v1.RecordLoginBlockedResponse
(*CheckLoginRiskIPWhitelistRequest)(nil), // 16: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
(*CheckLoginRiskIPWhitelistResponse)(nil), // 17: hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
(*RegisterRiskConfig)(nil), // 18: hyapp.user.v1.RegisterRiskConfig
(*GetRegisterRiskConfigRequest)(nil), // 19: hyapp.user.v1.GetRegisterRiskConfigRequest
(*GetRegisterRiskConfigResponse)(nil), // 20: hyapp.user.v1.GetRegisterRiskConfigResponse
(*UpdateRegisterRiskConfigRequest)(nil), // 21: hyapp.user.v1.UpdateRegisterRiskConfigRequest
(*UpdateRegisterRiskConfigResponse)(nil), // 22: hyapp.user.v1.UpdateRegisterRiskConfigResponse
(*AppHeartbeatRequest)(nil), // 23: hyapp.user.v1.AppHeartbeatRequest
(*AppHeartbeatResponse)(nil), // 24: hyapp.user.v1.AppHeartbeatResponse
(*RequestMeta)(nil), // 25: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 26: hyapp.user.v1.AuthToken
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
(*RegisterThirdPartyRequest)(nil), // 2: hyapp.user.v1.RegisterThirdPartyRequest
(*CheckThirdPartyRegisteredRequest)(nil), // 3: hyapp.user.v1.CheckThirdPartyRegisteredRequest
(*CheckThirdPartyRegisteredResponse)(nil), // 4: hyapp.user.v1.CheckThirdPartyRegisteredResponse
(*AuthResponse)(nil), // 5: hyapp.user.v1.AuthResponse
(*SetPasswordRequest)(nil), // 6: hyapp.user.v1.SetPasswordRequest
(*SetPasswordResponse)(nil), // 7: hyapp.user.v1.SetPasswordResponse
(*QuickCreateAccountRequest)(nil), // 8: hyapp.user.v1.QuickCreateAccountRequest
(*QuickCreateAccountResponse)(nil), // 9: hyapp.user.v1.QuickCreateAccountResponse
(*RefreshTokenRequest)(nil), // 10: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 11: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 12: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 13: hyapp.user.v1.LogoutResponse
(*RecordLoginBlockedRequest)(nil), // 14: hyapp.user.v1.RecordLoginBlockedRequest
(*RecordLoginBlockedResponse)(nil), // 15: hyapp.user.v1.RecordLoginBlockedResponse
(*CheckLoginRiskIPWhitelistRequest)(nil), // 16: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
(*CheckLoginRiskIPWhitelistResponse)(nil), // 17: hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
(*RefreshLoginRiskWhitelistCacheRequest)(nil), // 18: hyapp.user.v1.RefreshLoginRiskWhitelistCacheRequest
(*RefreshLoginRiskWhitelistCacheResponse)(nil), // 19: hyapp.user.v1.RefreshLoginRiskWhitelistCacheResponse
(*RegisterRiskConfig)(nil), // 20: hyapp.user.v1.RegisterRiskConfig
(*GetRegisterRiskConfigRequest)(nil), // 21: hyapp.user.v1.GetRegisterRiskConfigRequest
(*GetRegisterRiskConfigResponse)(nil), // 22: hyapp.user.v1.GetRegisterRiskConfigResponse
(*UpdateRegisterRiskConfigRequest)(nil), // 23: hyapp.user.v1.UpdateRegisterRiskConfigRequest
(*UpdateRegisterRiskConfigResponse)(nil), // 24: hyapp.user.v1.UpdateRegisterRiskConfigResponse
(*AppHeartbeatRequest)(nil), // 25: hyapp.user.v1.AppHeartbeatRequest
(*AppHeartbeatResponse)(nil), // 26: hyapp.user.v1.AppHeartbeatResponse
(*RequestMeta)(nil), // 27: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 28: hyapp.user.v1.AuthToken
}
var file_proto_user_v1_auth_proto_depIdxs = []int32{
25, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
25, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
25, // 2: hyapp.user.v1.RegisterThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
25, // 3: hyapp.user.v1.CheckThirdPartyRegisteredRequest.meta:type_name -> hyapp.user.v1.RequestMeta
26, // 4: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
25, // 5: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
25, // 6: hyapp.user.v1.QuickCreateAccountRequest.meta:type_name -> hyapp.user.v1.RequestMeta
26, // 7: hyapp.user.v1.QuickCreateAccountResponse.token:type_name -> hyapp.user.v1.AuthToken
25, // 8: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
26, // 9: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
25, // 10: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
25, // 11: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
25, // 12: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest.meta:type_name -> hyapp.user.v1.RequestMeta
25, // 13: hyapp.user.v1.GetRegisterRiskConfigRequest.meta:type_name -> hyapp.user.v1.RequestMeta
18, // 14: hyapp.user.v1.GetRegisterRiskConfigResponse.config:type_name -> hyapp.user.v1.RegisterRiskConfig
25, // 15: hyapp.user.v1.UpdateRegisterRiskConfigRequest.meta:type_name -> hyapp.user.v1.RequestMeta
18, // 16: hyapp.user.v1.UpdateRegisterRiskConfigResponse.config:type_name -> hyapp.user.v1.RegisterRiskConfig
25, // 17: hyapp.user.v1.AppHeartbeatRequest.meta:type_name -> hyapp.user.v1.RequestMeta
26, // 18: hyapp.user.v1.AppHeartbeatResponse.token:type_name -> hyapp.user.v1.AuthToken
0, // 19: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 20: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
2, // 21: hyapp.user.v1.AuthService.RegisterThirdParty:input_type -> hyapp.user.v1.RegisterThirdPartyRequest
3, // 22: hyapp.user.v1.AuthService.CheckThirdPartyRegistered:input_type -> hyapp.user.v1.CheckThirdPartyRegisteredRequest
6, // 23: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
8, // 24: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
10, // 25: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
12, // 26: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
14, // 27: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
16, // 28: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:input_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
19, // 29: hyapp.user.v1.AuthService.GetRegisterRiskConfig:input_type -> hyapp.user.v1.GetRegisterRiskConfigRequest
21, // 30: hyapp.user.v1.AuthService.UpdateRegisterRiskConfig:input_type -> hyapp.user.v1.UpdateRegisterRiskConfigRequest
23, // 31: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
5, // 32: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
5, // 33: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
5, // 34: hyapp.user.v1.AuthService.RegisterThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 35: hyapp.user.v1.AuthService.CheckThirdPartyRegistered:output_type -> hyapp.user.v1.CheckThirdPartyRegisteredResponse
7, // 36: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
9, // 37: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
11, // 38: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
13, // 39: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
15, // 40: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
17, // 41: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:output_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
20, // 42: hyapp.user.v1.AuthService.GetRegisterRiskConfig:output_type -> hyapp.user.v1.GetRegisterRiskConfigResponse
22, // 43: hyapp.user.v1.AuthService.UpdateRegisterRiskConfig:output_type -> hyapp.user.v1.UpdateRegisterRiskConfigResponse
24, // 44: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
32, // [32:45] is the sub-list for method output_type
19, // [19:32] is the sub-list for method input_type
19, // [19:19] is the sub-list for extension type_name
19, // [19:19] is the sub-list for extension extendee
0, // [0:19] is the sub-list for field type_name
27, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
27, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
27, // 2: hyapp.user.v1.RegisterThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
27, // 3: hyapp.user.v1.CheckThirdPartyRegisteredRequest.meta:type_name -> hyapp.user.v1.RequestMeta
28, // 4: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
27, // 5: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
27, // 6: hyapp.user.v1.QuickCreateAccountRequest.meta:type_name -> hyapp.user.v1.RequestMeta
28, // 7: hyapp.user.v1.QuickCreateAccountResponse.token:type_name -> hyapp.user.v1.AuthToken
27, // 8: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
28, // 9: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
27, // 10: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
27, // 11: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
27, // 12: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest.meta:type_name -> hyapp.user.v1.RequestMeta
27, // 13: hyapp.user.v1.RefreshLoginRiskWhitelistCacheRequest.meta:type_name -> hyapp.user.v1.RequestMeta
27, // 14: hyapp.user.v1.GetRegisterRiskConfigRequest.meta:type_name -> hyapp.user.v1.RequestMeta
20, // 15: hyapp.user.v1.GetRegisterRiskConfigResponse.config:type_name -> hyapp.user.v1.RegisterRiskConfig
27, // 16: hyapp.user.v1.UpdateRegisterRiskConfigRequest.meta:type_name -> hyapp.user.v1.RequestMeta
20, // 17: hyapp.user.v1.UpdateRegisterRiskConfigResponse.config:type_name -> hyapp.user.v1.RegisterRiskConfig
27, // 18: hyapp.user.v1.AppHeartbeatRequest.meta:type_name -> hyapp.user.v1.RequestMeta
28, // 19: hyapp.user.v1.AppHeartbeatResponse.token:type_name -> hyapp.user.v1.AuthToken
0, // 20: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 21: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
2, // 22: hyapp.user.v1.AuthService.RegisterThirdParty:input_type -> hyapp.user.v1.RegisterThirdPartyRequest
3, // 23: hyapp.user.v1.AuthService.CheckThirdPartyRegistered:input_type -> hyapp.user.v1.CheckThirdPartyRegisteredRequest
6, // 24: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
8, // 25: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
10, // 26: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
12, // 27: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
14, // 28: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
16, // 29: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:input_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
18, // 30: hyapp.user.v1.AuthService.RefreshLoginRiskWhitelistCache:input_type -> hyapp.user.v1.RefreshLoginRiskWhitelistCacheRequest
21, // 31: hyapp.user.v1.AuthService.GetRegisterRiskConfig:input_type -> hyapp.user.v1.GetRegisterRiskConfigRequest
23, // 32: hyapp.user.v1.AuthService.UpdateRegisterRiskConfig:input_type -> hyapp.user.v1.UpdateRegisterRiskConfigRequest
25, // 33: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
5, // 34: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
5, // 35: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
5, // 36: hyapp.user.v1.AuthService.RegisterThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 37: hyapp.user.v1.AuthService.CheckThirdPartyRegistered:output_type -> hyapp.user.v1.CheckThirdPartyRegisteredResponse
7, // 38: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
9, // 39: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
11, // 40: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
13, // 41: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
15, // 42: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
17, // 43: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:output_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
19, // 44: hyapp.user.v1.AuthService.RefreshLoginRiskWhitelistCache:output_type -> hyapp.user.v1.RefreshLoginRiskWhitelistCacheResponse
22, // 45: hyapp.user.v1.AuthService.GetRegisterRiskConfig:output_type -> hyapp.user.v1.GetRegisterRiskConfigResponse
24, // 46: hyapp.user.v1.AuthService.UpdateRegisterRiskConfig:output_type -> hyapp.user.v1.UpdateRegisterRiskConfigResponse
26, // 47: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
34, // [34:48] is the sub-list for method output_type
20, // [20:34] is the sub-list for method input_type
20, // [20:20] is the sub-list for extension type_name
20, // [20:20] is the sub-list for extension extendee
0, // [0:20] is the sub-list for field type_name
}
func init() { file_proto_user_v1_auth_proto_init() }
@ -2328,7 +2478,7 @@ func file_proto_user_v1_auth_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_user_v1_auth_proto_rawDesc,
NumEnums: 0,
NumMessages: 25,
NumMessages: 27,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -171,6 +171,19 @@ message CheckLoginRiskIPWhitelistResponse {
bool whitelisted = 1;
}
// RefreshLoginRiskWhitelistCacheRequest user-service MySQL
message RefreshLoginRiskWhitelistCacheRequest {
RequestMeta meta = 1;
}
// RefreshLoginRiskWhitelistCacheResponse 便
message RefreshLoginRiskWhitelistCacheResponse {
bool refreshed = 1;
int32 ip_whitelist_count = 2;
int32 user_whitelist_count = 3;
int64 user_cache_expires_at_ms = 4;
}
// RegisterRiskConfig
message RegisterRiskConfig {
string app_code = 1;
@ -226,6 +239,7 @@ service AuthService {
rpc Logout(LogoutRequest) returns (LogoutResponse);
rpc RecordLoginBlocked(RecordLoginBlockedRequest) returns (RecordLoginBlockedResponse);
rpc CheckLoginRiskIPWhitelist(CheckLoginRiskIPWhitelistRequest) returns (CheckLoginRiskIPWhitelistResponse);
rpc RefreshLoginRiskWhitelistCache(RefreshLoginRiskWhitelistCacheRequest) returns (RefreshLoginRiskWhitelistCacheResponse);
rpc GetRegisterRiskConfig(GetRegisterRiskConfigRequest) returns (GetRegisterRiskConfigResponse);
rpc UpdateRegisterRiskConfig(UpdateRegisterRiskConfigRequest) returns (UpdateRegisterRiskConfigResponse);
rpc AppHeartbeat(AppHeartbeatRequest) returns (AppHeartbeatResponse);

View File

@ -19,19 +19,20 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_RegisterThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/RegisterThirdParty"
AuthService_CheckThirdPartyRegistered_FullMethodName = "/hyapp.user.v1.AuthService/CheckThirdPartyRegistered"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
AuthService_Logout_FullMethodName = "/hyapp.user.v1.AuthService/Logout"
AuthService_RecordLoginBlocked_FullMethodName = "/hyapp.user.v1.AuthService/RecordLoginBlocked"
AuthService_CheckLoginRiskIPWhitelist_FullMethodName = "/hyapp.user.v1.AuthService/CheckLoginRiskIPWhitelist"
AuthService_GetRegisterRiskConfig_FullMethodName = "/hyapp.user.v1.AuthService/GetRegisterRiskConfig"
AuthService_UpdateRegisterRiskConfig_FullMethodName = "/hyapp.user.v1.AuthService/UpdateRegisterRiskConfig"
AuthService_AppHeartbeat_FullMethodName = "/hyapp.user.v1.AuthService/AppHeartbeat"
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_RegisterThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/RegisterThirdParty"
AuthService_CheckThirdPartyRegistered_FullMethodName = "/hyapp.user.v1.AuthService/CheckThirdPartyRegistered"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
AuthService_Logout_FullMethodName = "/hyapp.user.v1.AuthService/Logout"
AuthService_RecordLoginBlocked_FullMethodName = "/hyapp.user.v1.AuthService/RecordLoginBlocked"
AuthService_CheckLoginRiskIPWhitelist_FullMethodName = "/hyapp.user.v1.AuthService/CheckLoginRiskIPWhitelist"
AuthService_RefreshLoginRiskWhitelistCache_FullMethodName = "/hyapp.user.v1.AuthService/RefreshLoginRiskWhitelistCache"
AuthService_GetRegisterRiskConfig_FullMethodName = "/hyapp.user.v1.AuthService/GetRegisterRiskConfig"
AuthService_UpdateRegisterRiskConfig_FullMethodName = "/hyapp.user.v1.AuthService/UpdateRegisterRiskConfig"
AuthService_AppHeartbeat_FullMethodName = "/hyapp.user.v1.AuthService/AppHeartbeat"
)
// AuthServiceClient is the client API for AuthService service.
@ -50,6 +51,7 @@ type AuthServiceClient interface {
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
RecordLoginBlocked(ctx context.Context, in *RecordLoginBlockedRequest, opts ...grpc.CallOption) (*RecordLoginBlockedResponse, error)
CheckLoginRiskIPWhitelist(ctx context.Context, in *CheckLoginRiskIPWhitelistRequest, opts ...grpc.CallOption) (*CheckLoginRiskIPWhitelistResponse, error)
RefreshLoginRiskWhitelistCache(ctx context.Context, in *RefreshLoginRiskWhitelistCacheRequest, opts ...grpc.CallOption) (*RefreshLoginRiskWhitelistCacheResponse, error)
GetRegisterRiskConfig(ctx context.Context, in *GetRegisterRiskConfigRequest, opts ...grpc.CallOption) (*GetRegisterRiskConfigResponse, error)
UpdateRegisterRiskConfig(ctx context.Context, in *UpdateRegisterRiskConfigRequest, opts ...grpc.CallOption) (*UpdateRegisterRiskConfigResponse, error)
AppHeartbeat(ctx context.Context, in *AppHeartbeatRequest, opts ...grpc.CallOption) (*AppHeartbeatResponse, error)
@ -163,6 +165,16 @@ func (c *authServiceClient) CheckLoginRiskIPWhitelist(ctx context.Context, in *C
return out, nil
}
func (c *authServiceClient) RefreshLoginRiskWhitelistCache(ctx context.Context, in *RefreshLoginRiskWhitelistCacheRequest, opts ...grpc.CallOption) (*RefreshLoginRiskWhitelistCacheResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RefreshLoginRiskWhitelistCacheResponse)
err := c.cc.Invoke(ctx, AuthService_RefreshLoginRiskWhitelistCache_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) GetRegisterRiskConfig(ctx context.Context, in *GetRegisterRiskConfigRequest, opts ...grpc.CallOption) (*GetRegisterRiskConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRegisterRiskConfigResponse)
@ -209,6 +221,7 @@ type AuthServiceServer interface {
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
RecordLoginBlocked(context.Context, *RecordLoginBlockedRequest) (*RecordLoginBlockedResponse, error)
CheckLoginRiskIPWhitelist(context.Context, *CheckLoginRiskIPWhitelistRequest) (*CheckLoginRiskIPWhitelistResponse, error)
RefreshLoginRiskWhitelistCache(context.Context, *RefreshLoginRiskWhitelistCacheRequest) (*RefreshLoginRiskWhitelistCacheResponse, error)
GetRegisterRiskConfig(context.Context, *GetRegisterRiskConfigRequest) (*GetRegisterRiskConfigResponse, error)
UpdateRegisterRiskConfig(context.Context, *UpdateRegisterRiskConfigRequest) (*UpdateRegisterRiskConfigResponse, error)
AppHeartbeat(context.Context, *AppHeartbeatRequest) (*AppHeartbeatResponse, error)
@ -252,6 +265,9 @@ func (UnimplementedAuthServiceServer) RecordLoginBlocked(context.Context, *Recor
func (UnimplementedAuthServiceServer) CheckLoginRiskIPWhitelist(context.Context, *CheckLoginRiskIPWhitelistRequest) (*CheckLoginRiskIPWhitelistResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckLoginRiskIPWhitelist not implemented")
}
func (UnimplementedAuthServiceServer) RefreshLoginRiskWhitelistCache(context.Context, *RefreshLoginRiskWhitelistCacheRequest) (*RefreshLoginRiskWhitelistCacheResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RefreshLoginRiskWhitelistCache not implemented")
}
func (UnimplementedAuthServiceServer) GetRegisterRiskConfig(context.Context, *GetRegisterRiskConfigRequest) (*GetRegisterRiskConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRegisterRiskConfig not implemented")
}
@ -462,6 +478,24 @@ func _AuthService_CheckLoginRiskIPWhitelist_Handler(srv interface{}, ctx context
return interceptor(ctx, in, info, handler)
}
func _AuthService_RefreshLoginRiskWhitelistCache_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RefreshLoginRiskWhitelistCacheRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).RefreshLoginRiskWhitelistCache(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_RefreshLoginRiskWhitelistCache_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).RefreshLoginRiskWhitelistCache(ctx, req.(*RefreshLoginRiskWhitelistCacheRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_GetRegisterRiskConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRegisterRiskConfigRequest)
if err := dec(in); err != nil {
@ -563,6 +597,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CheckLoginRiskIPWhitelist",
Handler: _AuthService_CheckLoginRiskIPWhitelist_Handler,
},
{
MethodName: "RefreshLoginRiskWhitelistCache",
Handler: _AuthService_RefreshLoginRiskWhitelistCache_Handler,
},
{
MethodName: "GetRegisterRiskConfig",
Handler: _AuthService_GetRegisterRiskConfig_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -77,6 +77,7 @@ message CoinSellerListItem {
string status = 12;
string merchant_asset_type = 13;
int64 updated_at_ms = 14;
string contact_info = 15;
}
// UserRoleSummary App 使

File diff suppressed because it is too large Load Diff

View File

@ -86,6 +86,7 @@ message User {
string pretty_id = 29;
string pretty_display_user_id = 30;
string profile_bg_img = 31;
string contact_info = 32;
}
// InviteOverview read model
@ -733,6 +734,18 @@ message UpdateUserProfileBackgroundResponse {
User user = 1;
}
// UpdateUserContactInfoRequest
message UpdateUserContactInfoRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string contact_info = 3;
}
// UpdateUserContactInfoResponse
message UpdateUserContactInfoResponse {
User user = 1;
}
// ChangeUserCountryRequest App
message ChangeUserCountryRequest {
RequestMeta meta = 1;
@ -859,6 +872,29 @@ message CompleteOnboardingResponse {
InviteBinding invite = 6;
}
// SearchInviteReferrerRequest
message SearchInviteReferrerRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string invite_code = 3;
}
message SearchInviteReferrerResponse {
User inviter = 1;
}
// BindInviteReferrerRequest user-service
message BindInviteReferrerRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string invite_code = 3;
}
message BindInviteReferrerResponse {
InviteBinding invite = 1;
User inviter = 2;
}
// BindPushTokenRequest token
// push token 线
message BindPushTokenRequest {
@ -1272,6 +1308,7 @@ service UserService {
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
rpc UpdateUserProfileBackground(UpdateUserProfileBackgroundRequest) returns (UpdateUserProfileBackgroundResponse);
rpc UpdateUserContactInfo(UpdateUserContactInfoRequest) returns (UpdateUserContactInfoResponse);
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc AdminChangeUserCountry(AdminChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc SetUserStatus(SetUserStatusRequest) returns (SetUserStatusResponse);
@ -1279,6 +1316,8 @@ service UserService {
rpc ListManagerUserBlocks(ListManagerUserBlocksRequest) returns (ListManagerUserBlocksResponse);
rpc UnblockManagerUser(UnblockManagerUserRequest) returns (UnblockManagerUserResponse);
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
rpc SearchInviteReferrer(SearchInviteReferrerRequest) returns (SearchInviteReferrerResponse);
rpc BindInviteReferrer(BindInviteReferrerRequest) returns (BindInviteReferrerResponse);
}
// UserSocialService 访

View File

@ -28,6 +28,7 @@ const (
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_UpdateUserProfileBackground_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfileBackground"
UserService_UpdateUserContactInfo_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserContactInfo"
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
@ -35,6 +36,8 @@ const (
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
UserService_SearchInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/SearchInviteReferrer"
UserService_BindInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/BindInviteReferrer"
)
// UserServiceClient is the client API for UserService service.
@ -52,6 +55,7 @@ type UserServiceClient interface {
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(ctx context.Context, in *UpdateUserProfileBackgroundRequest, opts ...grpc.CallOption) (*UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(ctx context.Context, in *UpdateUserContactInfoRequest, opts ...grpc.CallOption) (*UpdateUserContactInfoResponse, error)
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(ctx context.Context, in *AdminChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
SetUserStatus(ctx context.Context, in *SetUserStatusRequest, opts ...grpc.CallOption) (*SetUserStatusResponse, error)
@ -59,6 +63,8 @@ type UserServiceClient interface {
ListManagerUserBlocks(ctx context.Context, in *ListManagerUserBlocksRequest, opts ...grpc.CallOption) (*ListManagerUserBlocksResponse, error)
UnblockManagerUser(ctx context.Context, in *UnblockManagerUserRequest, opts ...grpc.CallOption) (*UnblockManagerUserResponse, error)
CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error)
SearchInviteReferrer(ctx context.Context, in *SearchInviteReferrerRequest, opts ...grpc.CallOption) (*SearchInviteReferrerResponse, error)
BindInviteReferrer(ctx context.Context, in *BindInviteReferrerRequest, opts ...grpc.CallOption) (*BindInviteReferrerResponse, error)
}
type userServiceClient struct {
@ -159,6 +165,16 @@ func (c *userServiceClient) UpdateUserProfileBackground(ctx context.Context, in
return out, nil
}
func (c *userServiceClient) UpdateUserContactInfo(ctx context.Context, in *UpdateUserContactInfoRequest, opts ...grpc.CallOption) (*UpdateUserContactInfoResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateUserContactInfoResponse)
err := c.cc.Invoke(ctx, UserService_UpdateUserContactInfo_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ChangeUserCountryResponse)
@ -229,6 +245,26 @@ func (c *userServiceClient) CompleteOnboarding(ctx context.Context, in *Complete
return out, nil
}
func (c *userServiceClient) SearchInviteReferrer(ctx context.Context, in *SearchInviteReferrerRequest, opts ...grpc.CallOption) (*SearchInviteReferrerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SearchInviteReferrerResponse)
err := c.cc.Invoke(ctx, UserService_SearchInviteReferrer_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) BindInviteReferrer(ctx context.Context, in *BindInviteReferrerRequest, opts ...grpc.CallOption) (*BindInviteReferrerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BindInviteReferrerResponse)
err := c.cc.Invoke(ctx, UserService_BindInviteReferrer_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserServiceServer is the server API for UserService service.
// All implementations must embed UnimplementedUserServiceServer
// for forward compatibility.
@ -244,6 +280,7 @@ type UserServiceServer interface {
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(context.Context, *UpdateUserProfileBackgroundRequest) (*UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(context.Context, *UpdateUserContactInfoRequest) (*UpdateUserContactInfoResponse, error)
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(context.Context, *AdminChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error)
@ -251,6 +288,8 @@ type UserServiceServer interface {
ListManagerUserBlocks(context.Context, *ListManagerUserBlocksRequest) (*ListManagerUserBlocksResponse, error)
UnblockManagerUser(context.Context, *UnblockManagerUserRequest) (*UnblockManagerUserResponse, error)
CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error)
SearchInviteReferrer(context.Context, *SearchInviteReferrerRequest) (*SearchInviteReferrerResponse, error)
BindInviteReferrer(context.Context, *BindInviteReferrerRequest) (*BindInviteReferrerResponse, error)
mustEmbedUnimplementedUserServiceServer()
}
@ -288,6 +327,9 @@ func (UnimplementedUserServiceServer) UpdateUserProfile(context.Context, *Update
func (UnimplementedUserServiceServer) UpdateUserProfileBackground(context.Context, *UpdateUserProfileBackgroundRequest) (*UpdateUserProfileBackgroundResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserProfileBackground not implemented")
}
func (UnimplementedUserServiceServer) UpdateUserContactInfo(context.Context, *UpdateUserContactInfoRequest) (*UpdateUserContactInfoResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserContactInfo not implemented")
}
func (UnimplementedUserServiceServer) ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ChangeUserCountry not implemented")
}
@ -309,6 +351,12 @@ func (UnimplementedUserServiceServer) UnblockManagerUser(context.Context, *Unblo
func (UnimplementedUserServiceServer) CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CompleteOnboarding not implemented")
}
func (UnimplementedUserServiceServer) SearchInviteReferrer(context.Context, *SearchInviteReferrerRequest) (*SearchInviteReferrerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SearchInviteReferrer not implemented")
}
func (UnimplementedUserServiceServer) BindInviteReferrer(context.Context, *BindInviteReferrerRequest) (*BindInviteReferrerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BindInviteReferrer not implemented")
}
func (UnimplementedUserServiceServer) mustEmbedUnimplementedUserServiceServer() {}
func (UnimplementedUserServiceServer) testEmbeddedByValue() {}
@ -492,6 +540,24 @@ func _UserService_UpdateUserProfileBackground_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _UserService_UpdateUserContactInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateUserContactInfoRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).UpdateUserContactInfo(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_UpdateUserContactInfo_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).UpdateUserContactInfo(ctx, req.(*UpdateUserContactInfoRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_ChangeUserCountry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ChangeUserCountryRequest)
if err := dec(in); err != nil {
@ -618,6 +684,42 @@ func _UserService_CompleteOnboarding_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _UserService_SearchInviteReferrer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SearchInviteReferrerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).SearchInviteReferrer(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_SearchInviteReferrer_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).SearchInviteReferrer(ctx, req.(*SearchInviteReferrerRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_BindInviteReferrer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BindInviteReferrerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).BindInviteReferrer(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_BindInviteReferrer_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).BindInviteReferrer(ctx, req.(*BindInviteReferrerRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserService_ServiceDesc is the grpc.ServiceDesc for UserService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -661,6 +763,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpdateUserProfileBackground",
Handler: _UserService_UpdateUserProfileBackground_Handler,
},
{
MethodName: "UpdateUserContactInfo",
Handler: _UserService_UpdateUserContactInfo_Handler,
},
{
MethodName: "ChangeUserCountry",
Handler: _UserService_ChangeUserCountry_Handler,
@ -689,6 +795,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CompleteOnboarding",
Handler: _UserService_CompleteOnboarding_Handler,
},
{
MethodName: "SearchInviteReferrer",
Handler: _UserService_SearchInviteReferrer_Handler,
},
{
MethodName: "BindInviteReferrer",
Handler: _UserService_BindInviteReferrer_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS login_risk_user_whitelist (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
whitelist_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '白名单 ID',
user_id BIGINT NOT NULL COMMENT '允许跳过登录地区屏蔽复核的用户 ID',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用',
created_by_user_id BIGINT NULL COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NULL COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (whitelist_id),
UNIQUE KEY uk_login_risk_user_whitelist_user (app_code, user_id),
KEY idx_login_risk_user_whitelist_enabled (app_code, enabled, user_id),
KEY idx_login_risk_user_whitelist_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录风险用户白名单表';

View File

@ -294,7 +294,7 @@ func main() {
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
Report: reportmodule.New(userDB, roomClient),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, userclient.NewGRPC(userConn), auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler),
RiskConfig: riskconfigmodule.New(userclient.NewGRPC(userConn), auditHandler),
RoomAdmin: roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler),

View File

@ -17,6 +17,7 @@ type Client interface {
SetUserStatus(ctx context.Context, req SetUserStatusRequest) (*SetUserStatusResult, error)
GetRegisterRiskConfig(ctx context.Context, req GetRegisterRiskConfigRequest) (*RegisterRiskConfig, error)
UpdateRegisterRiskConfig(ctx context.Context, req UpdateRegisterRiskConfigRequest) (*RegisterRiskConfig, error)
RefreshLoginRiskWhitelistCache(ctx context.Context, req RefreshLoginRiskWhitelistCacheRequest) (*LoginRiskWhitelistCacheSnapshot, error)
ResolveDisplayUserID(ctx context.Context, req ResolveDisplayUserIDRequest) (*UserIdentity, error)
ListPrettyDisplayIDPools(ctx context.Context, req ListPrettyDisplayIDPoolsRequest) ([]*PrettyDisplayIDPool, int64, error)
CreatePrettyDisplayIDPool(ctx context.Context, req PrettyDisplayIDPoolRequest) (*PrettyDisplayIDPool, error)
@ -139,6 +140,18 @@ type RegisterRiskConfig struct {
UpdatedByAdminID int64 `json:"updatedByAdminId"`
}
type RefreshLoginRiskWhitelistCacheRequest struct {
RequestID string
Caller string
}
type LoginRiskWhitelistCacheSnapshot struct {
Refreshed bool `json:"refreshed"`
IPWhitelistCount int32 `json:"ipWhitelistCount"`
UserWhitelistCount int32 `json:"userWhitelistCount"`
UserCacheExpiresAtMs int64 `json:"userCacheExpiresAtMs"`
}
type ResolveDisplayUserIDRequest struct {
RequestID string
Caller string
@ -186,6 +199,7 @@ type User struct {
ISONumeric string `json:"isoNumeric"`
PhoneCountryCode string `json:"phoneCountryCode"`
CountryEnabled bool `json:"countryEnabled"`
ContactInfo string `json:"contactInfo"`
}
type GRPCClient struct {
@ -284,6 +298,21 @@ func (c *GRPCClient) UpdateRegisterRiskConfig(ctx context.Context, req UpdateReg
return fromProtoRegisterRiskConfig(resp.GetConfig()), nil
}
func (c *GRPCClient) RefreshLoginRiskWhitelistCache(ctx context.Context, req RefreshLoginRiskWhitelistCacheRequest) (*LoginRiskWhitelistCacheSnapshot, error) {
resp, err := c.authClient.RefreshLoginRiskWhitelistCache(ctx, &userv1.RefreshLoginRiskWhitelistCacheRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
})
if err != nil {
return nil, err
}
return &LoginRiskWhitelistCacheSnapshot{
Refreshed: resp.GetRefreshed(),
IPWhitelistCount: resp.GetIpWhitelistCount(),
UserWhitelistCount: resp.GetUserWhitelistCount(),
UserCacheExpiresAtMs: resp.GetUserCacheExpiresAtMs(),
}, nil
}
// BatchGetUsers 复用 user-service 主数据 RPC让后台列表只做展示聚合不复制用户资料到游戏库。
func (c *GRPCClient) BatchGetUsers(ctx context.Context, req BatchGetUsersRequest) (map[int64]*User, error) {
resp, err := c.client.BatchGetUsers(ctx, &userv1.BatchGetUsersRequest{
@ -415,6 +444,7 @@ func fromProtoUser(user *userv1.User) *User {
ISONumeric: user.GetIsoNumeric(),
PhoneCountryCode: user.GetPhoneCountryCode(),
CountryEnabled: user.GetCountryEnabled(),
ContactInfo: user.GetContactInfo(),
}
}
@ -566,6 +596,7 @@ type BDProfile struct {
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Contact string `json:"contact"`
RegionName string `json:"regionName"`
ParentLeaderDisplayID string `json:"parentLeaderDisplayUserId"`
ParentLeaderUsername string `json:"parentLeaderUsername"`
@ -593,6 +624,7 @@ type Agency struct {
OwnerDisplayUserID string `json:"ownerDisplayUserId"`
OwnerUsername string `json:"ownerUsername"`
OwnerAvatar string `json:"ownerAvatar"`
Contact string `json:"contact"`
ParentBDDisplayUserID string `json:"parentBdDisplayUserId"`
ParentBDUsername string `json:"parentBdUsername"`
ParentBDAvatar string `json:"parentBdAvatar"`
@ -614,6 +646,7 @@ type HostProfile struct {
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Contact string `json:"contact"`
RegionName string `json:"regionName"`
CurrentAgencyName string `json:"currentAgencyName"`
Diamond int64 `json:"diamond"`

View File

@ -146,9 +146,9 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli())
whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ?)"
whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ? OR u.contact_info LIKE ?)"
args = append(args, matchArgs...)
args = append(args, like)
args = append(args, like, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -167,7 +167,7 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
SELECT bp.user_id, bp.role, `+userCountryRegionIDSQL("user_region")+`, COALESCE(bp.parent_leader_user_id, 0),
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(parent_leader.current_display_user_id, ''), COALESCE(parent_leader.username, ''),
COALESCE(parent_leader.avatar, ''),
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
@ -203,6 +203,7 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
&item.DisplayUserID,
&item.Username,
&item.Avatar,
&item.Contact,
&item.RegionName,
&item.ParentLeaderDisplayID,
&item.ParentLeaderUsername,
@ -265,9 +266,9 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "bp.user_id", keyword, time.Now().UnixMilli())
whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ?)"
whereSQL += " AND (" + matchSQL + " OR user_region.name LIKE ? OR u.contact_info LIKE ?)"
args = append(args, matchArgs...)
args = append(args, like)
args = append(args, like, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -285,7 +286,7 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`,
'', '', '',
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
COALESCE(creator.avatar, ''),
@ -320,6 +321,7 @@ func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*
&item.DisplayUserID,
&item.Username,
&item.Avatar,
&item.Contact,
&item.RegionName,
&item.ParentLeaderDisplayID,
&item.ParentLeaderUsername,
@ -362,7 +364,7 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP
SELECT bp.user_id, 'bd_leader', `+userCountryRegionIDSQL("user_region")+`, 0,
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`,
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
COALESCE(creator.avatar, ''),
(
@ -389,6 +391,7 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP
&item.DisplayUserID,
&item.Username,
&item.Avatar,
&item.Contact,
&item.RegionName,
&item.CreatedByDisplayUserID,
&item.CreatedByUsername,
@ -829,11 +832,11 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
nowMs := time.Now().UnixMilli()
ownerSQL, ownerArgs := shared.UserIdentityKeywordSQL("owner", "a.owner_user_id", keyword, nowMs)
parentSQL, parentArgs := shared.UserIdentityKeywordSQL("parent_bd", "a.parent_bd_user_id", keyword, nowMs)
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR " + ownerSQL + " OR " + parentSQL + " OR owner_region.name LIKE ?)"
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR " + ownerSQL + " OR " + parentSQL + " OR owner_region.name LIKE ? OR owner.contact_info LIKE ?)"
args = append(args, like, like)
args = append(args, ownerArgs...)
args = append(args, parentArgs...)
args = append(args, like)
args = append(args, like, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -853,7 +856,7 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
a.name, a.status, a.join_enabled, a.max_hosts,
a.created_by_user_id, a.created_at_ms, a.updated_at_ms,
COALESCE(owner.current_display_user_id, ''), COALESCE(owner.username, ''),
COALESCE(owner.avatar, ''),
COALESCE(owner.avatar, ''), COALESCE(owner.contact_info, ''),
COALESCE(parent_bd.current_display_user_id, ''), COALESCE(parent_bd.username, ''),
COALESCE(parent_bd.avatar, ''), `+userCountryRegionNameSQL("owner_region")+`,
(
@ -890,6 +893,7 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
&item.OwnerDisplayUserID,
&item.OwnerUsername,
&item.OwnerAvatar,
&item.Contact,
&item.ParentBDDisplayUserID,
&item.ParentBDUsername,
&item.ParentBDAvatar,
@ -948,11 +952,11 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
nowMs := time.Now().UnixMilli()
hostSQL, hostArgs := shared.UserIdentityKeywordSQL("u", "hp.user_id", keyword, nowMs)
agencyOwnerSQL, agencyOwnerArgs := shared.UserIdentityKeywordSQL("agency_owner", "a.owner_user_id", keyword, nowMs)
whereSQL += " AND (" + hostSQL + " OR a.name LIKE ? OR " + agencyOwnerSQL + " OR user_region.name LIKE ?)"
whereSQL += " AND (" + hostSQL + " OR a.name LIKE ? OR " + agencyOwnerSQL + " OR user_region.name LIKE ? OR u.contact_info LIKE ?)"
args = append(args, hostArgs...)
args = append(args, like)
args = append(args, agencyOwnerArgs...)
args = append(args, like)
args = append(args, like, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -971,7 +975,7 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
COALESCE(hp.current_membership_id, 0), hp.source,
hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), `+userCountryRegionNameSQL("user_region")+`, COALESCE(a.name, ''),
COALESCE(u.avatar, ''), COALESCE(u.contact_info, ''), `+userCountryRegionNameSQL("user_region")+`, COALESCE(a.name, ''),
COALESCE(a.owner_user_id, 0), COALESCE(agency_owner.current_display_user_id, ''),
COALESCE(agency_owner.username, ''), COALESCE(agency_owner.avatar, '')
%s
@ -1000,6 +1004,7 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
&item.DisplayUserID,
&item.Username,
&item.Avatar,
&item.Contact,
&item.RegionName,
&item.CurrentAgencyName,
&item.CurrentAgencyOwnerUserID,
@ -1066,9 +1071,9 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
} else if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
matchSQL, matchArgs := shared.UserIdentityKeywordSQL("u", "csp.user_id", keyword, time.Now().UnixMilli())
whereSQL += " AND (" + matchSQL + " OR csp.contact_info LIKE ?)"
whereSQL += " AND (" + matchSQL + " OR u.contact_info LIKE ? OR csp.contact_info LIKE ?)"
args = append(args, matchArgs...)
args = append(args, like)
args = append(args, like, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -1084,7 +1089,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT csp.user_id, csp.status, csp.merchant_asset_type,
COALESCE(csp.contact_info, ''),
COALESCE(NULLIF(u.contact_info, ''), csp.contact_info, ''),
csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(u.region_id, 0),
@ -1147,11 +1152,9 @@ func (r *Reader) UpdateCoinSellerContact(ctx context.Context, userID int64, cont
if r == nil || r.db == nil {
return errUserDBNotConfigured()
}
if err := r.ensureCoinSellerContactColumn(ctx); err != nil {
return err
}
// 币商联系方式现在是用户主资料,后台行内编辑和 H5 自助修改必须写同一个 users 字段,避免同一用户多身份展示不同联系方式。
_, err := r.db.ExecContext(ctx, `
UPDATE coin_seller_profiles
UPDATE users
SET contact_info = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, strings.TrimSpace(contact), time.Now().UnixMilli(), appctx.FromContext(ctx), userID)

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
@ -18,12 +19,13 @@ type Handler struct {
}
type replaceRequest struct {
Keywords *[]string `json:"keywords"`
WhitelistIPs *[]string `json:"whitelist_ips"`
Keywords *[]string `json:"keywords"`
WhitelistIPs *[]string `json:"whitelist_ips"`
WhitelistUserIDs *[]string `json:"whitelist_user_ids"`
}
func New(userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userDB), audit: audit}
func New(userDB *sql.DB, user userclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userDB, user), audit: audit}
}
// ListRegionBlocks 返回用户管理 > 地区屏蔽页面需要的当前屏蔽词列表。
@ -43,15 +45,19 @@ func (h *Handler) ReplaceRegionBlocks(c *gin.Context) {
response.BadRequest(c, "地区屏蔽词参数不正确")
return
}
items, err := h.service.Replace(c.Request.Context(), int64(shared.ActorFromContext(c).UserID), *req.Keywords, req.WhitelistIPs)
items, err := h.service.Replace(c.Request.Context(), middleware.CurrentRequestID(c), int64(shared.ActorFromContext(c).UserID), *req.Keywords, req.WhitelistIPs, req.WhitelistUserIDs)
if err != nil {
if errors.Is(err, errInvalidPayload) {
response.BadRequest(c, "地区屏蔽词参数不正确")
return
}
if errors.Is(err, errWhitelistCacheRefresh) {
response.ServerError(c, "保存成功但刷新登录风控缓存失败")
return
}
response.ServerError(c, "保存地区屏蔽配置失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "replace-region-blocks", "login_risk_country_blocks", middleware.CurrentRequestID(c), "success", fmt.Sprintf("keywords=%d whitelist_ips=%d", len(items.Items), len(items.WhitelistItems)))
shared.OperationLogWithResourceID(c, h.audit, "replace-region-blocks", "login_risk_country_blocks", middleware.CurrentRequestID(c), "success", fmt.Sprintf("keywords=%d whitelist_ips=%d whitelist_users=%d", len(items.Items), len(items.WhitelistItems), len(items.WhitelistUserItems)))
response.OK(c, items)
}

View File

@ -6,26 +6,35 @@ import (
"errors"
"net/netip"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
maxRegionBlockKeywords = 200
maxRegionBlockKeywordLength = 128
maxIPWhitelistItems = 500
maxRegionBlockKeywords = 200
maxRegionBlockKeywordLength = 128
maxIPWhitelistItems = 500
maxUserWhitelistItems = 500
maxUserWhitelistLookupLength = 64
)
var (
countryCodePattern = regexp.MustCompile(`^[A-Za-z]{2,3}$`)
errInvalidPayload = errors.New("region block payload invalid")
countryCodePattern = regexp.MustCompile(`^[A-Za-z]{2,3}$`)
errInvalidPayload = errors.New("region block payload invalid")
errWhitelistCacheRefresh = errors.New("login risk whitelist cache refresh failed")
)
type Service struct {
userDB *sql.DB
user userclient.Client
}
type RegionBlock struct {
@ -45,11 +54,22 @@ type IPWhitelistItem struct {
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type UserWhitelistItem struct {
WhitelistID int64 `json:"whitelist_id"`
UserID int64 `json:"user_id,string"`
DisplayUserID string `json:"display_user_id"`
Enabled bool `json:"enabled"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type Config struct {
Items []RegionBlock `json:"items"`
Total int `json:"total"`
WhitelistItems []IPWhitelistItem `json:"whitelist_items"`
WhitelistTotal int `json:"whitelist_total"`
Items []RegionBlock `json:"items"`
Total int `json:"total"`
WhitelistItems []IPWhitelistItem `json:"whitelist_items"`
WhitelistTotal int `json:"whitelist_total"`
WhitelistUserItems []UserWhitelistItem `json:"whitelist_user_items"`
WhitelistUserTotal int `json:"whitelist_user_total"`
}
type normalizedKeyword struct {
@ -61,11 +81,11 @@ type countryCodeQuerier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
func NewService(userDB *sql.DB) *Service {
return &Service{userDB: userDB}
func NewService(userDB *sql.DB, user userclient.Client) *Service {
return &Service{userDB: userDB, user: user}
}
// List 返回当前 App 已启用的登录地区屏蔽词和 IP 白名单。
// List 返回当前 App 已启用的登录地区屏蔽词、IP 白名单和用户白名单。
// 表属于 user-service 登录风控事实admin-server 只作为后台维护入口直接写 user DB。
func (s *Service) List(ctx context.Context) (Config, error) {
if s.userDB == nil {
@ -80,12 +100,16 @@ func (s *Service) List(ctx context.Context) (Config, error) {
if err != nil {
return Config{}, err
}
return Config{Items: blocks, Total: len(blocks), WhitelistItems: whitelist, WhitelistTotal: len(whitelist)}, nil
userWhitelist, err := queryEnabledUserWhitelist(ctx, s.userDB, appCode)
if err != nil {
return Config{}, err
}
return Config{Items: blocks, Total: len(blocks), WhitelistItems: whitelist, WhitelistTotal: len(whitelist), WhitelistUserItems: userWhitelist, WhitelistUserTotal: len(userWhitelist)}, nil
}
// Replace 用页面提交的词表整体替换当前 App 的生效配置。
// 整体替换比逐条增删更适合配置页保存语义,也能避免客户端和服务端出现半旧半新的屏蔽词集合。
func (s *Service) Replace(ctx context.Context, actorID int64, keywords []string, whitelistIPs *[]string) (Config, error) {
func (s *Service) Replace(ctx context.Context, requestID string, actorID int64, keywords []string, whitelistIPs *[]string, whitelistUserIDs *[]string) (Config, error) {
if s.userDB == nil {
return Config{}, errors.New("user db is not configured")
}
@ -100,6 +124,13 @@ func (s *Service) Replace(ctx context.Context, actorID int64, keywords []string,
return Config{}, err
}
}
var normalizedUserIDs []int64
if whitelistUserIDs != nil {
normalizedUserIDs, err = s.normalizeUserIDWhitelist(ctx, requestID, *whitelistUserIDs)
if err != nil {
return Config{}, err
}
}
tx, err := s.userDB.BeginTx(ctx, nil)
if err != nil {
return Config{}, err
@ -155,9 +186,38 @@ func (s *Service) Replace(ctx context.Context, actorID int64, keywords []string,
}
}
}
if whitelistUserIDs != nil {
if _, err := tx.ExecContext(ctx, `
UPDATE login_risk_user_whitelist
SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ?
WHERE app_code = ? AND enabled = 1
`, nullableActor(actorID), nowMS, appCode); err != nil {
return Config{}, err
}
for _, userID := range normalizedUserIDs {
if _, err := tx.ExecContext(ctx, `
INSERT INTO login_risk_user_whitelist (
app_code, user_id, enabled,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
VALUES (?, ?, 1, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = 1,
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, userID, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil {
return Config{}, err
}
}
}
if err := tx.Commit(); err != nil {
return Config{}, err
}
if whitelistIPs != nil || whitelistUserIDs != nil {
if err := s.refreshWhitelistCache(ctx, requestID); err != nil {
return Config{}, err
}
}
return s.List(ctx)
}
@ -210,6 +270,116 @@ func normalizeIPWhitelist(raw []string) ([]string, error) {
return result, nil
}
func (s *Service) normalizeUserIDWhitelist(ctx context.Context, requestID string, raw []string) ([]int64, error) {
if len(raw) > maxUserWhitelistItems {
return nil, errInvalidPayload
}
result := make([]int64, 0, len(raw))
seen := map[int64]struct{}{}
for _, item := range raw {
value := strings.TrimSpace(item)
if value == "" {
return nil, errInvalidPayload
}
userID, err := s.resolveWhitelistUserID(ctx, requestID, value)
if err != nil {
return nil, err
}
if userID <= 0 {
return nil, errInvalidPayload
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
result = append(result, userID)
}
return result, nil
}
func (s *Service) resolveWhitelistUserID(ctx context.Context, requestID string, raw string) (int64, error) {
value := strings.TrimSpace(raw)
if value == "" || utf8.RuneCountInString(value) > maxUserWhitelistLookupLength {
return 0, errInvalidPayload
}
if s.user == nil {
// 本地隔离测试没有 user-service client 时只接受真实 user_id避免把短号误写成未知用户。
numericID, numericOK := parsePositiveInt64(value)
if !numericOK {
return 0, errInvalidPayload
}
return numericID, nil
}
// 后台输入优先按当前 active 展示号解析,默认短号和靓号都由 user-service 统一处理;
// 保存时只落真实 user_id后续用户改短号或靓号过期也不会影响登录风控白名单。
if identity, err := s.user.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{
RequestID: requestID,
Caller: "admin-server",
DisplayUserID: value,
}); err != nil {
if !isGRPCNotFound(err) && !isGRPCInvalidArgument(err) {
return 0, err
}
} else if identity != nil && identity.UserID > 0 {
return identity.UserID, nil
}
// 展示号没有命中时,纯数字输入再兜底按内部 user_id 校验;非数字靓号未命中就直接视为无效配置。
numericID, numericOK := parsePositiveInt64(value)
if !numericOK {
return 0, errInvalidPayload
}
if found, err := s.userExists(ctx, requestID, numericID); err != nil {
return 0, err
} else if found {
return numericID, nil
}
return 0, errInvalidPayload
}
func (s *Service) userExists(ctx context.Context, requestID string, userID int64) (bool, error) {
user, err := s.user.GetUser(ctx, userclient.GetUserRequest{
RequestID: requestID,
Caller: "admin-server",
UserID: userID,
})
if err != nil {
if isGRPCNotFound(err) {
return false, nil
}
return false, err
}
return user != nil && user.UserID > 0, nil
}
func parsePositiveInt64(text string) (int64, bool) {
parsed, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64)
return parsed, err == nil && parsed > 0
}
func isGRPCNotFound(err error) bool {
return status.Code(err) == codes.NotFound
}
func isGRPCInvalidArgument(err error) bool {
return status.Code(err) == codes.InvalidArgument
}
func (s *Service) refreshWhitelistCache(ctx context.Context, requestID string) error {
if s.user == nil {
return nil
}
_, err := s.user.RefreshLoginRiskWhitelistCache(ctx, userclient.RefreshLoginRiskWhitelistCacheRequest{
RequestID: requestID,
Caller: "admin-server",
})
if err != nil {
return errWhitelistCacheRefresh
}
return nil
}
func resolveCountryCode(ctx context.Context, querier countryCodeQuerier, appCode string, keyword string) (string, error) {
if countryCodePattern.MatchString(keyword) {
return strings.ToUpper(keyword), nil
@ -290,6 +460,37 @@ func queryEnabledIPWhitelist(ctx context.Context, db *sql.DB, appCode string) ([
return items, nil
}
func queryEnabledUserWhitelist(ctx context.Context, db *sql.DB, appCode string) ([]UserWhitelistItem, error) {
rows, err := db.QueryContext(ctx, `
SELECT w.whitelist_id, w.user_id, COALESCE(u.current_display_user_id, ''), w.enabled, w.created_at_ms, w.updated_at_ms
FROM login_risk_user_whitelist w
LEFT JOIN users u
ON u.app_code = w.app_code
AND u.user_id = w.user_id
WHERE w.app_code = ? AND w.enabled = 1
ORDER BY w.user_id ASC, w.whitelist_id ASC
`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]UserWhitelistItem, 0)
for rows.Next() {
var item UserWhitelistItem
if err := rows.Scan(&item.WhitelistID, &item.UserID, &item.DisplayUserID, &item.Enabled, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return nil, err
}
if item.UserID > 0 {
items = append(items, item)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func nullableActor(actorID int64) any {
if actorID <= 0 {
return nil

View File

@ -0,0 +1,110 @@
package regionblock
import (
"context"
"errors"
"reflect"
"testing"
"hyapp-admin-server/internal/integration/userclient"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type fakeWhitelistCacheUserClient struct {
userclient.Client
called bool
err error
resolveErr error
users map[int64]*userclient.User
identities map[string]*userclient.UserIdentity
}
func (f *fakeWhitelistCacheUserClient) RefreshLoginRiskWhitelistCache(_ context.Context, req userclient.RefreshLoginRiskWhitelistCacheRequest) (*userclient.LoginRiskWhitelistCacheSnapshot, error) {
f.called = true
if req.RequestID != "req-refresh" || req.Caller != "admin-server" {
return nil, errors.New("unexpected refresh request")
}
if f.err != nil {
return nil, f.err
}
return &userclient.LoginRiskWhitelistCacheSnapshot{Refreshed: true, UserWhitelistCount: 2}, nil
}
func (f *fakeWhitelistCacheUserClient) ResolveDisplayUserID(_ context.Context, req userclient.ResolveDisplayUserIDRequest) (*userclient.UserIdentity, error) {
if req.RequestID != "req-refresh" || req.Caller != "admin-server" {
return nil, errors.New("unexpected resolve request")
}
if f.resolveErr != nil {
return nil, f.resolveErr
}
if identity := f.identities[req.DisplayUserID]; identity != nil {
return identity, nil
}
return nil, status.Error(codes.NotFound, "display user id not found")
}
func (f *fakeWhitelistCacheUserClient) GetUser(_ context.Context, req userclient.GetUserRequest) (*userclient.User, error) {
if req.RequestID != "req-refresh" || req.Caller != "admin-server" {
return nil, errors.New("unexpected get user request")
}
if user := f.users[req.UserID]; user != nil {
return user, nil
}
return nil, status.Error(codes.NotFound, "user not found")
}
func TestNormalizeUserIDWhitelistResolvesDisplayIDsAndInternalIDs(t *testing.T) {
service := NewService(nil, &fakeWhitelistCacheUserClient{
users: map[int64]*userclient.User{
990000: {UserID: 990000},
},
identities: map[string]*userclient.UserIdentity{
"VIP2026": {UserID: 900025, DisplayUserID: "VIP2026"},
"123456789012": {UserID: 900026, DisplayUserID: "123456789012"},
},
})
got, err := service.normalizeUserIDWhitelist(context.Background(), "req-refresh", []string{" VIP2026 ", "123456789012", "990000", "VIP2026"})
if err != nil {
t.Fatalf("normalize user whitelist failed: %v", err)
}
want := []int64{900025, 900026, 990000}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalized user whitelist = %+v, want %+v", got, want)
}
}
func TestNormalizeUserIDWhitelistRejectsInvalidID(t *testing.T) {
service := NewService(nil, &fakeWhitelistCacheUserClient{})
for _, raw := range [][]string{{""}, {"0"}, {"-1"}, {"VIP-2026"}, {"123456"}} {
if _, err := service.normalizeUserIDWhitelist(context.Background(), "req-refresh", raw); !errors.Is(err, errInvalidPayload) {
t.Fatalf("normalize %v error = %v, want errInvalidPayload", raw, err)
}
}
}
func TestNormalizeUserIDWhitelistKeepsUserServiceError(t *testing.T) {
upstreamErr := status.Error(codes.Unavailable, "user-service unavailable")
service := NewService(nil, &fakeWhitelistCacheUserClient{resolveErr: upstreamErr})
_, err := service.normalizeUserIDWhitelist(context.Background(), "req-refresh", []string{"VIP2026"})
if !errors.Is(err, upstreamErr) {
t.Fatalf("normalize error = %v, want upstream error", err)
}
if errors.Is(err, errInvalidPayload) {
t.Fatalf("normalize error = %v, should not be mapped to invalid payload", err)
}
}
func TestRefreshWhitelistCacheMapsUserServiceError(t *testing.T) {
client := &fakeWhitelistCacheUserClient{err: errors.New("rpc down")}
service := NewService(nil, client)
err := service.refreshWhitelistCache(context.Background(), "req-refresh")
if !client.called {
t.Fatal("expected refresh rpc to be called")
}
if !errors.Is(err, errWhitelistCacheRefresh) {
t.Fatalf("refresh error = %v, want errWhitelistCacheRefresh", err)
}
}

View File

@ -86,6 +86,7 @@ func New(cfg config.Config) (*App, error) {
var userClient client.UserAuthClient = client.NewGRPCUserAuthClient(userConn)
var userIdentityClient client.UserIdentityClient = client.NewGRPCUserIdentityClient(userConn)
var userProfileClient client.UserProfileClient = client.NewGRPCUserProfileClient(userConn)
var userInviteClient client.UserInviteClient = client.NewGRPCUserInviteClient(userConn)
var userDeviceClient client.UserDeviceClient = client.NewGRPCUserDeviceClient(userConn)
var userCountryQueryClient client.UserCountryQueryClient = client.NewGRPCUserCountryQueryClient(userConn)
var userRegionClient client.UserRegionClient = client.NewGRPCUserRegionClient(userConn)
@ -149,6 +150,7 @@ func New(cfg config.Config) (*App, error) {
}
handler.SetRoomQueryClient(roomQueryClient)
handler.SetUserDeviceClient(userDeviceClient)
handler.SetUserInviteClient(userInviteClient)
handler.SetUserCountryQueryClient(userCountryQueryClient)
handler.SetUserRegionClient(userRegionClient)
handler.SetUserHostClient(userHostClient)

View File

@ -42,6 +42,7 @@ type UserProfileClient interface {
CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error)
UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error)
UpdateUserProfileBackground(ctx context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(ctx context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error)
ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error)
AdminChangeUserCountry(ctx context.Context, req *userv1.AdminChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error)
CreateManagerUserBlock(ctx context.Context, req *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error)
@ -49,6 +50,12 @@ type UserProfileClient interface {
UnblockManagerUser(ctx context.Context, req *userv1.UnblockManagerUserRequest) (*userv1.UnblockManagerUserResponse, error)
}
// UserInviteClient 抽象 H5 邀请页对 user-service 邀请人搜索和绑定能力的依赖。
type UserInviteClient interface {
SearchInviteReferrer(ctx context.Context, req *userv1.SearchInviteReferrerRequest) (*userv1.SearchInviteReferrerResponse, error)
BindInviteReferrer(ctx context.Context, req *userv1.BindInviteReferrerRequest) (*userv1.BindInviteReferrerResponse, error)
}
// UserSocialClient 抽象 gateway 对 user-service 访问、关注和好友关系能力的依赖。
type UserSocialClient interface {
RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error)
@ -141,6 +148,10 @@ type grpcUserProfileClient struct {
client userv1.UserServiceClient
}
type grpcUserInviteClient struct {
client userv1.UserServiceClient
}
type grpcUserSocialClient struct {
client userv1.UserSocialServiceClient
}
@ -194,6 +205,13 @@ func NewGRPCUserProfileClient(conn *grpc.ClientConn) UserProfileClient {
}
}
// NewGRPCUserInviteClient 用 gRPC 连接构造 H5 邀请页专用 client。
func NewGRPCUserInviteClient(conn *grpc.ClientConn) UserInviteClient {
return &grpcUserInviteClient{
client: userv1.NewUserServiceClient(conn),
}
}
// NewGRPCUserSocialClient 用 gRPC 连接构造 user-service social client。
func NewGRPCUserSocialClient(conn *grpc.ClientConn) UserSocialClient {
return &grpcUserSocialClient{
@ -346,6 +364,10 @@ func (c *grpcUserProfileClient) UpdateUserProfileBackground(ctx context.Context,
return c.client.UpdateUserProfileBackground(ctx, req)
}
func (c *grpcUserProfileClient) UpdateUserContactInfo(ctx context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) {
return c.client.UpdateUserContactInfo(ctx, req)
}
func (c *grpcUserProfileClient) ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
return c.client.ChangeUserCountry(ctx, req)
}
@ -366,6 +388,14 @@ func (c *grpcUserProfileClient) UnblockManagerUser(ctx context.Context, req *use
return c.client.UnblockManagerUser(ctx, req)
}
func (c *grpcUserInviteClient) SearchInviteReferrer(ctx context.Context, req *userv1.SearchInviteReferrerRequest) (*userv1.SearchInviteReferrerResponse, error) {
return c.client.SearchInviteReferrer(ctx, req)
}
func (c *grpcUserInviteClient) BindInviteReferrer(ctx context.Context, req *userv1.BindInviteReferrerRequest) (*userv1.BindInviteReferrerResponse, error) {
return c.client.BindInviteReferrer(ctx, req)
}
func (c *grpcUserSocialClient) RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
return c.client.RecordProfileVisit(ctx, req)
}

View File

@ -137,6 +137,10 @@ func (f *fakeInviteActivityUserProfileClient) UpdateUserProfileBackground(contex
return &userv1.UpdateUserProfileBackgroundResponse{}, nil
}
func (f *fakeInviteActivityUserProfileClient) UpdateUserContactInfo(context.Context, *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) {
return &userv1.UpdateUserContactInfoResponse{}, nil
}
func (f *fakeInviteActivityUserProfileClient) ChangeUserCountry(context.Context, *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
return &userv1.ChangeUserCountryResponse{}, nil
}

View File

@ -28,6 +28,7 @@ type Handler struct {
userClient client.UserAuthClient
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
userInviteClient client.UserInviteClient
userSocialClient client.UserSocialClient
userCPClient client.UserCPClient
userDeviceClient client.UserDeviceClient
@ -190,6 +191,11 @@ func (h *Handler) SetUserDeviceClient(userDeviceClient client.UserDeviceClient)
h.userDeviceClient = userDeviceClient
}
// SetUserInviteClient 注入 H5 邀请页搜索和绑定邀请人 client。
func (h *Handler) SetUserInviteClient(userInviteClient client.UserInviteClient) {
h.userInviteClient = userInviteClient
}
// SetUserHostClient 注入 user-service host domain 身份查询 client。
func (h *Handler) SetUserHostClient(userHostClient client.UserHostClient) {
h.userHostClient = userHostClient

View File

@ -73,6 +73,8 @@ type UserHandlers struct {
BatchRoomDisplayProfiles http.HandlerFunc
GetMyOverview http.HandlerFunc
GetMyInviteOverview http.HandlerFunc
SearchInviteReferrer http.HandlerFunc
BindInviteReferrer http.HandlerFunc
GetMyGiftWall http.HandlerFunc
GetMyIdentity http.HandlerFunc
GetMyHostIdentity http.HandlerFunc
@ -101,6 +103,7 @@ type UserHandlers struct {
CompleteMyOnboarding http.HandlerFunc
UpdateMyProfile http.HandlerFunc
UpdateMyProfileBackground http.HandlerFunc
UpdateMyContactInfo http.HandlerFunc
ChangeMyCountry http.HandlerFunc
ChangeMyDisplayUserID http.HandlerFunc
ApplyMyPrettyDisplayUserID http.HandlerFunc
@ -419,6 +422,8 @@ func (r routes) registerUserRoutes() {
r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles)
r.profile("/users/me/overview", "", h.GetMyOverview)
r.profile("/users/me/invite-overview", http.MethodGet, h.GetMyInviteOverview)
r.profile("/users/me/invite-referrer/search", http.MethodGet, h.SearchInviteReferrer)
r.profile("/users/me/invite-referrer/bind", http.MethodPost, h.BindInviteReferrer)
r.profile("/users/me/gift-wall", http.MethodGet, h.GetMyGiftWall)
r.auth("/users/me/identity", "", h.GetMyIdentity)
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
@ -447,6 +452,7 @@ func (r routes) registerUserRoutes() {
r.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
r.profile("/users/me/profile-bg-img", http.MethodPost, h.UpdateMyProfileBackground)
r.profile("/users/me/contact-info", http.MethodPost, h.UpdateMyContactInfo)
r.profile("/users/me/country/change", "", h.ChangeMyCountry)
r.auth("/users/me/display-id/change", "", h.ChangeMyDisplayUserID)
r.auth("/users/me/display-id/pretty/apply", "", h.ApplyMyPrettyDisplayUserID)

View File

@ -160,6 +160,29 @@ func TestGetMyOverviewProjectsOrdinaryUserEntriesAndZeroWallet(t *testing.T) {
}
}
func TestGetMyOverviewHidesAgencyCenterForOrdinaryHost(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetWalletClient(&fakeWalletClient{})
handler.SetUserHostClient(&fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{
UserId: 42,
IsHost: true,
IsAgency: false,
HostStatus: "active",
}})
handler.SetMessageInboxClient(&fakeMessageInboxClient{})
data := performOverviewRequest(t, handler, "req-overview-host-only")
roles := data["roles"].(map[string]any)
if roles["is_host"] != true || roles["is_agency"] != false {
t.Fatalf("ordinary host roles mismatch: %+v", roles)
}
entries := data["entries"].([]any)
if !overviewEntryVisible(entries, "host_center") || overviewEntryVisible(entries, "agency_center") || overviewEntryVisible(entries, "apply_host") {
t.Fatalf("ordinary host must only expose host center: %+v", entries)
}
}
func TestGetMyOverviewDegradesOptionalUpstreamFailures(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{statsErr: errors.New("stats down")})
handler.SetWalletClient(&fakeWalletClient{err: errors.New("wallet down")})

View File

@ -321,6 +321,7 @@ type fakeUserProfileClient struct {
lastComplete *userv1.CompleteOnboardingRequest
lastUpdate *userv1.UpdateUserProfileRequest
lastBackground *userv1.UpdateUserProfileBackgroundRequest
lastContact *userv1.UpdateUserContactInfoRequest
lastCountry *userv1.ChangeUserCountryRequest
lastAdminCountry *userv1.AdminChangeUserCountryRequest
lastCreateBlock *userv1.CreateManagerUserBlockRequest
@ -942,6 +943,15 @@ func (f *fakeUserProfileClient) UpdateUserProfileBackground(_ context.Context, r
}}, nil
}
func (f *fakeUserProfileClient) UpdateUserContactInfo(_ context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) {
f.lastContact = req
return &userv1.UpdateUserContactInfoResponse{User: &userv1.User{
UserId: req.GetUserId(),
ContactInfo: req.GetContactInfo(),
UpdatedAtMs: 2200,
}}, nil
}
func (f *fakeUserProfileClient) ChangeUserCountry(_ context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
f.lastCountry = req
if f.countryErr != nil {
@ -4675,8 +4685,18 @@ func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
RegionName: "East Asia",
Status: "active",
MerchantAssetType: "COIN_SELLER_COIN",
ContactInfo: "+65 1234 5678",
UpdatedAtMs: 1700000001000,
},
{
UserId: 802,
DisplayUserId: "900802",
Username: "Seller Without Contact",
RegionId: 30,
Status: "active",
MerchantAssetType: "COIN_SELLER_COIN",
UpdatedAtMs: 1700000002000,
},
}}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserHostClient(hostClient)
@ -4701,9 +4721,13 @@ func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
data := response.Data.(map[string]any)
items := data["items"].([]any)
first := items[0].(map[string]any)
if data["total"] != float64(1) || first["user_id"] != "801" || first["display_user_id"] != "900801" || first["username"] != "Seller One" || first["country_id"] != float64(86) || first["status"] != "active" {
if data["total"] != float64(2) || first["user_id"] != "801" || first["display_user_id"] != "900801" || first["username"] != "Seller One" || first["country_id"] != float64(86) || first["status"] != "active" || first["whatsapp_phone"] != "+65 1234 5678" || first["whatsapp_url"] != "https://wa.me/6512345678" {
t.Fatalf("coin seller list response mismatch: %+v", response)
}
second := items[1].(map[string]any)
if second["whatsapp_phone"] != "" || second["whatsapp_url"] != "" {
t.Fatalf("empty contact whatsapp fields must be stable empty strings: %+v", second)
}
}
func TestSalaryWalletSearchAllowsSelfCoinSeller(t *testing.T) {
@ -6141,6 +6165,36 @@ func TestUpdateProfileBackgroundUsesAuthenticatedUserIDAndURL(t *testing.T) {
}
}
func TestUpdateContactInfoUsesAuthenticatedUserID(t *testing.T) {
profileClient := &fakeUserProfileClient{}
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"whatsapp_phone":"+65 1234 5678","user_id":99}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/contact-info", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-contact")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if profileClient.lastContact == nil || profileClient.lastContact.GetUserId() != 42 {
t.Fatalf("authenticated user_id was not propagated to contact update: %+v", profileClient.lastContact)
}
if profileClient.lastContact.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-contact") || profileClient.lastContact.GetContactInfo() != "+65 1234 5678" {
t.Fatalf("contact update request mismatch: %+v", profileClient.lastContact)
}
var response httpkit.ResponseEnvelope
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || data["contact_info"] != "+65 1234 5678" || data["whatsapp_phone"] != "+65 1234 5678" {
t.Fatalf("contact update response mismatch: %+v", response)
}
}
func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) {
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {
UserId: 42,
@ -8917,6 +8971,7 @@ func TestProfileMutationEndpointsRequireCompletedProfileToken(t *testing.T) {
body string
}{
{name: "profile_update", path: "/api/v1/users/me/profile/update", body: `{"username":"hy"}`},
{name: "contact_info", path: "/api/v1/users/me/contact-info", body: `{"contact_info":"+65"}`},
{name: "country_change", path: "/api/v1/users/me/country/change", body: `{"country":"US"}`},
}

View File

@ -59,6 +59,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
userAPI := userapi.New(userapi.Config{
UserIdentityClient: h.userIdentityClient,
UserProfileClient: h.userProfileClient,
UserInviteClient: h.userInviteClient,
UserSocialClient: h.userSocialClient,
UserCPClient: h.userCPClient,
UserCountryClient: h.userCountryClient,

View File

@ -12,6 +12,7 @@ import (
type Handler struct {
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
userInviteClient client.UserInviteClient
userSocialClient client.UserSocialClient
userCPClient client.UserCPClient
userCountryClient client.UserCountryQueryClient
@ -25,6 +26,7 @@ type Handler struct {
type Config struct {
UserIdentityClient client.UserIdentityClient
UserProfileClient client.UserProfileClient
UserInviteClient client.UserInviteClient
UserSocialClient client.UserSocialClient
UserCPClient client.UserCPClient
UserCountryClient client.UserCountryQueryClient
@ -39,6 +41,7 @@ func New(config Config) *Handler {
return &Handler{
userIdentityClient: config.UserIdentityClient,
userProfileClient: config.UserProfileClient,
userInviteClient: config.UserInviteClient,
userSocialClient: config.UserSocialClient,
userCPClient: config.UserCPClient,
userCountryClient: config.UserCountryClient,
@ -55,6 +58,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
ResolveDisplayUserID: h.resolveDisplayUserID,
GetMyOverview: h.getMyOverview,
GetMyInviteOverview: h.getMyInviteOverview,
SearchInviteReferrer: h.searchInviteReferrer,
BindInviteReferrer: h.bindInviteReferrer,
GetMyIdentity: h.getMyIdentity,
GetMyHostIdentity: h.getMyHostIdentity,
GetMyRoleSummary: h.getMyRoleSummary,
@ -82,6 +87,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile,
UpdateMyProfileBackground: h.updateMyProfileBackground,
UpdateMyContactInfo: h.updateMyContactInfo,
ChangeMyCountry: h.changeMyCountry,
ChangeMyDisplayUserID: h.changeMyDisplayUserID,
ApplyMyPrettyDisplayUserID: h.applyMyPrettyDisplayUserID,

View File

@ -0,0 +1,77 @@
package userapi
import (
"net/http"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type bindInviteReferrerData struct {
Invite *inviteBindingData `json:"invite,omitempty"`
Inviter userProfileData `json:"inviter"`
}
// searchInviteReferrer 只按邀请码暴露当前用户同国家、可绑定的邀请人资料。
func (h *Handler) searchInviteReferrer(writer http.ResponseWriter, request *http.Request) {
if h.userInviteClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
inviteCode := strings.TrimSpace(request.URL.Query().Get("invite_code"))
if inviteCode == "" {
inviteCode = strings.TrimSpace(request.URL.Query().Get("keyword"))
}
if inviteCode == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userInviteClient.SearchInviteReferrer(request.Context(), &userv1.SearchInviteReferrerRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
InviteCode: inviteCode,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"inviter": profileData(resp.GetInviter(), 0)})
}
// bindInviteReferrer 在资料完成后按邀请码绑定邀请归因;跨国家、自邀和重复改绑由 user-service 事务内拒绝。
func (h *Handler) bindInviteReferrer(writer http.ResponseWriter, request *http.Request) {
if h.userInviteClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body struct {
InviteCode string `json:"invite_code"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
body.InviteCode = strings.TrimSpace(body.InviteCode)
if body.InviteCode == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userInviteClient.BindInviteReferrer(request.Context(), &userv1.BindInviteReferrerRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
InviteCode: body.InviteCode,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, bindInviteReferrerData{
Invite: inviteBindingDataFromProto(resp.GetInvite()),
Inviter: profileData(resp.GetInviter(), 0),
})
}

View File

@ -0,0 +1,109 @@
package userapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
)
type fakeInviteReferrerClient struct {
lastSearch *userv1.SearchInviteReferrerRequest
lastBind *userv1.BindInviteReferrerRequest
}
func (f *fakeInviteReferrerClient) SearchInviteReferrer(_ context.Context, req *userv1.SearchInviteReferrerRequest) (*userv1.SearchInviteReferrerResponse, error) {
f.lastSearch = req
return &userv1.SearchInviteReferrerResponse{
Inviter: &userv1.User{
UserId: 9001,
DisplayUserId: "163900",
Username: "Alice",
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
},
}, nil
}
func (f *fakeInviteReferrerClient) BindInviteReferrer(_ context.Context, req *userv1.BindInviteReferrerRequest) (*userv1.BindInviteReferrerResponse, error) {
f.lastBind = req
return &userv1.BindInviteReferrerResponse{
Invite: &userv1.InviteBinding{
Bound: true,
InviteCode: req.GetInviteCode(),
InviterUserId: 9001,
},
Inviter: &userv1.User{
UserId: 9001,
DisplayUserId: "163900",
Username: "Alice",
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
},
}, nil
}
func TestInviteReferrerSearchUsesInviteCode(t *testing.T) {
client := &fakeInviteReferrerClient{}
handler := New(Config{UserInviteClient: client})
request := newInviteReferrerRequest(http.MethodGet, "/api/v1/users/me/invite-referrer/search?invite_code=A1B2C3", "")
recorder := httptest.NewRecorder()
handler.searchInviteReferrer(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("search status = %d body=%s", recorder.Code, recorder.Body.String())
}
if client.lastSearch == nil || client.lastSearch.GetUserId() != 10001 || client.lastSearch.GetInviteCode() != "A1B2C3" {
t.Fatalf("search request mismatch: %+v", client.lastSearch)
}
}
func TestInviteReferrerBindUsesInviteCode(t *testing.T) {
client := &fakeInviteReferrerClient{}
handler := New(Config{UserInviteClient: client})
request := newInviteReferrerRequest(http.MethodPost, "/api/v1/users/me/invite-referrer/bind", `{"invite_code":"A1B2C3"}`)
recorder := httptest.NewRecorder()
handler.bindInviteReferrer(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("bind status = %d body=%s", recorder.Code, recorder.Body.String())
}
if client.lastBind == nil || client.lastBind.GetUserId() != 10001 || client.lastBind.GetInviteCode() != "A1B2C3" {
t.Fatalf("bind request mismatch: %+v", client.lastBind)
}
}
func TestInviteReferrerBindRejectsInviterUserID(t *testing.T) {
client := &fakeInviteReferrerClient{}
handler := New(Config{UserInviteClient: client})
request := newInviteReferrerRequest(http.MethodPost, "/api/v1/users/me/invite-referrer/bind", `{"inviter_user_id":"9001"}`)
recorder := httptest.NewRecorder()
handler.bindInviteReferrer(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("bind status = %d body=%s", recorder.Code, recorder.Body.String())
}
if client.lastBind != nil {
t.Fatalf("inviter_user_id body must not be forwarded: %+v", client.lastBind)
}
}
func newInviteReferrerRequest(method string, target string, body string) *http.Request {
var reader *strings.Reader
if body == "" {
reader = strings.NewReader("")
} else {
reader = strings.NewReader(body)
}
request := httptest.NewRequest(method, target, reader)
request.Header.Set("Content-Type", "application/json")
ctx := appcode.WithContext(request.Context(), "lalu")
ctx = auth.WithUserID(ctx, 10001)
return request.WithContext(ctx)
}

View File

@ -63,6 +63,7 @@ type userProfileData struct {
OnboardingStatus string `json:"onboarding_status"`
UpdatedAtMs int64 `json:"updated_at_ms"`
ProfileBgImg string `json:"profile_bg_img"`
ContactInfo string `json:"contact_info,omitempty"`
NextCountryChangeAtMs int64 `json:"next_country_change_allowed_at_ms,omitempty"`
}
@ -409,6 +410,58 @@ func (h *Handler) updateMyProfileBackground(writer http.ResponseWriter, request
})
}
type contactInfoUpdateBody struct {
ContactInfo *string `json:"contact_info"`
ContactInfoCamel *string `json:"contactInfo"`
WhatsappPhone *string `json:"whatsapp_phone"`
WhatsappPhoneCamel *string `json:"whatsappPhone"`
Contact *string `json:"contact"`
}
// updateMyContactInfo 修改当前登录用户的用户级联系方式;多个经营身份页面共用这一个字段。
func (h *Handler) updateMyContactInfo(writer http.ResponseWriter, request *http.Request) {
var body contactInfoUpdateBody
if !httpkit.Decode(writer, request, &body) {
return
}
if h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
contactInfo, ok := contactInfoFromBody(body)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "contact_info is required")
return
}
resp, err := h.userProfileClient.UpdateUserContactInfo(request.Context(), &userv1.UpdateUserContactInfoRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
ContactInfo: contactInfo,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
// 兼容 H5 WhatsApp 字段命名,同时返回 profile方便页面刷新本地用户资料。
httpkit.WriteOK(writer, request, map[string]any{
"contact_info": resp.GetUser().GetContactInfo(),
"whatsapp_phone": resp.GetUser().GetContactInfo(),
"profile": profileData(resp.GetUser(), 0),
})
}
func contactInfoFromBody(body contactInfoUpdateBody) (string, bool) {
// 按字段是否存在判断用户是否提交,允许空字符串清空联系方式。
for _, value := range []*string{body.ContactInfo, body.ContactInfoCamel, body.WhatsappPhone, body.WhatsappPhoneCamel, body.Contact} {
if value != nil {
return strings.TrimSpace(*value), true
}
}
return "", false
}
// changeMyCountry 单独修改当前用户国家;冷却期和审计由 user-service 保证。
func (h *Handler) changeMyCountry(writer http.ResponseWriter, request *http.Request) {
var body struct {
@ -644,6 +697,7 @@ func profileData(user *userv1.User, nextCountryChangeAtMs int64) userProfileData
OnboardingStatus: user.GetOnboardingStatus(),
UpdatedAtMs: user.GetUpdatedAtMs(),
ProfileBgImg: user.GetProfileBgImg(),
ContactInfo: user.GetContactInfo(),
NextCountryChangeAtMs: nextCountryChangeAtMs,
}
}

View File

@ -3,6 +3,7 @@ package walletapi
import (
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
@ -23,7 +24,10 @@ type coinSellerData struct {
RegionName string `json:"region_name,omitempty"`
Status string `json:"status"`
MerchantAssetType string `json:"merchant_asset_type"`
UpdatedAtMS int64 `json:"updated_at_ms"`
// WhatsApp 字段固定输出,空联系方式也保留空字符串,避免充值页逐项读取时出现 schema 漂移。
WhatsappPhone string `json:"whatsapp_phone"`
WhatsappURL string `json:"whatsapp_url"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// listCoinSellers 返回当前用户所在区域内所有启用币商。
@ -54,6 +58,7 @@ func coinSellerFromProto(item *userv1.CoinSellerListItem) coinSellerData {
if item == nil {
return coinSellerData{}
}
contactInfo := strings.TrimSpace(item.GetContactInfo())
return coinSellerData{
UserID: int64IDString(item.GetUserId()),
DisplayUserID: item.GetDisplayUserId(),
@ -68,10 +73,36 @@ func coinSellerFromProto(item *userv1.CoinSellerListItem) coinSellerData {
RegionName: item.GetRegionName(),
Status: item.GetStatus(),
MerchantAssetType: item.GetMerchantAssetType(),
WhatsappPhone: contactInfo,
WhatsappURL: coinSellerWhatsAppURL(contactInfo),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func coinSellerWhatsAppURL(raw string) string {
contact := strings.TrimSpace(raw)
if contact == "" {
return ""
}
lower := strings.ToLower(contact)
for _, prefix := range []string{"https://wa.me/", "http://wa.me/", "whatsapp://", "https://api.whatsapp.com/", "http://api.whatsapp.com/"} {
if strings.HasPrefix(lower, prefix) {
return contact
}
}
var digits strings.Builder
for _, r := range contact {
if r >= '0' && r <= '9' {
digits.WriteRune(r)
}
}
if digits.Len() == 0 {
return ""
}
return "https://wa.me/" + digits.String()
}
func int64IDString(value int64) string {
if value <= 0 {
return ""

View File

@ -0,0 +1,24 @@
package walletapi
import "testing"
func TestCoinSellerWhatsAppURL(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "phone number", raw: "+65 1234 5678", want: "https://wa.me/6512345678"},
{name: "wa me link", raw: "https://wa.me/6512345678", want: "https://wa.me/6512345678"},
{name: "whatsapp scheme", raw: "whatsapp://send?phone=6512345678", want: "whatsapp://send?phone=6512345678"},
{name: "blank contact", raw: " ", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := coinSellerWhatsAppURL(tt.raw); got != tt.want {
t.Fatalf("coinSellerWhatsAppURL(%q)=%q, want %q", tt.raw, got, tt.want)
}
})
}
}

View File

@ -185,7 +185,7 @@ type AdminUpdateRoom struct {
func (AdminUpdateRoom) Type() string { return "admin_update_room" }
// AdminDeleteRoom 定义后台删除房间请求;删除是 Room Cell 终态,不直接删物理表
// AdminDeleteRoom 定义后台删除房间请求;命令先经过 Room Cell 串行确认,再由 repository 清理恢复来源
type AdminDeleteRoom struct {
Base
AdminID uint64 `json:"admin_id,omitempty"`

View File

@ -79,17 +79,29 @@ func (s *Service) AdminDeleteRoom(ctx context.Context, req *roomv1.AdminDeleteRo
AdminID: req.GetAdminId(),
AdminName: strings.TrimSpace(req.GetAdminName()),
}
patch := command.AdminUpdateRoom{
Base: cmd.Base,
Status: stringPtr(state.RoomStatusDeleted),
CloseReason: "admin_deleted",
AdminID: cmd.AdminID,
AdminName: cmd.AdminName,
}
result, err := s.applyAdminRoomMutation(ctx, cmd, patch)
result, err := s.mutateRoom(ctx, cmd, false, func(_ time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if current == nil {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
}
// 删除不是“关闭”的另一种展示状态,而是后台确认移除房间恢复来源;状态只服务本次响应和内存 Cell 收尾。
current.Status = state.RoomStatusDeleted
current.OnlineUsers = map[int64]*state.RoomUserState{}
for index := range current.MicSeats {
current.ClearMicSession(index)
}
current.Version++
return mutationResult{
snapshot: current.ToProto(),
deleteRoom: true,
}, nil, nil
})
if err != nil {
return nil, err
}
if result.applied {
s.deleteRoomListCacheBestEffort(ctx, cmd.RoomID())
s.forgetLoadedRoom(ctx, cmd.RoomID())
}
return &roomv1.AdminDeleteRoomResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
Room: result.snapshot,
@ -143,7 +155,7 @@ func (s *Service) adminUpdateCommand(req *roomv1.AdminUpdateRoomRequest) (comman
if req.Status != nil {
value := strings.TrimSpace(req.GetStatus())
switch value {
case state.RoomStatusActive, state.RoomStatusClosed, state.RoomStatusDeleted:
case state.RoomStatusActive, state.RoomStatusClosed:
cmd.Status = &value
default:
return command.AdminUpdateRoom{}, xerr.New(xerr.InvalidArgument, "room status is invalid")
@ -294,7 +306,3 @@ func adminRoomListItemProto(item AdminRoomListEntry) *roomv1.AdminRoomListItem {
UpdatedAtMs: item.UpdatedAtMS,
}
}
func stringPtr(value string) *string {
return &value
}

View File

@ -2,12 +2,14 @@ package service
import (
"context"
"log/slog"
"slices"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/logx"
"hyapp/services/room-service/internal/room/cell"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/state"
@ -37,6 +39,29 @@ func (s *Service) loadCell(ctx context.Context, roomID string) *cell.RoomCell {
return s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)]
}
func (s *Service) forgetLoadedRoom(ctx context.Context, roomID string) {
if s == nil {
return
}
app := appcode.FromContext(ctx)
key := runtimeRoomKey(app, roomID)
s.mu.Lock()
delete(s.cells, key)
s.mu.Unlock()
if s.directory != nil {
if _, err := s.directory.ReleaseOwner(ctx, key, s.nodeID); err != nil {
// 删除事实已在 MySQL 提交,释放 lease 失败只影响其他节点接管等待 TTL不能回滚删除结果。
logx.Warn(ctx, "room_delete_lease_release_failed",
slog.String("node_id", s.nodeID),
slog.String("app_code", app),
slog.String("room_id", roomID),
slog.String("error", err.Error()),
)
}
}
}
func (s *Service) loadedRoomRefs() []loadedRoomRef {
s.mu.Lock()
defer s.mu.Unlock()

View File

@ -348,6 +348,159 @@ func TestCloseRoomByAdminEvictsCurrentUsersThroughOutboxAndRTC(t *testing.T) {
}
}
func TestAdminDeleteRoomHardDeletesRoomStateAndAllowsRecreate(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := roomservice.New(roomservice.Config{
NodeID: "node-admin-delete-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-admin-delete-flow"
ownerID := int64(8301)
viewerID := int64(8302)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Delete Flow",
RoomAvatar: testRoomCoverURL,
RoomShortId: "delete-flow",
}); err != nil {
t.Fatalf("create delete room fixture failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join delete fixture viewer failed: %v", err)
}
resp, err := svc.AdminDeleteRoom(ctx, &roomv1.AdminDeleteRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-admin-delete-room",
CommandId: "cmd-admin-delete-room",
ActorUserId: 1,
RoomId: roomID,
AppCode: appcode.Default,
SentAtMs: time.Now().UnixMilli(),
},
AdminId: 1,
AdminName: "admin",
})
if err != nil {
t.Fatalf("admin delete room failed: %v", err)
}
if !resp.GetResult().GetApplied() || resp.GetRoom().GetStatus() != "deleted" {
t.Fatalf("delete response must report one applied delete mutation: %+v", resp)
}
if _, exists, err := repository.GetRoomMeta(ctx, roomID); err != nil || exists {
t.Fatalf("rooms metadata must be physically deleted, exists=%v err=%v", exists, err)
}
for _, table := range []string{"room_list_entries", "room_snapshots", "room_command_log", "room_user_presence", "room_user_feed_entries"} {
if count := repository.CountRowsByRoom(table, roomID); count != 0 {
t.Fatalf("%s rows must be deleted, count=%d", table, count)
}
}
listResp, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
OwnerUserId: ownerID,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("list rooms after delete failed: %v", err)
}
if listResp.GetTotal() != 0 || len(listResp.GetRooms()) != 0 {
t.Fatalf("deleted room must disappear from admin list: %+v", listResp)
}
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Recreated Flow",
RoomAvatar: testRoomCoverURL,
RoomShortId: "delete-flow",
}); err != nil {
t.Fatalf("owner must be able to recreate room after hard delete: %v", err)
}
}
func TestClosedRoomOwnerJoinReactivatesButAudienceCannot(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := roomservice.New(roomservice.Config{
NodeID: "node-owner-reactivate-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-owner-reactivate"
ownerID := int64(8401)
viewerID := int64(8402)
adminID := int64(1)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Reactivate Flow",
RoomAvatar: testRoomCoverURL,
RoomShortId: "reactivate-flow",
}); err != nil {
t.Fatalf("create reactivation room fixture failed: %v", err)
}
if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-close-reactivation-room",
CommandId: "cmd-close-reactivation-room",
ActorUserId: adminID,
RoomId: roomID,
AppCode: appcode.Default,
SentAtMs: time.Now().UnixMilli(),
},
Reason: "admin_closed",
}); err != nil {
t.Fatalf("close room fixture failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); !xerr.IsCode(err, xerr.RoomClosed) {
t.Fatalf("audience join closed room must be rejected as ROOM_CLOSED: %v", err)
}
ownerJoin, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
Role: "audience",
})
if err != nil {
t.Fatalf("owner join closed room must reactivate it: %v", err)
}
if ownerJoin.GetRoom().GetStatus() != "active" {
t.Fatalf("owner join must return active room: %+v", ownerJoin.GetRoom())
}
if user := findRoomUser(ownerJoin.GetRoom(), ownerID); user == nil || user.GetRole() != "owner" {
t.Fatalf("reactivated owner must be in room with owner role: %+v", ownerJoin.GetRoom().GetOnlineUsers())
}
meta, exists, err := repository.GetRoomMeta(ctx, roomID)
if err != nil || !exists || meta.Status != "active" {
t.Fatalf("owner reactivation must persist active status, exists=%v meta=%+v err=%v", exists, meta, err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("audience must join after owner reactivates room: %v", err)
}
}
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() == userID {

View File

@ -16,13 +16,31 @@ import (
"hyapp/services/room-service/internal/room/state"
)
func (s *Service) requireRoomOpenForEntry(ctx context.Context, roomID string) error {
closed, err := s.isRoomClosed(ctx, roomID)
if err != nil || !closed {
return err
func (s *Service) roomEntryPolicy(ctx context.Context, roomID string, actorUserID int64) (bool, error) {
// 进房前先读 rooms 元数据,避免为了拒绝关闭/删除房间而恢复完整 Room Cell。
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
if err != nil {
return false, err
}
if !exists {
// 硬删除后的房间必须表现为不存在,不能因为旧内存 Cell 或旧快照被再次加入。
return false, xerr.New(xerr.NotFound, "room not found")
}
switch strings.TrimSpace(roomMeta.Status) {
case "", state.RoomStatusActive:
return false, nil
case state.RoomStatusClosed:
if roomMeta.OwnerUserID == actorUserID && actorUserID > 0 {
// 只有房主本人进入 closed 房间时才允许在 JoinRoom 命令内重新激活。
return true, nil
}
return false, xerr.New(xerr.RoomClosed, "room closed")
case state.RoomStatusDeleted:
// deleted 只兼容历史软删除数据;新删除会物理清理,旧 deleted 也不能被房主重新激活。
return false, xerr.New(xerr.NotFound, "room not found")
default:
return false, xerr.New(xerr.RoomClosed, "room closed")
}
// 进房入口使用专门错误码gateway 可以稳定映射成客户端房间关闭态。
return xerr.New(xerr.RoomClosed, "room closed")
}
func (s *Service) isRoomClosed(ctx context.Context, roomID string) (bool, error) {

View File

@ -303,6 +303,16 @@ func (s *Service) projectRoomListCacheBestEffort(ctx context.Context, entry Room
}
}
func (s *Service) deleteRoomListCacheBestEffort(ctx context.Context, roomID string) {
if s == nil || s.roomListCache == nil {
return
}
if err := s.roomListCache.DeleteRoomListEntry(ctx, appcode.FromContext(ctx), roomID); err != nil {
// MySQL 删除已经是权威事实;缓存删除失败只能记录,后续全量重建会收敛。
logx.Warn(ctx, "room_list_cache_delete_failed", slog.String("room_id", roomID), slog.String("error", err.Error()))
}
}
// roomListEntryFromSnapshot 把完整房间快照压缩成列表卡片读模型。
// 这里刻意只提取列表需要的字段,避免列表表结构反向绑定 RoomState 全量细节。
func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID int64, nowMS int64) RoomListEntry {

View File

@ -98,6 +98,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
CreatedAtMS: now.UnixMilli(),
},
OutboxRecords: durableOutboxRecords,
DeleteRoom: result.deleteRoom,
RoomStatus: result.roomStatus,
RoomSeatCount: result.roomSeatCount,
RoomPasswordHash: result.roomPasswordHash,
@ -164,7 +165,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
s.roomRocketProgressCoalescer.enqueue(*result.roomRocketProgress)
}
if (result.applied || result.forceSnapshot) && result.snapshot != nil && (result.forceSnapshot || shouldSnapshot(result.snapshot, s.snapshotEveryN)) {
if !result.deleteRoom && (result.applied || result.forceSnapshot) && result.snapshot != nil && (result.forceSnapshot || shouldSnapshot(result.snapshot, s.snapshotEveryN)) {
// 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。
snapshotStartedAt := time.Now()
if err := s.persistSnapshot(ctx, result.snapshot); err != nil {
@ -172,7 +173,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
}
snapshotMS = elapsedMS(snapshotStartedAt)
}
if result.applied {
if result.applied && !result.deleteRoom {
projectionStartedAt := time.Now()
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
s.projectRoomListBestEffort(ctx, result.snapshot)

View File

@ -39,11 +39,25 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
// 客户端不传角色时按普通观众进入房间。
cmd.Role = "audience"
}
if err := s.requireRoomOpenForEntry(ctx, cmd.RoomID()); err != nil {
reactivateClosedRoom, err := s.roomEntryPolicy(ctx, cmd.RoomID(), cmd.ActorUserID())
if err != nil {
return nil, err
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
result := mutationResult{}
if current.Status == state.RoomStatusClosed {
if !reactivateClosedRoom || current.OwnerUserID != cmd.ActorUserID() {
// 元数据和 Room Cell 任一层发现不是房主恢复 closed 房间,都必须继续拒绝进房。
return mutationResult{}, nil, xerr.New(xerr.RoomClosed, "room closed")
}
current.Status = state.RoomStatusActive
result.roomStatus = state.RoomStatusActive
result.closeInfo = &RoomCloseInfo{ClearOnOpen: true}
}
if current.Status != state.RoomStatusActive {
return mutationResult{}, nil, xerr.New(xerr.RoomClosed, "room closed")
}
if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists {
if ban.ActiveAt(now.UnixMilli()) {
// 被踢或封禁用户在 ban 未过期前不能重新进入业务 presence。
@ -58,6 +72,11 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "room password is invalid")
}
joinRole := cmd.Role
if current.OwnerUserID == cmd.ActorUserID() {
// 房主恢复 closed 房间时通常没有现存 presence角色必须按房间 owner 事实收敛为 owner。
joinRole = "owner"
}
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
// 重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新 last_seen
// 但房间 UI 仍需要一条入房飘窗。这里把事件放进 directIMRecords不进入 durable outbox
@ -70,43 +89,41 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
return mutationResult{}, nil, err
}
snapshot := current.ToProto()
return mutationResult{
snapshot: snapshot,
user: findProtoUser(snapshot, cmd.ActorUserID()),
directIMRecords: []outbox.Record{joinedDisplayEvent},
}, nil, nil
result.snapshot = snapshot
result.user = findProtoUser(snapshot, cmd.ActorUserID())
result.directIMRecords = []outbox.Record{joinedDisplayEvent}
return result, nil, nil
}
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
UserID: cmd.ActorUserID(),
Role: cmd.Role,
Role: joinRole,
JoinedAtMS: now.UnixMilli(),
LastSeenAtMS: now.UnixMilli(),
}
current.Version++
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, cmd.Role, current.VisibleRegionID))
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, joinRole, current.VisibleRegionID))
if err != nil {
return mutationResult{}, nil, err
}
snapshot := current.ToProto()
return mutationResult{
snapshot: snapshot,
user: findProtoUser(snapshot, cmd.ActorUserID()),
syncEvent: &tencentim.RoomEvent{
EventID: joinedEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_joined",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.ActorUserID(),
RoomVersion: current.Version,
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
Attributes: roomUserJoinedIMAttributes(cmd.Role, cmd.ActorDisplayProfile),
},
}, []outbox.Record{joinedEvent}, nil
result.snapshot = snapshot
result.user = findProtoUser(snapshot, cmd.ActorUserID())
result.syncEvent = &tencentim.RoomEvent{
EventID: joinedEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_joined",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.ActorUserID(),
RoomVersion: current.Version,
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
Attributes: roomUserJoinedIMAttributes(joinRole, cmd.ActorDisplayProfile),
}
return result, []outbox.Record{joinedEvent}, nil
})
if err != nil {
return nil, err

View File

@ -147,6 +147,14 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
current.RoomExt[roomExtBackgroundKey] = typed.BackgroundURL
current.Version++
case *command.JoinRoom:
if current.Status == state.RoomStatusClosed && typed.ActorUserID() == current.OwnerUserID {
// 房主进 closed 房会在 JoinRoom 提交里恢复 active回放必须重建同一个生命周期状态。
current.Status = state.RoomStatusActive
}
role := typed.Role
if typed.ActorUserID() == current.OwnerUserID {
role = "owner"
}
if existing, exists := current.OnlineUsers[typed.ActorUserID()]; exists {
// 重复 JoinRoom 回放只刷新 last_seen不能重置首次进房时间或降级 owner 角色。
existing.LastSeenAtMS = typed.SentAtMS
@ -154,7 +162,7 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
// presence 使用命令发送时间恢复,真实 IM 连接由客户端重新登录腾讯云 IM。
current.OnlineUsers[typed.ActorUserID()] = &state.RoomUserState{
UserID: typed.ActorUserID(),
Role: typed.Role,
Role: role,
JoinedAtMS: typed.SentAtMS,
LastSeenAtMS: typed.SentAtMS,
}

View File

@ -59,6 +59,9 @@ type MutationCommit struct {
Command CommandRecord
// OutboxRecords 是本次命令产生的房间外事件。
OutboxRecords []outbox.Record
// DeleteRoom 表示后台确认删除房间后清理该房间的持久恢复来源和列表投影。
// 该标记只用于 AdminDeleteRoom关闭房间必须继续走 RoomStatus=closed保留可恢复的房间事实。
DeleteRoom bool
// RoomStatus 非空时同步更新 rooms 和 room_list_entries 的生命周期状态。
RoomStatus string
// RoomSeatCount 非 nil 时同步更新 rooms 元数据中的麦位总数。

View File

@ -184,6 +184,8 @@ type mutationResult struct {
roomRocketProgress *roomRocketProgressPending
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
roomStatus string
// deleteRoom 表示本命令成功后房间应从持久恢复来源、列表投影和当前节点 Cell 注册表中移除。
deleteRoom bool
// roomSeatCount 非 nil 时代表命令提交时需要同步更新 rooms 元数据座位数。
roomSeatCount *int32
// roomPasswordHash 非 nil 时代表命令提交时需要同步更新 rooms 当前密码哈希。

View File

@ -17,7 +17,7 @@ const (
RoomStatusClosing = "closing"
// RoomStatusClosed 表示房间已经关闭guard 和进房都必须拒绝。
RoomStatusClosed = "closed"
// RoomStatusDeleted 表示后台删除后的运营终态;保留 command log/snapshot 作为恢复和审计来源。
// RoomStatusDeleted 只兼容历史后台软删除数据;新的后台删除会物理清理房间恢复来源。
RoomStatusDeleted = "deleted"
// RocketStatusCharging 表示当前等级火箭还在积攒燃料。

View File

@ -95,6 +95,14 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
return err
}
if commit.DeleteRoom {
if err := r.deleteRoomRows(ctx, tx, appCode, commit.Command.RoomID); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
if strings.TrimSpace(commit.RoomStatus) != "" {
if _, err := tx.ExecContext(ctx,
`UPDATE rooms
@ -276,6 +284,31 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
return tx.Commit()
}
func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error {
// 删除覆盖所有能让房间重新出现在列表、恢复链路或 owner 唯一约束里的本服务表。
// room_outbox 也一起清理,避免已删除房间继续补偿投递旧的展示事件。
statements := []string{
`DELETE FROM room_outbox WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_robot_rooms WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_region_pins WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_follows WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_user_feed_entries WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_user_presence WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_user_gift_stats WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_background_images WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`,
`DELETE FROM rooms WHERE app_code = ? AND room_id = ?`,
}
for _, statement := range statements {
if _, err := tx.ExecContext(ctx, statement, appCode, roomID); err != nil {
return err
}
}
return nil
}
func (r *Repository) insertRoomOutboxRecords(ctx context.Context, tx *sql.Tx, records []outbox.Record) error {
if len(records) == 0 {
return nil

View File

@ -72,6 +72,17 @@ func (r *Repository) DeleteRoomListEntry(roomID string) {
}
}
// CountRowsByRoom returns how many rows a room still owns in a test table.
func (r *Repository) CountRowsByRoom(table string, roomID string) int64 {
r.t.Helper()
var count int64
if err := r.schema.DB.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM `+table+` WHERE app_code = ? AND room_id = ?`, appcode.Default, roomID).Scan(&count); err != nil {
r.t.Fatalf("count %s rows failed: %v", table, err)
}
return count
}
// SetRoomSortScore rewrites discovery sort_score for deterministic list-order tests.
func (r *Repository) SetRoomSortScore(roomID string, sortScore int64) {
r.t.Helper()

View File

@ -65,6 +65,7 @@ CREATE TABLE IF NOT EXISTS users (
os_version VARCHAR(64) NULL COMMENT 'os版本',
avatar VARCHAR(512) NULL COMMENT '头像',
profile_bg_img VARCHAR(512) NOT NULL DEFAULT '' COMMENT '个人主页信息卡背景图',
contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户联系信息',
birth_date DATE NULL COMMENT '出生日期',
app_version VARCHAR(64) NULL COMMENT '应用版本',
build_number VARCHAR(64) NULL COMMENT '构建编号',
@ -1267,3 +1268,18 @@ CREATE TABLE IF NOT EXISTS login_risk_ip_whitelist (
KEY idx_login_risk_ip_whitelist_enabled (app_code, enabled, ip_address),
KEY idx_login_risk_ip_whitelist_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录风险 IP 白名单表';
CREATE TABLE IF NOT EXISTS login_risk_user_whitelist (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
whitelist_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '白名单 ID',
user_id BIGINT NOT NULL COMMENT '允许跳过登录地区屏蔽复核的用户 ID',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用',
created_by_user_id BIGINT NULL COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NULL COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (whitelist_id),
UNIQUE KEY uk_login_risk_user_whitelist_user (app_code, user_id),
KEY idx_login_risk_user_whitelist_enabled (app_code, enabled, user_id),
KEY idx_login_risk_user_whitelist_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录风险用户白名单表';

View File

@ -0,0 +1,15 @@
SET @add_user_contact_info := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE users ADD COLUMN contact_info VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''用户联系信息'' AFTER profile_bg_img',
'SELECT 1'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'contact_info'
);
PREPARE stmt FROM @add_user_contact_info;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -83,6 +83,17 @@ type LoginRiskIPWhitelist struct {
UpdatedAtMs int64
}
// LoginRiskUserWhitelist 是后台维护的登录地区屏蔽用户白名单事实。
// 用户 ID 命中后只跳过登录后的地区/IP 复核,不改变账号状态、注册设备限制或登录前 gateway 快拦语义。
type LoginRiskUserWhitelist struct {
AppCode string
WhitelistID int64
UserID int64
Enabled bool
CreatedAtMs int64
UpdatedAtMs int64
}
// RegisterRiskConfig 是注册链路的风控开关集合。
// 当前只包含单设备最大账号数,后续增加注册频率或设备指纹策略时仍可复用同一行 App 配置。
type RegisterRiskConfig struct {

View File

@ -11,6 +11,8 @@ const (
HostSourceApplication = "agency_application"
// HostSourceAgencyInvitation 表示主播身份来自接受 Agency 邀请并成为 Agency owner。
HostSourceAgencyInvitation = "agency_invitation"
// HostSourceHostInvitation 表示主播身份来自 Agency owner 邀请加入,本身不具备 Agency owner 身份。
HostSourceHostInvitation = "host_invitation"
// HostSourceAdminCreateAgency 表示主播身份来自后台直接创建 Agency。
HostSourceAdminCreateAgency = "admin_create_agency"
@ -235,6 +237,7 @@ type CoinSellerListItem struct {
RegionName string
Status string
MerchantAssetType string
ContactInfo string
UpdatedAtMs int64
}

View File

@ -14,6 +14,8 @@ const (
ProfileUsernameMaxRunes = 64
// ProfileAvatarMaxRunes 必须和 users.avatar VARCHAR(512) 保持一致。
ProfileAvatarMaxRunes = 512
// ProfileContactInfoMaxRunes 必须和 users.contact_info VARCHAR(128) 保持一致。
ProfileContactInfoMaxRunes = 128
// ProfileGenderMaxRunes 必须和 users.gender VARCHAR(32) 保持一致。
ProfileGenderMaxRunes = 32
)

View File

@ -258,6 +258,8 @@ type User struct {
Avatar string
// ProfileBgImg 是用户主页信息卡背景图片 URL属于用户主资料不属于钱包装扮或 profile_card 资源。
ProfileBgImg string
// ContactInfo 是用户级联系方式Host、BD、Agency owner 和币商等经营身份共同展示同一份用户主资料。
ContactInfo string
// BirthDate 使用 yyyy-mm-dd 字符串表达生日,避免时区把日期偏移成前后一天。
BirthDate string
// RegisterAppVersion 是注册客户端版本。
@ -642,6 +644,18 @@ type ProfileBackgroundUpdateCommand struct {
UpdatedAtMs int64
}
// ProfileContactUpdateCommand 描述用户级联系方式的专用修改。
type ProfileContactUpdateCommand struct {
// AppCode 是资料修改所属 App。
AppCode string
// UserID 是要修改联系方式的用户,只能来自当前登录态或后台已解析出的目标用户。
UserID int64
// ContactInfo 是用于后台和经营中心展示的联系方式;空字符串表示清空。
ContactInfo string
// UpdatedAtMs 是本次修改时间。
UpdatedAtMs int64
}
// CompleteOnboardingCommand 描述注册页一次性补齐资料的事务输入。
type CompleteOnboardingCommand struct {
// AppCode 是注册资料固化所属 App。
@ -666,6 +680,20 @@ type CompleteOnboardingCommand struct {
CompletedAtMs int64
}
// BindInviteReferrerCommand 描述资料完成后的邀请码绑定事务。
type BindInviteReferrerCommand struct {
// AppCode 是邀请关系所属 App必须和双方用户及邀请码保持一致。
AppCode string
// InvitedUserID 是当前登录用户,只能绑定一次邀请人。
InvitedUserID int64
// InviteCode 是用户输入的邀请码,服务端在事务内解析归属并绑定。
InviteCode string
// RequestID 是入口请求 ID写入邀请关系和 outbox 便于追踪。
RequestID string
// BoundAtMs 是绑定事务时间,同时作为用户快照更新时间。
BoundAtMs int64
}
// CountryChangeCommand 描述用户国家修改事务。
type CountryChangeCommand struct {
// AppCode 是国家修改所属 App。

View File

@ -83,6 +83,29 @@ func (c *RedisIPDecisionCache) SetIPWhitelist(ctx context.Context, appCode strin
return c.client.Set(ctx, loginRiskIPWhitelistKey(appCode), raw, ttl).Err()
}
func (c *RedisIPDecisionCache) GetUserWhitelist(ctx context.Context, appCode string) ([]int64, bool, error) {
raw, err := c.client.Get(ctx, loginRiskUserWhitelistKey(appCode)).Result()
if errors.Is(err, redis.Nil) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var userIDs []int64
if err := json.Unmarshal([]byte(raw), &userIDs); err != nil {
return nil, false, err
}
return userIDs, true, nil
}
func (c *RedisIPDecisionCache) SetUserWhitelist(ctx context.Context, appCode string, userIDs []int64, ttl time.Duration) error {
raw, err := json.Marshal(userIDs)
if err != nil {
return err
}
return c.client.Set(ctx, loginRiskUserWhitelistKey(appCode), raw, ttl).Err()
}
func (c *RedisIPDecisionCache) GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, bool, error) {
raw, err := c.client.Get(ctx, registerRiskConfigKey(appCode)).Result()
if errors.Is(err, redis.Nil) {
@ -114,6 +137,10 @@ func loginRiskIPWhitelistKey(appCode string) string {
return "login_risk:ip_whitelist:" + appcode.Normalize(appCode)
}
func loginRiskUserWhitelistKey(appCode string) string {
return "login_risk:user_whitelist:" + appcode.Normalize(appCode)
}
func revokedSessionKey(appCode string, sessionID string) string {
return "auth:revoked_session:" + appcode.Normalize(appCode) + ":" + strings.TrimSpace(sessionID)
}

View File

@ -11,7 +11,19 @@ import (
authdomain "hyapp/services/user-service/internal/domain/auth"
)
const loginRiskIPWhitelistCacheTTL = 30 * time.Second
const (
loginRiskIPWhitelistCacheTTL = 30 * time.Second
loginRiskUserWhitelistCacheTTL = 5 * time.Minute
)
// LoginRiskWhitelistCacheSnapshot 是后台保存配置后主动刷新缓存的结果。
// IP 白名单仍沿用原短 TTL用户白名单按运营要求缓存 5 分钟,避免每个风控任务回源 MySQL。
type LoginRiskWhitelistCacheSnapshot struct {
Refreshed bool
IPWhitelistCount int
UserWhitelistCount int
UserCacheExpiresAtMs int64
}
// ListLoginRiskBlockedCountries 返回当前 App 实际生效的登录地区风控词表。
// 静态配置和后台动态配置都下发给 AppApp 可异步做本地风险提示,最终封禁仍以服务端 worker 为准。
@ -79,6 +91,57 @@ func (s *Service) CheckLoginRiskIPWhitelisted(ctx context.Context, clientIP stri
return false, nil
}
// CheckLoginRiskUserWhitelisted 判断登录用户是否命中后台白名单。
// 该白名单只在 user-service 登录后风控 worker 中使用gateway 登录前没有可信 user_id不能用它跳过入口快拦。
func (s *Service) CheckLoginRiskUserWhitelisted(ctx context.Context, userID int64) (bool, error) {
if userID <= 0 {
return false, nil
}
userIDs, err := s.listLoginRiskUserWhitelist(ctx)
if err != nil {
return false, err
}
for _, item := range userIDs {
if item == userID {
return true, nil
}
}
return false, nil
}
// RefreshLoginRiskWhitelistCache 从 MySQL 读取当前 App 白名单并立即写入 Redis。
// 后台保存直接调用该方法,避免新增白名单后仍等 5 分钟 TTL 自然过期才生效。
func (s *Service) RefreshLoginRiskWhitelistCache(ctx context.Context) (LoginRiskWhitelistCacheSnapshot, error) {
if s.authRepository == nil {
return LoginRiskWhitelistCacheSnapshot{}, nil
}
ips, err := s.loadLoginRiskIPWhitelist(ctx)
if err != nil {
return LoginRiskWhitelistCacheSnapshot{}, err
}
userIDs, err := s.loadLoginRiskUserWhitelist(ctx)
if err != nil {
return LoginRiskWhitelistCacheSnapshot{}, err
}
snapshot := LoginRiskWhitelistCacheSnapshot{
IPWhitelistCount: len(ips),
UserWhitelistCount: len(userIDs),
UserCacheExpiresAtMs: s.now().Add(loginRiskUserWhitelistCacheTTL).UnixMilli(),
}
if s.ipDecisionCache == nil {
return snapshot, nil
}
// IP 和用户白名单共用一次刷新入口;任一缓存写失败都返回错误,让后台保存页能提示重试刷新。
if err := s.ipDecisionCache.SetIPWhitelist(ctx, appcode.FromContext(ctx), ips, loginRiskIPWhitelistCacheTTL); err != nil {
return LoginRiskWhitelistCacheSnapshot{}, err
}
if err := s.ipDecisionCache.SetUserWhitelist(ctx, appcode.FromContext(ctx), userIDs, loginRiskUserWhitelistCacheTTL); err != nil {
return LoginRiskWhitelistCacheSnapshot{}, err
}
snapshot.Refreshed = true
return snapshot, nil
}
func (s *Service) listLoginRiskIPWhitelist(ctx context.Context) ([]string, error) {
appCode := appcode.FromContext(ctx)
if s.ipDecisionCache != nil {
@ -87,6 +150,37 @@ func (s *Service) listLoginRiskIPWhitelist(ctx context.Context) ([]string, error
return normalizeLoginRiskIPList(ips), nil
}
}
ips, err := s.loadLoginRiskIPWhitelist(ctx)
if err != nil {
return nil, err
}
if s.ipDecisionCache != nil {
// 白名单读取结果按 App 整体缓存 30 秒;空列表也缓存,避免无配置时每次登录都回源 DB。
_ = s.ipDecisionCache.SetIPWhitelist(ctx, appCode, ips, loginRiskIPWhitelistCacheTTL)
}
return ips, nil
}
func (s *Service) listLoginRiskUserWhitelist(ctx context.Context) ([]int64, error) {
appCode := appcode.FromContext(ctx)
if s.ipDecisionCache != nil {
userIDs, ok, err := s.ipDecisionCache.GetUserWhitelist(ctx, appCode)
if err == nil && ok {
return normalizeLoginRiskUserIDList(userIDs), nil
}
}
userIDs, err := s.loadLoginRiskUserWhitelist(ctx)
if err != nil {
return nil, err
}
if s.ipDecisionCache != nil {
// 用户白名单按运营要求缓存 5 分钟;空列表也缓存,减少无配置时的任务回源。
_ = s.ipDecisionCache.SetUserWhitelist(ctx, appCode, userIDs, loginRiskUserWhitelistCacheTTL)
}
return userIDs, nil
}
func (s *Service) loadLoginRiskIPWhitelist(ctx context.Context) ([]string, error) {
if s.authRepository == nil {
return nil, nil
}
@ -100,12 +194,24 @@ func (s *Service) listLoginRiskIPWhitelist(ctx context.Context) ([]string, error
ips = append(ips, ip)
}
}
ips = normalizeLoginRiskIPList(ips)
if s.ipDecisionCache != nil {
// 白名单读取结果按 App 整体缓存 30 秒;空列表也缓存,避免无配置时每次登录都回源 DB。
_ = s.ipDecisionCache.SetIPWhitelist(ctx, appCode, ips, loginRiskIPWhitelistCacheTTL)
return normalizeLoginRiskIPList(ips), nil
}
func (s *Service) loadLoginRiskUserWhitelist(ctx context.Context) ([]int64, error) {
if s.authRepository == nil {
return nil, nil
}
return ips, nil
items, err := s.authRepository.ListEnabledLoginRiskUserWhitelist(ctx)
if err != nil {
return nil, err
}
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item.UserID > 0 {
userIDs = append(userIDs, item.UserID)
}
}
return normalizeLoginRiskUserIDList(userIDs), nil
}
func appendUniqueCountryBlock(blocks []authdomain.LoginRiskCountryBlock, seen map[string]struct{}, block authdomain.LoginRiskCountryBlock) []authdomain.LoginRiskCountryBlock {
@ -167,3 +273,22 @@ func normalizeLoginRiskIPList(raw []string) []string {
sort.Strings(ips)
return ips
}
func normalizeLoginRiskUserIDList(raw []int64) []int64 {
seen := make(map[int64]struct{}, len(raw))
userIDs := make([]int64, 0, len(raw))
for _, userID := range raw {
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
userIDs = append(userIDs, userID)
}
sort.Slice(userIDs, func(i, j int) bool {
return userIDs[i] < userIDs[j]
})
return userIDs
}

View File

@ -71,6 +71,19 @@ func normalizeLoginIPRiskWorkerOptions(options LoginIPRiskWorkerOptions) LoginIP
func (s *Service) processLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error {
policy := s.loginRiskPolicy
nowMs := s.now().UnixMilli()
userWhitelisted, err := s.CheckLoginRiskUserWhitelisted(ctx, job.UserID)
if err != nil {
logx.Warn(ctx, "login_user_whitelist_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.Int64("user_id", job.UserID), slog.String("error", err.Error()))
}
if userWhitelisted {
// 用户白名单是运营按真实 user_id 做的例外;命中后不再访问 GeoIP也不写 IP 决策缓存,避免把用户级放行扩散到同 IP 其他账号。
return s.applyLoginIPRiskDecision(ctx, job, IPDecision{
Decision: authdomain.LoginIPRiskDecisionAllowed,
Source: "user_whitelist",
CheckedAtMs: nowMs,
ExpiresAtMs: nowMs + loginRiskUserWhitelistCacheTTL.Milliseconds(),
}, nil, authdomain.LoginIPRiskStatusCompleted)
}
whitelisted, err := s.CheckLoginRiskIPWhitelisted(ctx, job.ClientIP)
if err != nil {
logx.Warn(ctx, "login_ip_whitelist_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))

View File

@ -88,6 +88,8 @@ type AuthRepository interface {
ListEnabledLoginRiskCountryBlocks(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error)
// ListEnabledLoginRiskIPWhitelist 返回当前 App 的登录地区屏蔽 IP 白名单。
ListEnabledLoginRiskIPWhitelist(ctx context.Context) ([]authdomain.LoginRiskIPWhitelist, error)
// ListEnabledLoginRiskUserWhitelist 返回当前 App 的登录地区屏蔽用户白名单。
ListEnabledLoginRiskUserWhitelist(ctx context.Context) ([]authdomain.LoginRiskUserWhitelist, error)
// MarkLoginAuditBlocked 把登录成功后被异步风控撤销的审计行更新成阻断态。
MarkLoginAuditBlocked(ctx context.Context, requestID string, userID int64, countryCode string, blockReason string) error
}
@ -177,6 +179,8 @@ type IPDecisionCache interface {
SetRevokedSession(ctx context.Context, appCode string, sessionID string, reason string, ttl time.Duration) error
GetIPWhitelist(ctx context.Context, appCode string) ([]string, bool, error)
SetIPWhitelist(ctx context.Context, appCode string, ips []string, ttl time.Duration) error
GetUserWhitelist(ctx context.Context, appCode string) ([]int64, bool, error)
SetUserWhitelist(ctx context.Context, appCode string, userIDs []int64, ttl time.Duration) error
}
// RegisterRiskConfigCache 是注册风控配置的 Redis 快取边界。

View File

@ -24,10 +24,18 @@ type memoryDecisionCache struct {
ip map[string]authservice.IPDecision
revoked map[string]string
ips map[string][]string
users map[string][]int64
userTTL map[string]time.Duration
}
func newMemoryDecisionCache() *memoryDecisionCache {
return &memoryDecisionCache{ip: make(map[string]authservice.IPDecision), revoked: make(map[string]string), ips: make(map[string][]string)}
return &memoryDecisionCache{
ip: make(map[string]authservice.IPDecision),
revoked: make(map[string]string),
ips: make(map[string][]string),
users: make(map[string][]int64),
userTTL: make(map[string]time.Duration),
}
}
func (c *memoryDecisionCache) GetIPDecision(_ context.Context, appCode string, clientIPHash string) (authservice.IPDecision, bool, error) {
@ -65,6 +73,21 @@ func (c *memoryDecisionCache) SetIPWhitelist(_ context.Context, appCode string,
return nil
}
func (c *memoryDecisionCache) GetUserWhitelist(_ context.Context, appCode string) ([]int64, bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
userIDs, ok := c.users[appCode]
return append([]int64(nil), userIDs...), ok, nil
}
func (c *memoryDecisionCache) SetUserWhitelist(_ context.Context, appCode string, userIDs []int64, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.users[appCode] = append([]int64(nil), userIDs...)
c.userTTL[appCode] = ttl
return nil
}
type sequenceIDGenerator struct {
// values 是测试预设 user_id 序列。
values []int64
@ -671,6 +694,81 @@ func TestLoginIPRiskIPWhitelistBypassesBlockedCountry(t *testing.T) {
}
}
func TestLoginIPRiskUserWhitelistBypassesBlockedCountryWithFiveMinuteCache(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
now := time.UnixMilli(1000)
if _, err := repository.RawDB().ExecContext(ctx, `
INSERT INTO login_risk_user_whitelist (
app_code, user_id, enabled, created_at_ms, updated_at_ms
)
VALUES ('lalu', 900025, 1, 1000, 1000)
`); err != nil {
t.Fatalf("seed user whitelist failed: %v", err)
}
cache := newMemoryDecisionCache()
authSvc := newAuthService(repository, &now, []int64{900025}, []string{"100025"},
authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
Enabled: true,
UnsupportedCountries: []string{"CN"},
BlockedTTL: time.Hour,
AllowedTTL: time.Hour,
UnknownTTL: time.Minute,
ProviderTimeout: 50 * time.Millisecond,
ProviderNames: []string{"edge_country"},
DenylistTTL: time.Hour,
}),
authservice.WithIPDecisionCache(cache),
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})),
)
snapshot, err := authSvc.RefreshLoginRiskWhitelistCache(ctx)
if err != nil {
t.Fatalf("refresh whitelist cache failed: %v", err)
}
if !snapshot.Refreshed || snapshot.UserWhitelistCount != 1 || snapshot.UserCacheExpiresAtMs != now.Add(5*time.Minute).UnixMilli() {
t.Fatalf("user whitelist refresh snapshot mismatch: %+v", snapshot)
}
if got := cache.userTTL["lalu"]; got != 5*time.Minute {
t.Fatalf("user whitelist cache ttl = %v, want 5m", got)
}
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-user-whitelist", thirdPartyRegistration("ios"), authservice.Meta{
AppCode: "lalu",
RequestID: "req-risk-user-whitelist",
ClientIP: "203.0.113.81",
CountryByIP: "CN",
Platform: "ios",
})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(2000)
processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-user-whitelist-test", LockTTL: time.Second, BatchSize: 10})
if err != nil || processed != 1 {
t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
}
if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-user-whitelist"}); err != nil {
t.Fatalf("whitelisted user must keep refresh session active: %v", err)
}
if _, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.81")]; ok {
t.Fatalf("user whitelist must not write ordinary IP decision cache")
}
var decision string
if err := repository.RawDB().QueryRowContext(ctx, `
SELECT decision
FROM login_ip_risk_jobs
WHERE app_code = 'lalu' AND request_id = 'req-risk-user-whitelist'
`).Scan(&decision); err != nil {
t.Fatalf("query risk job failed: %v", err)
}
if decision != "allowed" {
t.Fatalf("whitelisted user risk job decision mismatch: %s", decision)
}
}
func TestLoginIPRiskWorkerRevokesRotatedSameDeviceSessions(t *testing.T) {
// 复现线上竞态:登录后风控任务尚未完成,客户端连续 refresh 轮换 session。
// blocked 决策落地时必须沿源 session 的同设备登录链路清掉后续 session否则最新 access/refresh 仍能继续使用。

View File

@ -389,7 +389,7 @@ func (s *Service) CreateBDLeader(ctx context.Context, command CreateBDLeaderInpu
TargetUserID: command.TargetUserID,
Reason: strings.TrimSpace(command.Reason),
RequestID: strings.TrimSpace(command.RequestID),
EventID: s.idGenerator.NewInt64(),
EventID: s.nextEventIDRange(2),
NowMs: nowMs,
})
}

View File

@ -51,6 +51,22 @@ func seedActiveUserInCountry(t *testing.T, repository *mysqltest.Repository, use
})
}
func seedActiveMembership(t *testing.T, repository *mysqltest.Repository, membershipID int64, agencyID int64, hostUserID int64, membershipType string) {
t.Helper()
if membershipType == "" {
membershipType = hostdomain.MembershipTypeMember
}
_, err := repository.RawDB().Exec(`
INSERT INTO agency_memberships (
membership_id, agency_id, host_user_id, membership_type, status,
joined_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
`, membershipID, agencyID, hostUserID, membershipType, hostdomain.MembershipStatusActive)
if err != nil {
t.Fatalf("seed active membership %d failed: %v", membershipID, err)
}
}
func displayID(userID int64) string {
return fmt.Sprintf("%06d", 900000+userID%100000)
}
@ -216,18 +232,27 @@ func TestApplyReviewApproveCreatesHostMembershipIdempotently(t *testing.T) {
}
}
func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.T) {
func TestAcceptAgencyInvitationCreatesOwnerHostAndPreservesExistingHostBinding(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 501, 10)
seedActiveUser(t, repository, 601, 10)
seedActiveUser(t, repository, 602, 10)
seedActiveUser(t, repository, 603, 10)
seedActiveUser(t, repository, 604, 10)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 501,
Role: hostdomain.BDRoleBD,
RegionID: 10,
Status: hostdomain.BDStatusActive,
})
repository.PutAgency(hostdomain.Agency{
AgencyID: 650,
OwnerUserID: 604,
RegionID: 10,
Status: hostdomain.AgencyStatusActive,
JoinEnabled: true,
})
svc := newHostService(repository, 2000, 3000)
invitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
@ -239,8 +264,16 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.
if err != nil {
t.Fatalf("InviteAgency failed: %v", err)
}
if invitation.Status != hostdomain.InvitationStatusAccepted {
t.Fatalf("agency invitation should be accepted immediately: %+v", invitation)
if invitation.Status != hostdomain.InvitationStatusPending {
t.Fatalf("agency invitation should wait for target accept: %+v", invitation)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-agency-601",
ActorUserID: 601,
InvitationID: invitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("ProcessRoleInvitation accept agency failed: %v", err)
}
summary, err := svc.GetUserRoleSummary(ctx, 601)
if err != nil {
@ -272,6 +305,14 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.
if err != nil {
t.Fatalf("detached active host should become agency owner: %v", err)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-agency-602",
ActorUserID: 602,
InvitationID: secondInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("ProcessRoleInvitation accept detached host agency failed: %v", err)
}
secondSummary, err := svc.GetUserRoleSummary(ctx, 602)
if err != nil {
t.Fatalf("GetUserRoleSummary for reused host failed: %v", err)
@ -280,17 +321,121 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.
if err != nil {
t.Fatalf("GetHostProfile for reused host failed: %v", err)
}
if !secondSummary.IsAgency || !secondSummary.IsHost || secondHostProfile.CurrentAgencyID <= 0 || secondHostProfile.Source != "test" || secondInvitation.Status != hostdomain.InvitationStatusAccepted {
if !secondSummary.IsAgency || !secondSummary.IsHost || secondHostProfile.CurrentAgencyID <= 0 || secondHostProfile.Source != "test" || secondInvitation.Status != hostdomain.InvitationStatusPending {
t.Fatalf("detached host reuse mismatch: summary=%+v host=%+v invitation=%+v", secondSummary, secondHostProfile, secondInvitation)
}
_, err = svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
CommandID: "invite-agency-602-again",
InviterUserID: 501,
TargetUserID: 602,
AgencyName: "Blocked Agency",
repository.PutHostProfile(hostdomain.HostProfile{
UserID: 603,
Status: hostdomain.HostStatusActive,
RegionID: 10,
CurrentAgencyID: 650,
CurrentMembershipID: 660,
Source: "existing-membership",
})
if got := xerr.CodeOf(err); got != xerr.Conflict {
t.Fatalf("host with active membership must not become another agency owner: got %s err=%v", got, err)
seedActiveMembership(t, repository, 660, 650, 603, hostdomain.MembershipTypeMember)
boundHostInvitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
CommandID: "invite-agency-603-bound-host",
InviterUserID: 501,
TargetUserID: 603,
AgencyName: "Bound Host Agency",
})
if err != nil {
t.Fatalf("active bound host should still be invitable as agency owner: %v", err)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-agency-603-bound-host",
ActorUserID: 603,
InvitationID: boundHostInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("ProcessRoleInvitation accept bound host agency failed: %v", err)
}
boundHostProfile, err := svc.GetHostProfile(ctx, 603)
if err != nil {
t.Fatalf("GetHostProfile for bound host failed: %v", err)
}
boundSummary, err := svc.GetUserRoleSummary(ctx, 603)
if err != nil {
t.Fatalf("GetUserRoleSummary for bound host failed: %v", err)
}
if !boundSummary.IsAgency || !boundSummary.IsHost || boundHostProfile.CurrentAgencyID != 650 || boundHostProfile.CurrentMembershipID != 660 {
t.Fatalf("agency owner must not overwrite existing host binding: summary=%+v host=%+v", boundSummary, boundHostProfile)
}
}
func TestAcceptHostInvitationDoesNotGrantAgencyIdentity(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 611, 10)
seedActiveUser(t, repository, 612, 10)
seedActiveUser(t, repository, 613, 10)
repository.PutAgency(hostdomain.Agency{
AgencyID: 620,
OwnerUserID: 611,
RegionID: 10,
Status: hostdomain.AgencyStatusActive,
JoinEnabled: true,
})
repository.PutHostProfile(hostdomain.HostProfile{
UserID: 611,
Status: hostdomain.HostStatusActive,
RegionID: 10,
CurrentAgencyID: 620,
CurrentMembershipID: 621,
Source: hostdomain.HostSourceAgencyInvitation,
})
svc := newHostService(repository, 630, 640)
invitation, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
CommandID: "invite-host-612",
InviterUserID: 611,
TargetUserID: 612,
})
if err != nil {
t.Fatalf("InviteHost failed: %v", err)
}
result, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-host-612",
ActorUserID: 612,
InvitationID: invitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if err != nil {
t.Fatalf("ProcessRoleInvitation accept host failed: %v", err)
}
if result.Membership.MembershipType != hostdomain.MembershipTypeMember || result.HostProfile.Source != hostdomain.HostSourceHostInvitation {
t.Fatalf("host invitation should create member-only host facts: result=%+v", result)
}
summary, err := svc.GetUserRoleSummary(ctx, 612)
if err != nil {
t.Fatalf("GetUserRoleSummary host member failed: %v", err)
}
if !summary.IsHost || summary.IsAgency || summary.AgencyID != 0 {
t.Fatalf("ordinary host member must not expose agency identity: %+v", summary)
}
repository.PutHostProfile(hostdomain.HostProfile{
UserID: 613,
Status: hostdomain.HostStatusActive,
RegionID: 10,
CurrentAgencyID: 620,
CurrentMembershipID: 622,
Source: hostdomain.HostSourceAgencyInvitation,
})
legacySummary, err := svc.GetUserRoleSummary(ctx, 613)
if err != nil {
t.Fatalf("GetUserRoleSummary legacy host member failed: %v", err)
}
if !legacySummary.IsHost || legacySummary.IsAgency || legacySummary.AgencyID != 0 {
t.Fatalf("legacy host member source must not imply agency identity: %+v", legacySummary)
}
ownerSummary, err := svc.GetUserRoleSummary(ctx, 611)
if err != nil {
t.Fatalf("GetUserRoleSummary owner failed: %v", err)
}
if !ownerSummary.IsHost || !ownerSummary.IsAgency || ownerSummary.AgencyID != 620 {
t.Fatalf("agency owner should keep agency identity: %+v", ownerSummary)
}
}
@ -315,8 +460,16 @@ func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) {
if err != nil {
t.Fatalf("InviteBD failed: %v", err)
}
if invitation.Status != hostdomain.InvitationStatusAccepted {
t.Fatalf("bd invitation should be accepted immediately: %+v", invitation)
if invitation.Status != hostdomain.InvitationStatusPending {
t.Fatalf("bd invitation should wait for target accept: %+v", invitation)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-bd-702",
ActorUserID: 702,
InvitationID: invitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("ProcessRoleInvitation accept bd failed: %v", err)
}
bdProfile, err := svc.GetBDProfile(ctx, 702, hostdomain.BDRoleBD)
if err != nil {
@ -387,11 +540,19 @@ func TestInviteBDAcceptsSelfInviteForLeader(t *testing.T) {
TargetUserID: 703,
})
if err != nil {
t.Fatalf("bd leader self invite must be accepted: %v", err)
t.Fatalf("bd leader self invite must be created: %v", err)
}
if invitation.Status != hostdomain.InvitationStatusAccepted || invitation.InviterUserID != 703 || invitation.TargetUserID != 703 || invitation.ParentLeaderUserID != 703 {
if invitation.Status != hostdomain.InvitationStatusPending || invitation.InviterUserID != 703 || invitation.TargetUserID != 703 || invitation.ParentLeaderUserID != 703 {
t.Fatalf("self invite invitation mismatch: %+v", invitation)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-bd-self-703",
ActorUserID: 703,
InvitationID: invitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("self invite accept must create ordinary bd profile: %v", err)
}
ordinaryBD, err := svc.GetBDProfile(ctx, 703, hostdomain.BDRoleBD)
if err != nil {
t.Fatalf("self invite must create ordinary bd profile: %v", err)
@ -458,19 +619,23 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) {
t.Fatalf("leader retry must replay existing result: %+v", retriedLeader)
}
sameUserBD, err := svc.CreateBD(ctx, hostservice.CreateBDInput{
sameUserBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleBD)
if err != nil {
t.Fatalf("CreateBDLeader must create self ordinary bd: %v", err)
}
if sameUserBD.Role != hostdomain.BDRoleBD || sameUserBD.ParentLeaderUserID != 801 {
t.Fatalf("self ordinary bd mismatch: %+v", sameUserBD)
}
_, err = svc.CreateBD(ctx, hostservice.CreateBDInput{
CommandID: "admin-create-bd-801-same-user",
AdminUserID: 1,
TargetUserID: 801,
ParentLeaderUserID: 801,
Reason: "same user can also be bd",
Reason: "same user already has bd",
RequestID: "req-bd-same-user-1",
})
if err != nil {
t.Fatalf("CreateBD for existing leader user failed: %v", err)
}
if sameUserBD.Role != hostdomain.BDRoleBD || sameUserBD.ParentLeaderUserID != 801 {
t.Fatalf("same user bd mismatch: %+v", sameUserBD)
if got := xerr.CodeOf(err); got != xerr.Conflict {
t.Fatalf("CreateBDLeader already creates ordinary bd: got %s err=%v", got, err)
}
leaderAfterBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader)
if err != nil || leaderAfterBD.Role != hostdomain.BDRoleLeader || leaderAfterBD.Status != hostdomain.BDStatusActive {
@ -525,13 +690,52 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) {
if disabled.Status != hostdomain.BDStatusDisabled {
t.Fatalf("disabled bd mismatch: %+v", disabled)
}
_, err = svc.InviteBD(ctx, hostservice.InviteBDInput{
disabledInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
CommandID: "invite-disabled-bd-802-again",
InviterUserID: 801,
TargetUserID: 802,
})
if got := xerr.CodeOf(err); got != xerr.Conflict {
t.Fatalf("disabled ordinary bd must not be invited again: got %s err=%v", got, err)
if err != nil {
t.Fatalf("disabled ordinary bd should be invitable again: %v", err)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-disabled-bd-802-again",
ActorUserID: 802,
InvitationID: disabledInvite.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("accept disabled bd invite should restore profile: %v", err)
}
restoredBD, err := svc.GetBDProfile(ctx, 802, hostdomain.BDRoleBD)
if err != nil {
t.Fatalf("GetBDProfile restored disabled bd failed: %v", err)
}
if restoredBD.Status != hostdomain.BDStatusActive || restoredBD.ParentLeaderUserID != 801 {
t.Fatalf("disabled bd invite should restore active under inviter leader: %+v", restoredBD)
}
seedActiveUser(t, repository, 805, 20)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 805,
Role: hostdomain.BDRoleBD,
RegionID: 20,
ParentLeaderUserID: 801,
Status: hostdomain.BDStatusActive,
})
if _, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
CommandID: "admin-create-leader-existing-bd-805",
AdminUserID: 1,
TargetUserID: 805,
Reason: "promote existing bd",
RequestID: "req-leader-existing-bd-1",
}); err != nil {
t.Fatalf("CreateBDLeader for existing bd failed: %v", err)
}
existingBD, err := svc.GetBDProfile(ctx, 805, hostdomain.BDRoleBD)
if err != nil {
t.Fatalf("GetBDProfile existing bd after leader create failed: %v", err)
}
if existingBD.ParentLeaderUserID != 801 {
t.Fatalf("CreateBDLeader must not overwrite active bd parent: %+v", existingBD)
}
disabledSameUserBD, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{
CommandID: "admin-disable-bd-801",
@ -799,6 +1003,8 @@ func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 911, 40)
seedActiveUser(t, repository, 910, 40)
seedActiveUser(t, repository, 912, 40)
seedActiveUser(t, repository, 913, 40)
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 910,
Role: hostdomain.BDRoleBD,
@ -811,6 +1017,22 @@ func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
RegionID: 40,
Source: "test",
})
repository.PutAgency(hostdomain.Agency{
AgencyID: 914,
OwnerUserID: 913,
RegionID: 40,
Status: hostdomain.AgencyStatusActive,
JoinEnabled: true,
})
repository.PutHostProfile(hostdomain.HostProfile{
UserID: 912,
Status: hostdomain.HostStatusActive,
RegionID: 40,
CurrentAgencyID: 914,
CurrentMembershipID: 915,
Source: "existing-membership",
})
seedActiveMembership(t, repository, 915, 914, 912, hostdomain.MembershipTypeMember)
svc := newHostService(repository, 6000, 7000)
created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
@ -827,6 +1049,27 @@ func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.HostProfile.Source != "test" {
t.Fatalf("existing host identity should be reused without rewriting source: %+v", created.HostProfile)
}
boundOwner, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
CommandID: "admin-create-agency-bound-host",
AdminUserID: 1,
OwnerUserID: 912,
ParentBDUserID: 910,
Name: "Bound Host Agency",
JoinEnabled: true,
})
if err != nil {
t.Fatalf("bound host should become agency owner without rebinding host: %v", err)
}
if boundOwner.HostProfile.CurrentAgencyID != 914 || boundOwner.HostProfile.CurrentMembershipID != 915 || boundOwner.Membership.AgencyID != 914 {
t.Fatalf("admin agency create must preserve existing host binding: %+v", boundOwner)
}
boundSummary, err := svc.GetUserRoleSummary(ctx, 912)
if err != nil {
t.Fatalf("GetUserRoleSummary for bound agency owner failed: %v", err)
}
if !boundSummary.IsAgency || !boundSummary.IsHost || boundSummary.AgencyID != boundOwner.Agency.AgencyID {
t.Fatalf("bound host agency owner summary mismatch: %+v agency=%+v", boundSummary, boundOwner.Agency)
}
_, err = svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
CommandID: "admin-create-agency-existing-host-again",
AdminUserID: 1,
@ -1036,13 +1279,22 @@ func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
}); err != nil {
t.Fatalf("CreateCoinSeller failed: %v", err)
}
if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
invite932, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
CommandID: "invite-bd-932",
InviterUserID: 931,
TargetUserID: 932,
}); err != nil {
})
if err != nil {
t.Fatalf("InviteBD failed: %v", err)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-bd-932",
ActorUserID: 932,
InvitationID: invite932.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("accept InviteBD for summary failed: %v", err)
}
summary, err := svc.GetUserRoleSummary(ctx, 931)
if err != nil {
@ -1051,13 +1303,22 @@ func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
if !summary.IsHost || !summary.IsAgency || !summary.IsManager || summary.IsBD || !summary.IsBDLeader || !summary.IsCoinSeller || summary.AgencyID != 933 || summary.BDID != 931 {
t.Fatalf("leader-only role summary mismatch: %+v", summary)
}
if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
selfInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
CommandID: "invite-bd-self-931",
InviterUserID: 931,
TargetUserID: 931,
}); err != nil {
})
if err != nil {
t.Fatalf("self InviteBD for summary failed: %v", err)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-bd-self-931-summary",
ActorUserID: 931,
InvitationID: selfInvite.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("accept self InviteBD for summary failed: %v", err)
}
combinedSummary, err := svc.GetUserRoleSummary(ctx, 931)
if err != nil {
t.Fatalf("GetUserRoleSummary combined failed: %v", err)

View File

@ -0,0 +1,90 @@
package user
import (
"context"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
invitedomain "hyapp/services/user-service/internal/domain/invite"
userdomain "hyapp/services/user-service/internal/domain/user"
)
// SearchInviteReferrer 通过邀请码搜索可绑定邀请人。
// 搜索结果只允许同 App、同国家、已完成资料且 active 的普通用户,避免 H5 枚举跨国家用户资料。
func (s *Service) SearchInviteReferrer(ctx context.Context, userID int64, inviteCode string) (userdomain.User, error) {
inviteCode = invitedomain.NormalizeCode(inviteCode)
if userID <= 0 {
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if !invitedomain.ValidCode(inviteCode) {
// 对外搜索入口只接受合法邀请码;非法输入直接拒绝,不落入 NotFound 分支。
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")
}
current, err := s.userRepository.GetUser(ctx, userID)
if err != nil {
return userdomain.User{}, err
}
if !current.ProfileCompleted {
// 当前账号未完成资料时没有稳定国家边界,不能用这个入口搜索邀请人。
return userdomain.User{}, xerr.New(xerr.ProfileRequired, "profile required")
}
if current.Country == "" {
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "current user country is required")
}
inviter, err := s.userRepository.FindInviteReferrerByCode(ctx, appcode.FromContext(ctx), inviteCode)
if err != nil {
return userdomain.User{}, err
}
if err := validateInviteReferrerPair(current, inviter); err != nil {
return userdomain.User{}, err
}
return s.refreshExpiredUser(ctx, inviter, "")
}
// BindInviteReferrer 原子绑定当前用户的邀请人。
// H5 只能传用户输入的邀请码;归属用户由 repository 在事务内重新解析并复核。
func (s *Service) BindInviteReferrer(ctx context.Context, userID int64, inviteCode string, requestID string) (userdomain.User, error) {
inviteCode = invitedomain.NormalizeCode(inviteCode)
if userID <= 0 {
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if !invitedomain.ValidCode(inviteCode) {
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")
}
return s.userRepository.BindInviteReferrer(ctx, userdomain.BindInviteReferrerCommand{
AppCode: appcode.FromContext(ctx),
InvitedUserID: userID,
InviteCode: inviteCode,
RequestID: strings.TrimSpace(requestID),
BoundAtMs: s.now().UnixMilli(),
})
}
func validateInviteReferrerPair(invited userdomain.User, inviter userdomain.User) error {
if invited.UserID <= 0 || inviter.UserID <= 0 {
return xerr.New(xerr.NotFound, "inviter not found")
}
if invited.UserID == inviter.UserID {
// 自邀不进入邀请码解析,避免把当前用户自己的邀请码可用性暴露出去。
return xerr.New(xerr.InvalidInviteCode, "invalid invite code")
}
if !inviter.ProfileCompleted || !inviter.CanLogin() {
return xerr.New(xerr.NotFound, "inviter not found")
}
if invited.Country == "" || inviter.Country == "" || invited.Country != inviter.Country {
// 产品要求只能搜索和绑定本国家邀请人;跨国家统一返回 not found避免泄漏目标用户是否存在。
return xerr.New(xerr.NotFound, "inviter not found")
}
return nil
}

View File

@ -211,6 +211,10 @@ func (r *fakeModerationRepository) BatchGetUsers(context.Context, []int64) (map[
return nil, nil
}
func (r *fakeModerationRepository) FindInviteReferrerByCode(context.Context, string, string) (userdomain.User, error) {
return r.user, nil
}
func (r *fakeModerationRepository) ListUserIDs(context.Context, userdomain.UserIDPageFilter) ([]int64, error) {
return nil, nil
}
@ -229,10 +233,19 @@ func (r *fakeModerationRepository) UpdateUserProfileBackground(_ context.Context
return r.user, nil
}
func (r *fakeModerationRepository) UpdateUserContactInfo(_ context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error) {
r.user.ContactInfo = command.ContactInfo
return r.user, nil
}
func (r *fakeModerationRepository) CompleteOnboarding(context.Context, userdomain.CompleteOnboardingCommand) (userdomain.User, error) {
return r.user, nil
}
func (r *fakeModerationRepository) BindInviteReferrer(context.Context, userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
return r.user, nil
}
func (r *fakeModerationRepository) ChangeUserCountry(_ context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error) {
r.lastCountryCommand = command
r.countryChangeCalls++

View File

@ -112,6 +112,34 @@ func (s *Service) UpdateUserProfileBackground(ctx context.Context, userID int64,
})
}
// UpdateUserContactInfo 修改用户级联系方式Host、BD、Agency owner 和币商中心共用这一个字段展示。
func (s *Service) UpdateUserContactInfo(ctx context.Context, userID int64, contactInfo string) (userdomain.User, error) {
if userID <= 0 {
// 联系方式只能改当前登录用户或后台已解析出的真实用户,非法 user_id 直接拒绝,避免落到空用户。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.userRepository == nil {
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
if err := s.requireCompletedProfile(ctx, userID); err != nil {
// 未完成注册资料的账号不能先写经营联系方式,否则后台会看到一个仍不可进入业务的半成品用户。
return userdomain.User{}, err
}
contactInfo = strings.TrimSpace(contactInfo)
if userdomain.ProfileStringTooLong(contactInfo, userdomain.ProfileContactInfoMaxRunes) {
// users.contact_info 是短展示字段;提前拦截超长输入,避免 MySQL 截断造成 H5 显示值和用户输入不一致。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "contact_info is too long")
}
return s.userRepository.UpdateUserContactInfo(ctx, userdomain.ProfileContactUpdateCommand{
AppCode: appcode.FromContext(ctx),
UserID: userID,
ContactInfo: contactInfo,
UpdatedAtMs: s.now().UnixMilli(),
})
}
// 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, inviteCode string, requestID string) (userdomain.User, error) {

View File

@ -163,3 +163,35 @@ func TestUpdateUserProfileBackgroundRejectsInvalidProfileBgImg(t *testing.T) {
t.Fatalf("invalid profile_bg_img must be rejected, err=%v", err)
}
}
func TestUpdateUserContactInfoPersistsUserContact(t *testing.T) {
repository := &fakeModerationRepository{user: userdomain.User{
AppCode: "lalu",
UserID: 10001,
ProfileCompleted: true,
}}
svc := New(repository)
updated, err := svc.UpdateUserContactInfo(appcode.WithContext(context.Background(), "lalu"), 10001, " +65 1234 5678 ")
if err != nil {
t.Fatalf("UpdateUserContactInfo failed: %v", err)
}
if updated.ContactInfo != "+65 1234 5678" {
t.Fatalf("updated contact mismatch: %+v", updated)
}
}
func TestUpdateUserContactInfoRejectsTooLong(t *testing.T) {
repository := &fakeModerationRepository{user: userdomain.User{
AppCode: "lalu",
UserID: 10001,
ProfileCompleted: true,
}}
svc := New(repository)
_, err := svc.UpdateUserContactInfo(appcode.WithContext(context.Background(), "lalu"), 10001, strings.Repeat("1", userdomain.ProfileContactInfoMaxRunes+1))
if xerr.CodeOf(err) != xerr.InvalidArgument {
t.Fatalf("too long contact_info must be rejected, err=%v", err)
}
}

View File

@ -47,6 +47,8 @@ type UserRepository interface {
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
// GetInviteAttribution 按被邀请用户读取邀请归因快照;无关系时返回 Found=false。
GetInviteAttribution(ctx context.Context, invitedUserID int64) (invitedomain.Attribution, error)
// FindInviteReferrerByCode 按 active 邀请码读取归属用户,供 H5 绑定页搜索同国家邀请人。
FindInviteReferrerByCode(ctx context.Context, appCode string, inviteCode string) (userdomain.User, error)
// ListUserIDs 按稳定 user_id 游标读取低频后台任务目标用户。
ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error)
// CreateUserWithIdentity 原子创建用户和默认短号。
@ -55,8 +57,12 @@ type UserRepository interface {
UpdateUserProfile(ctx context.Context, command userdomain.ProfileUpdateCommand) (userdomain.User, error)
// UpdateUserProfileBackground 原子修改个人信息页背景图。
UpdateUserProfileBackground(ctx context.Context, command userdomain.ProfileBackgroundUpdateCommand) (userdomain.User, error)
// UpdateUserContactInfo 原子修改用户级联系方式。
UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error)
// CompleteOnboarding 原子写注册页必填资料并标记 profile_completed。
CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error)
// BindInviteReferrer 原子绑定当前用户的邀请人,并在事务内复核双方国家一致。
BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error)
// ChangeUserCountry 原子修改用户国家、写变更日志,并返回需要立即失效的 auth session。
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error)
}

View File

@ -161,6 +161,35 @@ func (r *Repository) ListEnabledLoginRiskIPWhitelist(ctx context.Context) ([]aut
return items, nil
}
func (r *Repository) ListEnabledLoginRiskUserWhitelist(ctx context.Context) ([]authdomain.LoginRiskUserWhitelist, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, whitelist_id, user_id, enabled, created_at_ms, updated_at_ms
FROM login_risk_user_whitelist
WHERE app_code = ? AND enabled = 1
ORDER BY user_id ASC, whitelist_id ASC
`, appcode.FromContext(ctx))
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]authdomain.LoginRiskUserWhitelist, 0)
for rows.Next() {
var item authdomain.LoginRiskUserWhitelist
if err := rows.Scan(&item.AppCode, &item.WhitelistID, &item.UserID, &item.Enabled, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
return nil, err
}
if item.UserID <= 0 {
continue
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
type loginIPRiskJobScanner interface {
Scan(dest ...any) error
}

View File

@ -34,29 +34,26 @@ func (r *Repository) CreateBDLeader(ctx context.Context, command hostservice.Cre
if err := requireActiveRegion(ctx, tx, regionID); err != nil {
return hostdomain.BDProfile{}, err
}
if _, ok, err := queryBDLeaderProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil {
// admin 和经理入口都是强制开通:已有 active Leader 直接复用disabled Leader 恢复 active。
// 开通 Leader 后再补齐普通 BD 身份;已有 active BD 时绝不覆盖原 parent_leader_user_id。
profile, leaderEvent, err := ensureActiveBDLeaderProfile(ctx, tx, command.TargetUserID, regionID, command.AdminUserID, command.NowMs)
if err != nil {
return hostdomain.BDProfile{}, err
} else if ok {
return hostdomain.BDProfile{}, xerr.New(xerr.Conflict, "target already has bd leader profile")
}
profile := hostdomain.BDProfile{
UserID: command.TargetUserID,
Role: hostdomain.BDRoleLeader,
RegionID: regionID,
Status: hostdomain.BDStatusActive,
CreatedByUserID: command.AdminUserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := insertBDLeaderProfile(ctx, tx, profile); err != nil {
return hostdomain.BDProfile{}, mapHostDuplicateError(err)
if _, bdEvent, err := ensureSelfBDProfileForLeader(ctx, tx, command.TargetUserID, regionID, command.AdminUserID, command.NowMs); err != nil {
return hostdomain.BDProfile{}, err
} else if bdEvent != "" {
if err := insertHostOutbox(ctx, tx, command.EventID+1, bdEvent, "bd_profile", command.TargetUserID, command.NowMs); err != nil {
return hostdomain.BDProfile{}, err
}
}
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeCreateBDLeader, ResultType: hostdomain.ResultTypeBDProfile, ResultID: profile.UserID, CreatedAtMs: command.NowMs}); err != nil {
return hostdomain.BDProfile{}, mapHostDuplicateError(err)
}
if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", profile.UserID, command.NowMs); err != nil {
return hostdomain.BDProfile{}, err
if leaderEvent != "" {
if err := insertHostOutbox(ctx, tx, command.EventID, leaderEvent, "bd_profile", profile.UserID, command.NowMs); err != nil {
return hostdomain.BDProfile{}, err
}
}
if err := tx.Commit(); err != nil {
return hostdomain.BDProfile{}, err
@ -313,10 +310,9 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
} else if ok {
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.Conflict, "owner already has active agency")
}
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.OwnerUserID, true); err != nil {
activeMembership, hasActiveMembership, err := queryActiveMembershipByHost(ctx, tx, command.OwnerUserID, true)
if err != nil {
return hostdomain.CreateAgencyResult{}, err
} else if ok {
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.Conflict, "owner already has active agency membership")
}
// max_hosts 是历史兼容字段,当前业务不再限制主播数量,新建 Agency 固化为 0。
@ -324,18 +320,33 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
if err := insertAgency(ctx, tx, agency); err != nil {
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
}
hostProfile, created, err := ensureHostProfileForMembership(ctx, tx, command.OwnerUserID, regionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAdminCreateAgency, command.NowMs)
if err != nil {
return hostdomain.CreateAgencyResult{}, err
}
membership := hostdomain.AgencyMembership{MembershipID: command.MembershipID, AgencyID: agency.AgencyID, HostUserID: command.OwnerUserID, RegionID: regionID, MembershipType: hostdomain.MembershipTypeOwner, Status: hostdomain.MembershipStatusActive, JoinedAtMs: command.NowMs, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs}
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
var hostProfile hostdomain.HostProfile
var membership hostdomain.AgencyMembership
var hostProfileChanged bool
var membershipCreated bool
if hasActiveMembership {
// owner 已经是其他 Agency 的 active Host 时,只开启 Agency owner 身份;
// Host 当前归属继续指向原 membership避免覆盖既有 Agency 关系。
hostProfile, hostProfileChanged, err = ensureHostProfileForExistingMembership(ctx, tx, command.OwnerUserID, regionID, activeMembership, hostdomain.HostSourceAdminCreateAgency, command.NowMs)
if err != nil {
return hostdomain.CreateAgencyResult{}, err
}
membership = activeMembership
} else {
hostProfile, hostProfileChanged, err = ensureHostProfileForMembership(ctx, tx, command.OwnerUserID, regionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAdminCreateAgency, command.NowMs)
if err != nil {
return hostdomain.CreateAgencyResult{}, err
}
membership = hostdomain.AgencyMembership{MembershipID: command.MembershipID, AgencyID: agency.AgencyID, HostUserID: command.OwnerUserID, RegionID: regionID, MembershipType: hostdomain.MembershipTypeOwner, Status: hostdomain.MembershipStatusActive, JoinedAtMs: command.NowMs, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs}
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
}
membershipCreated = true
}
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeCreateAgency, ResultType: hostdomain.ResultTypeAgency, ResultID: agency.AgencyID, CreatedAtMs: command.NowMs}); err != nil {
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
}
if created {
if hostProfileChanged {
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", hostProfile.UserID, command.NowMs); err != nil {
return hostdomain.CreateAgencyResult{}, err
}
@ -343,8 +354,10 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", agency.AgencyID, command.NowMs); err != nil {
return hostdomain.CreateAgencyResult{}, err
}
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil {
return hostdomain.CreateAgencyResult{}, err
if membershipCreated {
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil {
return hostdomain.CreateAgencyResult{}, err
}
}
if err := tx.Commit(); err != nil {
return hostdomain.CreateAgencyResult{}, err
@ -542,9 +555,11 @@ func createAgencyResultByAgencyID(ctx context.Context, q sqlQueryer, agencyID in
if err != nil {
return hostdomain.CreateAgencyResult{}, err
}
membership, err := queryAgencyMembership(ctx, q, "WHERE agency_id = ? AND host_user_id = ? AND membership_type = ? AND status = ?", agency.AgencyID, agency.OwnerUserID, hostdomain.MembershipTypeOwner, hostdomain.MembershipStatusActive)
if err != nil {
return hostdomain.CreateAgencyResult{}, err
var membership hostdomain.AgencyMembership
if hostProfile.CurrentMembershipID > 0 {
// 新规则允许 Agency owner 已经是其他 Agency 的 Host 成员;
// 幂等回放返回用户当前 Host 归属,而不是强行要求新 Agency 存在 owner membership。
membership, _ = queryAgencyMembership(ctx, q, "WHERE membership_id = ?", hostProfile.CurrentMembershipID)
}
return hostdomain.CreateAgencyResult{Agency: agency, HostProfile: hostProfile, Membership: membership}, nil
}

View File

@ -47,13 +47,8 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
if targetRegionID != inviter.RegionID {
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match inviter region")
}
// Agency 拥有者必须没有当前有效 Agency 归属;已有 Host 身份但未归属 Agency 时可以复用身份。
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has active agency membership")
}
// 一个用户只能拥有一个有效 Agency这里先拦截最终仍依赖唯一索引兜底并发。
// Agency owner 和 Host 成员归属是两套身份;目标已有 active Host 绑定时仍允许成为 Agency owner
// 但接受邀请时不会覆盖原 Host 当前归属。一个用户只能拥有一个有效 Agency这里仍先行拦截。
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
@ -138,9 +133,9 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
if targetRegionID != inviter.RegionID {
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region")
}
// 目标已有普通 BD 身份行时不再创建新邀请,即使已停用也不能再插入第二条普通 BD 事实。
// BD Leader 自邀也必须新增普通 BD 行,后续才能获得 BD_SALARY_USD 钱包鉴权。
if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil {
// 只有 active 普通 BD 才算已有权限disabled 行可以重新发邀请,
// 接受时会复用原主键行恢复 active避免历史停用记录阻断重新授权。
if _, ok, err := queryBDProfileMaybe(ctx, tx, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has bd profile")
@ -422,13 +417,13 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
invitation.Status = hostdomain.InvitationStatusAccepted
if invitation.InvitationType == hostdomain.InvitationTypeAgency {
// Agency 邀请的接受会一次性创建 Agency 拥有者身份、Agency 和拥有者成员关系。
hostProfileCreated, err := r.acceptAgencyInvitation(ctx, tx, command, &invitation, &result)
hostProfileChanged, membershipCreated, err := r.acceptAgencyInvitation(ctx, tx, command, &invitation, &result)
if err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
// 事件 ID 在业务层预分配;这里按固定偏移写多条事件箱记录,
// 让同一个接受事务中的派生事件保持可追踪顺序。
if hostProfileCreated {
if hostProfileChanged {
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", invitation.TargetUserID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
@ -436,8 +431,10 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", command.AgencyID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
if membershipCreated {
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
}
} else if invitation.InvitationType == hostdomain.InvitationTypeBD {
// BD 邀请的接受只创建 bd_profileBD 不需要 host_profile。
@ -486,31 +483,31 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
return result, nil
}
func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) {
func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, bool, error) {
// 邀请可能放置一段时间后才被接受,所以目标用户和邀请 BD 都用 users 当前区域重新对齐。
// 邀请表不再保存区域快照,旧库残留的 region_id 也不能参与是否可接受的业务判断。
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
if err != nil {
return false, err
return false, false, err
}
inviterRegionID, err := userRegionValue(ctx, tx, invitation.InviterBDUserID, "")
if err != nil {
return false, err
return false, false, err
}
if targetRegionID != inviterRegionID {
return false, xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
return false, false, xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
}
// 目标用户在邀请创建后可能已经加入其他 Agency接受前必须再次阻断有效归属
if _, ok, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true); err != nil {
return false, err
} else if ok {
return false, xerr.New(xerr.Conflict, "target already has active agency membership")
// 目标已有 Host 绑定时保留绑定,不再把 Host 当前归属切到自己新建的 Agency
// 这里仍锁住 active membership避免接受邀请期间另一条入会流程改写当前关系。
activeMembership, hasActiveMembership, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true)
if err != nil {
return false, false, err
}
// 同一用户不能拥有多个有效 Agency这里和唯一索引共同处理并发接受。
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, invitation.TargetUserID, true); err != nil {
return false, err
return false, false, err
} else if ok {
return false, xerr.New(xerr.Conflict, "target already owns active agency")
return false, false, xerr.New(xerr.Conflict, "target already owns active agency")
}
// Agency、拥有者 host_profile、拥有者成员关系是同一业务事实的三张表投影
@ -530,11 +527,23 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
UpdatedAtMs: command.NowMs,
}
if err := insertAgency(ctx, tx, agency); err != nil {
return false, mapHostDuplicateError(err)
return false, false, mapHostDuplicateError(err)
}
if hasActiveMembership {
// 已有 active Host 绑定时只恢复/修正 host_profile 指向原 membership不插入 owner membership
// 防止同一 Host 同时拥有两条 active agency_memberships。
hostProfile, hostProfileChanged, err := ensureHostProfileForExistingMembership(ctx, tx, invitation.TargetUserID, targetRegionID, activeMembership, hostdomain.HostSourceAgencyInvitation, command.NowMs)
if err != nil {
return false, false, err
}
result.HostProfile = hostProfile
result.Agency = agency
result.Membership = activeMembership
return hostProfileChanged, false, nil
}
hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAgencyInvitation, command.NowMs)
if err != nil {
return false, err
return false, false, err
}
membership := hostdomain.AgencyMembership{
MembershipID: command.MembershipID,
@ -548,12 +557,12 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
UpdatedAtMs: command.NowMs,
}
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
return false, mapHostDuplicateError(err)
return false, false, mapHostDuplicateError(err)
}
result.HostProfile = hostProfile
result.Agency = agency
result.Membership = membership
return hostProfileCreated, nil
return hostProfileCreated, true, nil
}
func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) {
@ -585,7 +594,7 @@ func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, comma
return false, xerr.New(xerr.Conflict, "target already owns active agency")
}
hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAgencyInvitation, command.NowMs)
hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceHostInvitation, command.NowMs)
if err != nil {
return false, err
}
@ -622,11 +631,22 @@ func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command
if targetRegionID != leaderRegionID {
return xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
}
// BD 身份是独立角色事实,不依赖 host_profile任何已有普通 BD 行都不能重复插入。
if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil {
// BD 身份是独立角色事实,不依赖 host_profileactive BD 不能被覆盖,
// disabled BD 不算当前权限,接受邀请时恢复并绑定到本次 Leader。
if existing, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil {
return err
} else if ok {
return xerr.New(xerr.Conflict, "target already has bd profile")
if existing.Status == hostdomain.BDStatusActive {
return xerr.New(xerr.Conflict, "target already has bd profile")
}
existing.ParentLeaderUserID = invitation.ParentLeaderUserID
existing.Status = hostdomain.BDStatusActive
existing.UpdatedAtMs = command.NowMs
if err := updateBDProfileForActivation(ctx, tx, existing); err != nil {
return err
}
result.BDProfile = existing
return nil
}
bd := hostdomain.BDProfile{
UserID: invitation.TargetUserID,

View File

@ -183,6 +183,7 @@ func (r *Repository) ListActiveCoinSellersInMyRegion(ctx context.Context, userID
COALESCE(region.name, ''),
seller.status,
seller.merchant_asset_type,
COALESCE(NULLIF(u.contact_info, ''), seller.contact_info, ''),
seller.updated_at_ms
FROM users u
INNER JOIN coin_seller_profiles seller
@ -220,6 +221,7 @@ func (r *Repository) ListActiveCoinSellersInMyRegion(ctx context.Context, userID
&item.RegionName,
&item.Status,
&item.MerchantAssetType,
&item.ContactInfo,
&item.UpdatedAtMs,
); err != nil {
return nil, err
@ -235,8 +237,6 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
app := appcode.FromContext(ctx)
summary := hostdomain.UserRoleSummary{UserID: userID}
var hostStatus string
var hostSource string
var hostAgencyID int64
var ownerAgencyID int64
var bdUserID int64
var bdStatus string
@ -249,8 +249,6 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
err := r.db.QueryRowContext(ctx, `
SELECT
COALESCE(h.status, ''),
COALESCE(h.source, ''),
COALESCE(h.current_agency_id, 0),
COALESCE(a.agency_id, 0),
COALESCE(b.user_id, 0),
COALESCE(b.status, ''),
@ -279,21 +277,20 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
app,
app, hostdomain.ManagerStatusActive,
app,
).Scan(&hostStatus, &hostSource, &hostAgencyID, &ownerAgencyID, &bdUserID, &bdStatus, &leaderUserID, &leaderStatus, &managerUserID, &coinSellerStatus, &merchantAssetType)
).Scan(&hostStatus, &ownerAgencyID, &bdUserID, &bdStatus, &leaderUserID, &leaderStatus, &managerUserID, &coinSellerStatus, &merchantAssetType)
if err != nil {
return hostdomain.UserRoleSummary{}, err
}
summary.HostStatus = hostStatus
summary.IsHost = hostStatus == hostdomain.HostStatusActive
if ownerAgencyID > 0 {
// Agency 身份只来自 active Agency owner 行;普通 Host 即使有 current_agency_id也只是成员归属不能暴露 Agency Center。
summary.IsAgency = ownerAgencyID > 0
if summary.IsAgency {
summary.AgencyID = ownerAgencyID
} else {
summary.AgencyID = hostAgencyID
}
// 经理身份来自独立 manager_profiles 表Agency owner、Host、BD 或 BD Leader 身份都不会隐式获得经理中心入口。
summary.IsManager = managerUserID > 0
summary.IsAgency = ownerAgencyID > 0 || (summary.IsHost && (hostSource == hostdomain.HostSourceAgencyInvitation || hostSource == hostdomain.HostSourceAdminCreateAgency))
summary.BDStatus = bdStatus
if bdUserID > 0 && bdStatus == hostdomain.BDStatusActive {
summary.BDID = bdUserID
@ -323,7 +320,7 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
}
// HasActiveAgencyOwner 精确判断用户是否为 active Agency owner。
// 普通 Agency 成员会在 UserRoleSummary 中有 Agency 入口,但不能获得经理中心资源赠送权限
// 普通 Agency 成员只保留 host_profile.current_agency_id 归属,不会在 UserRoleSummary 中获得 Agency 入口
func (r *Repository) HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error) {
agency, ok, err := queryActiveAgencyByOwner(ctx, r.db, userID, false)
if err != nil || !ok {

View File

@ -81,7 +81,14 @@ func ensureHostProfileForMembership(ctx context.Context, tx *sql.Tx, userID int6
return hostdomain.HostProfile{}, false, err
}
if profile.Status != hostdomain.HostStatusActive {
return hostdomain.HostProfile{}, false, xerr.New(xerr.Conflict, "host profile is not active")
// disabled 不再算有效 Host 权限;当用户通过邀请或后台开通重新获得 Host 身份时,
// 复用原主键行并挂到本次新 membership避免因为历史停用记录导致身份补齐失败。
profile.Status = hostdomain.HostStatusActive
profile.RegionID = regionID
profile.CurrentAgencyID = agencyID
profile.CurrentMembershipID = membershipID
profile.UpdatedAtMs = nowMs
return profile, true, updateHostProfileForMembership(ctx, tx, profile)
}
if profile.CurrentMembershipID > 0 {
// current_membership 是为“我的 Agency”快速读取做的冗余非 0 表示已经挂载。
@ -95,6 +102,39 @@ func ensureHostProfileForMembership(ctx context.Context, tx *sql.Tx, userID int6
return profile, false, updateHostCurrentMembership(ctx, tx, profile)
}
func ensureHostProfileForExistingMembership(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, membership hostdomain.AgencyMembership, source string, nowMs int64) (hostdomain.HostProfile, bool, error) {
profile, ok, err := queryHostProfileByUserMaybe(ctx, tx, userID, true)
if err != nil {
return hostdomain.HostProfile{}, false, err
}
if !ok {
// 极端历史数据可能只有 active membership 没有 host_profile这里补回 Host 身份,
// 但指针仍指向已有 membership绝不把用户重新绑定到新创建的 Agency。
profile = hostdomain.HostProfile{
UserID: userID,
Status: hostdomain.HostStatusActive,
RegionID: regionID,
CurrentAgencyID: membership.AgencyID,
CurrentMembershipID: membership.MembershipID,
Source: source,
FirstBecameHostAtMs: nowMs,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
return profile, true, insertHostProfile(ctx, tx, profile)
}
changed := profile.Status != hostdomain.HostStatusActive || profile.CurrentAgencyID != membership.AgencyID || profile.CurrentMembershipID != membership.MembershipID
profile.Status = hostdomain.HostStatusActive
profile.RegionID = regionID
profile.CurrentAgencyID = membership.AgencyID
profile.CurrentMembershipID = membership.MembershipID
profile.UpdatedAtMs = nowMs
if !changed {
return profile, false, nil
}
return profile, true, updateHostProfileForMembership(ctx, tx, profile)
}
func insertHostProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
// 这里只用于首次创建身份;成员关系变化走 updateHostCurrentMembership。
_, err := tx.ExecContext(ctx, `
@ -117,6 +157,17 @@ func updateHostCurrentMembership(ctx context.Context, tx *sql.Tx, profile hostdo
return err
}
func updateHostProfileForMembership(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
// 恢复 disabled Host 或修复历史指针时,需要同时写 status 和当前归属;
// source/first_became_host_at_ms 仍然保留首次成为 Host 的历史语义。
_, err := tx.ExecContext(ctx, `
UPDATE host_profiles
SET status = ?, current_agency_id = ?, current_membership_id = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, profile.Status, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func updateUserRegion(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, nowMs int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE users
@ -179,6 +230,17 @@ func updateBDProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.B
return err
}
func updateBDProfileForActivation(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// disabled 普通 BD 被重新授予权限时才允许重写父级 Leader
// active BD 的 parent 代表既有业务关系,任何补齐逻辑都不能覆盖它。
_, err := tx.ExecContext(ctx, `
UPDATE bd_profiles
SET parent_leader_user_id = ?, status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND role = 'bd'
`, nullableRegionID(profile.ParentLeaderUserID), profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// Leader 状态只影响负责人权限和 ADMIN_SALARY_USD 钱包入口,不影响同用户可能存在的普通 BD 身份。
_, err := tx.ExecContext(ctx, `
@ -189,6 +251,75 @@ func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdo
return err
}
func ensureActiveBDLeaderProfile(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, createdByUserID int64, nowMs int64) (hostdomain.BDProfile, string, error) {
profile, ok, err := queryBDLeaderProfileByUserMaybe(ctx, tx, userID, true)
if err != nil {
return hostdomain.BDProfile{}, "", err
}
if !ok {
// 直开 BD Leader 时,负责人身份先成为 active 事实;普通 BD 钱包/工资身份由后续 helper 补齐。
profile = hostdomain.BDProfile{
UserID: userID,
Role: hostdomain.BDRoleLeader,
RegionID: regionID,
Status: hostdomain.BDStatusActive,
CreatedByUserID: createdByUserID,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
if err := insertBDLeaderProfile(ctx, tx, profile); err != nil {
return hostdomain.BDProfile{}, "", mapHostDuplicateError(err)
}
return profile, "BDCreated", nil
}
if profile.Status == hostdomain.BDStatusActive {
// 已经是 active Leader 时只回放当前事实,不重写 created_by也不重复发状态事件。
return profile, "", nil
}
profile.Status = hostdomain.BDStatusActive
profile.UpdatedAtMs = nowMs
if err := updateBDLeaderProfileStatus(ctx, tx, profile); err != nil {
return hostdomain.BDProfile{}, "", err
}
return profile, "BDStatusChanged", nil
}
func ensureSelfBDProfileForLeader(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, createdByUserID int64, nowMs int64) (hostdomain.BDProfile, string, error) {
profile, ok, err := queryBDProfileByUserMaybe(ctx, tx, userID, true)
if err != nil {
return hostdomain.BDProfile{}, "", err
}
if !ok {
// BD Leader 需要普通 BD 身份承载下级 Agency/工资等 BD 体系能力;
// 首次补齐时把 parent_leader_user_id 指向自己,形成自有 BD Leader 体系。
profile = hostdomain.BDProfile{
UserID: userID,
Role: hostdomain.BDRoleBD,
RegionID: regionID,
ParentLeaderUserID: userID,
Status: hostdomain.BDStatusActive,
CreatedByUserID: createdByUserID,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
if err := insertBDProfile(ctx, tx, profile); err != nil {
return hostdomain.BDProfile{}, "", mapHostDuplicateError(err)
}
return profile, "BDCreated", nil
}
if profile.Status == hostdomain.BDStatusActive {
// 已有 active 普通 BD 说明用户已经在某个 BD 关系里;开通 Leader 不能覆盖原 parent。
return profile, "", nil
}
profile.ParentLeaderUserID = userID
profile.Status = hostdomain.BDStatusActive
profile.UpdatedAtMs = nowMs
if err := updateBDProfileForActivation(ctx, tx, profile); err != nil {
return hostdomain.BDProfile{}, "", err
}
return profile, "BDStatusChanged", nil
}
func insertCoinSellerProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.CoinSellerProfile) error {
// 币商身份是独立角色事实;账户余额属于 wallet-service不在此表冗余。
_, err := tx.ExecContext(ctx, `

View File

@ -106,6 +106,7 @@ const userSelectColumns = `
COALESCE(os_version, ''),
COALESCE(avatar, ''),
COALESCE(profile_bg_img, ''),
COALESCE(contact_info, ''),
COALESCE(DATE_FORMAT(birth_date, '%Y-%m-%d'), ''),
COALESCE(app_version, ''),
COALESCE(build_number, ''),
@ -173,6 +174,7 @@ func ScanUser(scanner interface {
&user.RegisterOSVersion,
&user.Avatar,
&user.ProfileBgImg,
&user.ContactInfo,
&user.BirthDate,
&user.RegisterAppVersion,
&user.RegisterBuildNumber,
@ -246,6 +248,7 @@ func InsertUserIdentity(ctx context.Context, tx *sql.Tx, user userdomain.User, i
os_version,
avatar,
profile_bg_img,
contact_info,
birth_date,
app_version,
build_number,
@ -264,10 +267,10 @@ 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), profileBgImg, 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)
`, 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), profileBgImg, strings.TrimSpace(user.ContactInfo), 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 {
return err
}

View File

@ -0,0 +1,225 @@
package user
import (
"context"
"database/sql"
"fmt"
"strings"
"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"
)
// FindInviteReferrerByCode 按 active 邀请码读取归属用户,搜索阶段只做只读解析。
func (r *Repository) FindInviteReferrerByCode(ctx context.Context, appCode string, inviteCode string) (userdomain.User, error) {
if r == nil || r.db == nil {
return userdomain.User{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode = appcode.Normalize(appCode)
inviteCode = invitedomain.NormalizeCode(inviteCode)
if !invitedomain.ValidCode(inviteCode) {
return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
}
row := r.db.QueryRowContext(ctx, `
SELECT `+userSelectColumns+`
FROM users
INNER JOIN user_invite_codes codes
ON codes.app_code = users.app_code
AND codes.owner_user_id = users.user_id
WHERE users.app_code = ?
AND codes.code = ?
AND codes.status = ?
LIMIT 1
`, appCode, inviteCode, invitedomain.CodeStatusActive)
inviter, err := ScanUser(row)
if err == sql.ErrNoRows {
return userdomain.User{}, xerr.New(xerr.NotFound, "inviter not found")
}
if err != nil {
return userdomain.User{}, err
}
return inviter, nil
}
// BindInviteReferrer 在资料完成后绑定当前用户的邀请人。
// 这里必须锁双方 users 行并在同一事务内复核国家,不能只信 H5 先前搜索结果。
func (r *Repository) BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
if r == nil || r.db == nil {
return userdomain.User{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.InviteCode = invitedomain.NormalizeCode(command.InviteCode)
command.RequestID = strings.TrimSpace(command.RequestID)
if command.InvitedUserID <= 0 || !invitedomain.ValidCode(command.InviteCode) {
return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userdomain.User{}, err
}
defer tx.Rollback()
inviterUserID, err := inviteReferrerOwnerUserID(ctx, tx, command.AppCode, command.InviteCode)
if err != nil {
return userdomain.User{}, err
}
if command.InvitedUserID == inviterUserID {
return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
}
invited, inviter, err := lockInviteReferrerUsers(ctx, tx, command.AppCode, command.InvitedUserID, inviterUserID)
if err != nil {
return userdomain.User{}, err
}
if err := validateLockedInviteReferrerPair(invited, inviter); err != nil {
return userdomain.User{}, err
}
if existing, exists, err := inviteRelationForInvited(ctx, tx, command.AppCode, command.InvitedUserID); err != nil {
return userdomain.User{}, err
} else if exists {
if invitedomain.NormalizeCode(existing.InviteCode) != command.InviteCode {
// 邀请归因是一次性事实;已绑定其他邀请人时不能被 H5 改写。
return userdomain.User{}, xerr.New(xerr.Conflict, "invite referrer already bound")
}
if err := fillUserInviteCodeSnapshot(ctx, tx, command, existing.InviteCode); err != nil {
return userdomain.User{}, err
}
if err := tx.Commit(); err != nil {
return userdomain.User{}, err
}
updated, err := r.GetUser(ctx, command.InvitedUserID)
updated.InviteBinding = invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}
return updated, err
}
binding, err := invitestorage.BindRelation(ctx, tx, invitedomain.BindCommand{
AppCode: command.AppCode,
InvitedUserID: command.InvitedUserID,
InvitedRegionID: invited.RegionID,
InviteCode: command.InviteCode,
Source: "bind_invite_referrer",
RequestID: command.RequestID,
BoundAtMs: command.BoundAtMs,
})
if err != nil {
return userdomain.User{}, err
}
if binding.InviterUserID != inviter.UserID || invitedomain.NormalizeCode(binding.InviteCode) != command.InviteCode {
// 并发下如果另一个事务先绑定了不同邀请人,保持一次性归因语义并让调用方刷新状态。
return userdomain.User{}, xerr.New(xerr.Conflict, "invite referrer already bound")
}
if err := fillUserInviteCodeSnapshot(ctx, tx, command, binding.InviteCode); err != nil {
return userdomain.User{}, err
}
if err := tx.Commit(); err != nil {
return userdomain.User{}, err
}
updated, err := r.GetUser(ctx, command.InvitedUserID)
updated.InviteBinding = binding
return updated, err
}
func inviteReferrerOwnerUserID(ctx context.Context, tx *sql.Tx, appCode string, inviteCode string) (int64, error) {
var ownerUserID int64
err := tx.QueryRowContext(ctx, `
SELECT owner_user_id
FROM user_invite_codes
WHERE app_code = ? AND code = ? AND status = ?
LIMIT 1
`, appCode, inviteCode, invitedomain.CodeStatusActive).Scan(&ownerUserID)
if err == sql.ErrNoRows {
return 0, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
}
if err != nil {
return 0, err
}
return ownerUserID, nil
}
func lockInviteReferrerUsers(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64, inviterUserID int64) (userdomain.User, userdomain.User, error) {
rows, err := tx.QueryContext(ctx, fmt.Sprintf(`
SELECT %s
FROM users
WHERE app_code = ? AND user_id IN (?, ?)
ORDER BY user_id ASC
FOR UPDATE
`, userSelectColumns), appCode, invitedUserID, inviterUserID)
if err != nil {
return userdomain.User{}, userdomain.User{}, err
}
defer rows.Close()
users := make(map[int64]userdomain.User, 2)
for rows.Next() {
user, err := ScanUser(rows)
if err != nil {
return userdomain.User{}, userdomain.User{}, err
}
users[user.UserID] = user
}
if err := rows.Err(); err != nil {
return userdomain.User{}, userdomain.User{}, err
}
invited, invitedOK := users[invitedUserID]
inviter, inviterOK := users[inviterUserID]
if !invitedOK || !inviterOK {
return userdomain.User{}, userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
}
return invited, inviter, nil
}
func validateLockedInviteReferrerPair(invited userdomain.User, inviter userdomain.User) error {
if !invited.ProfileCompleted {
return xerr.New(xerr.ProfileRequired, "profile required")
}
if !invited.CanLogin() {
return xerr.New(xerr.PermissionDenied, "permission denied")
}
if !inviter.ProfileCompleted || !inviter.CanLogin() {
return xerr.New(xerr.NotFound, "inviter not found")
}
if invited.Country == "" || inviter.Country == "" || invited.Country != inviter.Country {
// 绑定入口和搜索入口都必须执行同国家限制,避免直接 POST 邀请码绕过搜索态校验。
return xerr.New(xerr.NotFound, "inviter not found")
}
return nil
}
func inviteRelationForInvited(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (invitedomain.Binding, bool, error) {
var binding invitedomain.Binding
err := tx.QueryRowContext(ctx, `
SELECT inviter_user_id, invite_code
FROM user_invite_relations
WHERE app_code = ? AND invited_user_id = ?
LIMIT 1
FOR UPDATE
`, appCode, invitedUserID).Scan(&binding.InviterUserID, &binding.InviteCode)
if err == sql.ErrNoRows {
return invitedomain.Binding{}, false, nil
}
if err != nil {
return invitedomain.Binding{}, false, err
}
return binding, true, nil
}
func fillUserInviteCodeSnapshot(ctx context.Context, tx *sql.Tx, command userdomain.BindInviteReferrerCommand, inviteCode string) error {
inviteCode = invitedomain.NormalizeCode(inviteCode)
if inviteCode == "" {
return nil
}
_, err := tx.ExecContext(ctx, `
UPDATE users
SET invite_code = CASE WHEN COALESCE(invite_code, '') = '' THEN ? ELSE invite_code END,
updated_at_ms = GREATEST(updated_at_ms, ?)
WHERE app_code = ? AND user_id = ?
`, inviteCode, command.BoundAtMs, command.AppCode, command.InvitedUserID)
return err
}

View File

@ -335,6 +335,40 @@ func (r *Repository) UpdateUserProfileBackground(ctx context.Context, command us
return user, nil
}
// UpdateUserContactInfo 只修改用户级联系方式,避免 Host/BD/Agency/币商各自身份表保存多份互相漂移的数据。
func (r *Repository) UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error) {
// 联系方式展示在多个经营身份入口,写库时锁定 users 行,确保返回快照和后续列表读取都来自同一主记录。
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userdomain.User{}, err
}
defer tx.Rollback()
user, err := QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
if err == sql.ErrNoRows {
return userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
}
if err != nil {
return userdomain.User{}, err
}
user.ContactInfo = strings.TrimSpace(command.ContactInfo)
user.UpdatedAtMs = command.UpdatedAtMs
_, err = tx.ExecContext(ctx, `
UPDATE users
SET contact_info = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, user.ContactInfo, user.UpdatedAtMs, appcode.Normalize(command.AppCode), command.UserID)
if err != nil {
return userdomain.User{}, err
}
if err := tx.Commit(); err != nil {
return userdomain.User{}, err
}
return user, nil
}
// CompleteOnboarding 是注册页资料固化的事务入口。
// 该方法不写 user_country_change_logs确保首次国家选择不消耗改国家冷却窗口。

View File

@ -149,11 +149,26 @@ func (r *Repository) UpdateUserProfileBackground(ctx context.Context, command us
return r.Repository.UserRepository().UpdateUserProfileBackground(ctx, command)
}
// UpdateUserContactInfo 让测试 wrapper 继续满足业务层用户级联系方式更新接口。
func (r *Repository) UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error) {
return r.Repository.UserRepository().UpdateUserContactInfo(ctx, command)
}
// CompleteOnboarding 让测试 wrapper 继续满足业务层注册资料接口。
func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error) {
return r.Repository.UserRepository().CompleteOnboarding(ctx, command)
}
// BindInviteReferrer 让测试 wrapper 继续满足 H5 邀请人绑定接口。
func (r *Repository) BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
return r.Repository.UserRepository().BindInviteReferrer(ctx, command)
}
// FindInviteReferrerByCode 让测试 wrapper 继续满足 H5 邀请人搜索接口。
func (r *Repository) FindInviteReferrerByCode(ctx context.Context, appCode string, inviteCode string) (userdomain.User, error) {
return r.Repository.UserRepository().FindInviteReferrerByCode(ctx, appCode, inviteCode)
}
// ChangeUserCountry 让测试 wrapper 继续满足业务层国家修改接口。
func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error) {
return r.Repository.UserRepository().ChangeUserCountry(ctx, command)
@ -419,6 +434,11 @@ func (r *Repository) ListEnabledLoginRiskIPWhitelist(ctx context.Context) ([]aut
return r.Repository.AuthRepository().ListEnabledLoginRiskIPWhitelist(ctx)
}
// ListEnabledLoginRiskUserWhitelist 让测试 wrapper 继续满足认证风控接口。
func (r *Repository) ListEnabledLoginRiskUserWhitelist(ctx context.Context) ([]authdomain.LoginRiskUserWhitelist, error) {
return r.Repository.AuthRepository().ListEnabledLoginRiskUserWhitelist(ctx)
}
// MarkLoginAuditBlocked 让测试 wrapper 继续满足认证接口。
func (r *Repository) MarkLoginAuditBlocked(ctx context.Context, requestID string, userID int64, countryCode string, blockReason string) error {
return r.Repository.AuthRepository().MarkLoginAuditBlocked(ctx, requestID, userID, countryCode, blockReason)
@ -463,11 +483,11 @@ func (r *Repository) PutUser(user userdomain.User) {
app_code, user_id, default_display_user_id, current_display_user_id, current_display_user_id_kind,
current_display_user_id_expires_at_ms, username, gender, country, region_id, invite_code,
register_ip, register_user_agent, country_by_ip, register_device_id, register_device,
os_version, avatar, profile_bg_img, birth_date, app_version, build_number, source, install_channel,
os_version, avatar, profile_bg_img, contact_info, birth_date, app_version, build_number, source, install_channel,
campaign, platform, language, timezone, profile_completed, profile_completed_at_ms,
onboarding_status, status, created_at_ms, updated_at_ms
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
app_code = VALUES(app_code),
default_display_user_id = VALUES(default_display_user_id),
@ -487,6 +507,7 @@ func (r *Repository) PutUser(user userdomain.User) {
os_version = VALUES(os_version),
avatar = VALUES(avatar),
profile_bg_img = VALUES(profile_bg_img),
contact_info = VALUES(contact_info),
birth_date = VALUES(birth_date),
app_version = VALUES(app_version),
build_number = VALUES(build_number),
@ -501,7 +522,7 @@ func (r *Repository) PutUser(user userdomain.User) {
onboarding_status = VALUES(onboarding_status),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)
`, user.AppCode, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableInt64(user.CurrentDisplayUserIDExpiresAtMs), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableInt64(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.ProfileBgImg), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, string(user.OnboardingStatus), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
`, user.AppCode, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableInt64(user.CurrentDisplayUserIDExpiresAtMs), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableInt64(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.ProfileBgImg), strings.TrimSpace(user.ContactInfo), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, string(user.OnboardingStatus), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
if err != nil {
r.t.Fatalf("seed user %d failed: %v", user.UserID, err)
}

View File

@ -73,6 +73,7 @@ func toProtoUser(user userdomain.User) *userv1.User {
Country: user.Country,
Avatar: user.Avatar,
ProfileBgImg: user.ProfileBgImg,
ContactInfo: user.ContactInfo,
Birth: user.BirthDate,
Language: user.Language,
CountryId: user.CountryID,
@ -489,6 +490,7 @@ func toProtoCoinSellerListItem(item hostdomain.CoinSellerListItem) *userv1.CoinS
RegionName: item.RegionName,
Status: item.Status,
MerchantAssetType: item.MerchantAssetType,
ContactInfo: item.ContactInfo,
UpdatedAtMs: item.UpdatedAtMs,
}
}

View File

@ -264,6 +264,21 @@ func (s *Server) CheckLoginRiskIPWhitelist(ctx context.Context, req *userv1.Chec
return &userv1.CheckLoginRiskIPWhitelistResponse{Whitelisted: whitelisted}, nil
}
// RefreshLoginRiskWhitelistCache 让后台保存白名单后立刻重建 Redis 缓存,避免等待 TTL 自然过期。
func (s *Server) RefreshLoginRiskWhitelistCache(ctx context.Context, req *userv1.RefreshLoginRiskWhitelistCacheRequest) (*userv1.RefreshLoginRiskWhitelistCacheResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
snapshot, err := s.authSvc.RefreshLoginRiskWhitelistCache(ctx)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.RefreshLoginRiskWhitelistCacheResponse{
Refreshed: snapshot.Refreshed,
IpWhitelistCount: int32(snapshot.IPWhitelistCount),
UserWhitelistCount: int32(snapshot.UserWhitelistCount),
UserCacheExpiresAtMs: snapshot.UserCacheExpiresAtMs,
}, nil
}
// GetRegisterRiskConfig 返回后台配置页和注册链路共用的设备注册上限。
func (s *Server) GetRegisterRiskConfig(ctx context.Context, req *userv1.GetRegisterRiskConfigRequest) (*userv1.GetRegisterRiskConfigResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
@ -318,6 +333,36 @@ func (s *Server) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*user
return &userv1.GetUserResponse{User: toProtoUser(user)}, nil
}
// SearchInviteReferrer 用当前用户国家边界搜索 H5 邀请页可绑定邀请人。
func (s *Server) SearchInviteReferrer(ctx context.Context, req *userv1.SearchInviteReferrerRequest) (*userv1.SearchInviteReferrerResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
inviter, err := s.userSvc.SearchInviteReferrer(ctx, req.GetUserId(), req.GetInviteCode())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.SearchInviteReferrerResponse{Inviter: toProtoUser(inviter)}, nil
}
// BindInviteReferrer 绑定当前用户的邀请人;邀请码归属由 user-service 事务内确认。
func (s *Server) BindInviteReferrer(ctx context.Context, req *userv1.BindInviteReferrerRequest) (*userv1.BindInviteReferrerResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
updated, err := s.userSvc.BindInviteReferrer(ctx, req.GetUserId(), req.GetInviteCode(), req.GetMeta().GetRequestId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
inviter, err := s.userSvc.GetUser(ctx, updated.InviteBinding.InviterUserID)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.BindInviteReferrerResponse{
Invite: toProtoInviteBinding(updated.InviteBinding),
Inviter: toProtoUser(inviter),
}, nil
}
// GetInviteAttribution 返回被邀请用户的邀请人归因,供活动服务按充值事实累计邀请活动奖励。
func (s *Server) GetInviteAttribution(ctx context.Context, req *userv1.GetInviteAttributionRequest) (*userv1.GetInviteAttributionResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
@ -672,6 +717,18 @@ func (s *Server) UpdateUserProfileBackground(ctx context.Context, req *userv1.Up
return &userv1.UpdateUserProfileBackgroundResponse{User: toProtoUser(user)}, nil
}
// UpdateUserContactInfo 修改当前用户级联系方式。
func (s *Server) UpdateUserContactInfo(ctx context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
// 联系方式是用户主资料字段,不落到具体经营身份表,避免同一用户多身份时展示不一致。
user, err := s.userSvc.UpdateUserContactInfo(ctx, req.GetUserId(), req.GetContactInfo())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.UpdateUserContactInfoResponse{User: toProtoUser(user)}, nil
}
// ChangeUserCountry 是 App 自助改国家入口;受限经营身份会被 service 层按当前角色摘要拦截。
func (s *Server) ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())