注册,im私聊消息

This commit is contained in:
zhx 2026-06-25 21:30:26 +08:00
parent bf2735d068
commit ba0e5664dd
63 changed files with 8331 additions and 5001 deletions

View File

@ -1542,6 +1542,100 @@ func (x *AppHeartbeatResponse) GetToken() *AuthToken {
return nil
}
// CompletePendingThirdPartyRegistrationRequest 使用 pending token 完成三方注册。
type CompletePendingThirdPartyRegistrationRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
PendingRegistrationId string `protobuf:"bytes,2,opt,name=pending_registration_id,json=pendingRegistrationId,proto3" json:"pending_registration_id,omitempty"`
Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"`
Avatar string `protobuf:"bytes,4,opt,name=avatar,proto3" json:"avatar,omitempty"`
Gender string `protobuf:"bytes,5,opt,name=gender,proto3" json:"gender,omitempty"`
Country string `protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"`
InviteCode string `protobuf:"bytes,7,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"`
}
func (x *CompletePendingThirdPartyRegistrationRequest) Reset() {
*x = CompletePendingThirdPartyRegistrationRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CompletePendingThirdPartyRegistrationRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CompletePendingThirdPartyRegistrationRequest) ProtoMessage() {}
func (x *CompletePendingThirdPartyRegistrationRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[22]
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 CompletePendingThirdPartyRegistrationRequest.ProtoReflect.Descriptor instead.
func (*CompletePendingThirdPartyRegistrationRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{22}
}
func (x *CompletePendingThirdPartyRegistrationRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
func (x *CompletePendingThirdPartyRegistrationRequest) GetPendingRegistrationId() string {
if x != nil {
return x.PendingRegistrationId
}
return ""
}
func (x *CompletePendingThirdPartyRegistrationRequest) GetUsername() string {
if x != nil {
return x.Username
}
return ""
}
func (x *CompletePendingThirdPartyRegistrationRequest) GetAvatar() string {
if x != nil {
return x.Avatar
}
return ""
}
func (x *CompletePendingThirdPartyRegistrationRequest) GetGender() string {
if x != nil {
return x.Gender
}
return ""
}
func (x *CompletePendingThirdPartyRegistrationRequest) GetCountry() string {
if x != nil {
return x.Country
}
return ""
}
func (x *CompletePendingThirdPartyRegistrationRequest) GetInviteCode() string {
if x != nil {
return x.InviteCode
}
return ""
}
var File_proto_user_v1_auth_proto protoreflect.FileDescriptor
var file_proto_user_v1_auth_proto_rawDesc = []byte{
@ -1774,7 +1868,25 @@ var file_proto_user_v1_auth_proto_rawDesc = []byte{
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, 0xcd, 0x08, 0x0a, 0x0b, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65,
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x9d, 0x02, 0x0a, 0x2c, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
0x74, 0x65, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61,
0x72, 0x74, 0x79, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65,
0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61,
0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x36, 0x0a, 0x17, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e,
0x67, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1a,
0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x76,
0x61, 0x74, 0x61, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x76, 0x61, 0x74,
0x61, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f,
0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x72, 0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x5f, 0x63,
0x6f, 0x64, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x76, 0x69, 0x74,
0x65, 0x43, 0x6f, 0x64, 0x65, 0x32, 0xdf, 0x09, 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,
@ -1843,10 +1955,19 @@ var file_proto_user_v1_auth_proto_rawDesc = []byte{
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,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x8f, 0x01, 0x0a, 0x25, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
0x74, 0x65, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61,
0x72, 0x74, 0x79, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12,
0x3b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x50, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x54,
0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72,
0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d,
0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x6e, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 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 (
@ -1861,78 +1982,83 @@ 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, 22)
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 23)
var file_proto_user_v1_auth_proto_goTypes = []any{
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
(*AuthResponse)(nil), // 2: hyapp.user.v1.AuthResponse
(*SetPasswordRequest)(nil), // 3: hyapp.user.v1.SetPasswordRequest
(*SetPasswordResponse)(nil), // 4: hyapp.user.v1.SetPasswordResponse
(*QuickCreateAccountRequest)(nil), // 5: hyapp.user.v1.QuickCreateAccountRequest
(*QuickCreateAccountResponse)(nil), // 6: hyapp.user.v1.QuickCreateAccountResponse
(*RefreshTokenRequest)(nil), // 7: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 8: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 9: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 10: hyapp.user.v1.LogoutResponse
(*RecordLoginBlockedRequest)(nil), // 11: hyapp.user.v1.RecordLoginBlockedRequest
(*RecordLoginBlockedResponse)(nil), // 12: hyapp.user.v1.RecordLoginBlockedResponse
(*CheckLoginRiskIPWhitelistRequest)(nil), // 13: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
(*CheckLoginRiskIPWhitelistResponse)(nil), // 14: hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
(*RegisterRiskConfig)(nil), // 15: hyapp.user.v1.RegisterRiskConfig
(*GetRegisterRiskConfigRequest)(nil), // 16: hyapp.user.v1.GetRegisterRiskConfigRequest
(*GetRegisterRiskConfigResponse)(nil), // 17: hyapp.user.v1.GetRegisterRiskConfigResponse
(*UpdateRegisterRiskConfigRequest)(nil), // 18: hyapp.user.v1.UpdateRegisterRiskConfigRequest
(*UpdateRegisterRiskConfigResponse)(nil), // 19: hyapp.user.v1.UpdateRegisterRiskConfigResponse
(*AppHeartbeatRequest)(nil), // 20: hyapp.user.v1.AppHeartbeatRequest
(*AppHeartbeatResponse)(nil), // 21: hyapp.user.v1.AppHeartbeatResponse
(*RequestMeta)(nil), // 22: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 23: hyapp.user.v1.AuthToken
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
(*AuthResponse)(nil), // 2: hyapp.user.v1.AuthResponse
(*SetPasswordRequest)(nil), // 3: hyapp.user.v1.SetPasswordRequest
(*SetPasswordResponse)(nil), // 4: hyapp.user.v1.SetPasswordResponse
(*QuickCreateAccountRequest)(nil), // 5: hyapp.user.v1.QuickCreateAccountRequest
(*QuickCreateAccountResponse)(nil), // 6: hyapp.user.v1.QuickCreateAccountResponse
(*RefreshTokenRequest)(nil), // 7: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 8: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 9: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 10: hyapp.user.v1.LogoutResponse
(*RecordLoginBlockedRequest)(nil), // 11: hyapp.user.v1.RecordLoginBlockedRequest
(*RecordLoginBlockedResponse)(nil), // 12: hyapp.user.v1.RecordLoginBlockedResponse
(*CheckLoginRiskIPWhitelistRequest)(nil), // 13: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
(*CheckLoginRiskIPWhitelistResponse)(nil), // 14: hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
(*RegisterRiskConfig)(nil), // 15: hyapp.user.v1.RegisterRiskConfig
(*GetRegisterRiskConfigRequest)(nil), // 16: hyapp.user.v1.GetRegisterRiskConfigRequest
(*GetRegisterRiskConfigResponse)(nil), // 17: hyapp.user.v1.GetRegisterRiskConfigResponse
(*UpdateRegisterRiskConfigRequest)(nil), // 18: hyapp.user.v1.UpdateRegisterRiskConfigRequest
(*UpdateRegisterRiskConfigResponse)(nil), // 19: hyapp.user.v1.UpdateRegisterRiskConfigResponse
(*AppHeartbeatRequest)(nil), // 20: hyapp.user.v1.AppHeartbeatRequest
(*AppHeartbeatResponse)(nil), // 21: hyapp.user.v1.AppHeartbeatResponse
(*CompletePendingThirdPartyRegistrationRequest)(nil), // 22: hyapp.user.v1.CompletePendingThirdPartyRegistrationRequest
(*RequestMeta)(nil), // 23: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 24: hyapp.user.v1.AuthToken
(*CompleteOnboardingResponse)(nil), // 25: hyapp.user.v1.CompleteOnboardingResponse
}
var file_proto_user_v1_auth_proto_depIdxs = []int32{
22, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
22, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
22, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
22, // 4: hyapp.user.v1.QuickCreateAccountRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 5: hyapp.user.v1.QuickCreateAccountResponse.token:type_name -> hyapp.user.v1.AuthToken
22, // 6: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 7: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
22, // 8: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
22, // 9: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
22, // 10: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest.meta:type_name -> hyapp.user.v1.RequestMeta
22, // 11: hyapp.user.v1.GetRegisterRiskConfigRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
24, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
23, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 4: hyapp.user.v1.QuickCreateAccountRequest.meta:type_name -> hyapp.user.v1.RequestMeta
24, // 5: hyapp.user.v1.QuickCreateAccountResponse.token:type_name -> hyapp.user.v1.AuthToken
23, // 6: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
24, // 7: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
23, // 8: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 9: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 10: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 11: hyapp.user.v1.GetRegisterRiskConfigRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 12: hyapp.user.v1.GetRegisterRiskConfigResponse.config:type_name -> hyapp.user.v1.RegisterRiskConfig
22, // 13: hyapp.user.v1.UpdateRegisterRiskConfigRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 13: hyapp.user.v1.UpdateRegisterRiskConfigRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 14: hyapp.user.v1.UpdateRegisterRiskConfigResponse.config:type_name -> hyapp.user.v1.RegisterRiskConfig
22, // 15: hyapp.user.v1.AppHeartbeatRequest.meta:type_name -> hyapp.user.v1.RequestMeta
23, // 16: hyapp.user.v1.AppHeartbeatResponse.token:type_name -> hyapp.user.v1.AuthToken
0, // 17: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 18: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 19: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 20: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
7, // 21: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
9, // 22: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
11, // 23: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
13, // 24: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:input_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
16, // 25: hyapp.user.v1.AuthService.GetRegisterRiskConfig:input_type -> hyapp.user.v1.GetRegisterRiskConfigRequest
18, // 26: hyapp.user.v1.AuthService.UpdateRegisterRiskConfig:input_type -> hyapp.user.v1.UpdateRegisterRiskConfigRequest
20, // 27: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
2, // 28: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 29: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 30: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 31: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
8, // 32: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
10, // 33: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // 34: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // 35: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:output_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
17, // 36: hyapp.user.v1.AuthService.GetRegisterRiskConfig:output_type -> hyapp.user.v1.GetRegisterRiskConfigResponse
19, // 37: hyapp.user.v1.AuthService.UpdateRegisterRiskConfig:output_type -> hyapp.user.v1.UpdateRegisterRiskConfigResponse
21, // 38: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
28, // [28:39] is the sub-list for method output_type
17, // [17:28] is the sub-list for method input_type
17, // [17:17] is the sub-list for extension type_name
17, // [17:17] is the sub-list for extension extendee
0, // [0:17] is the sub-list for field type_name
23, // 15: hyapp.user.v1.AppHeartbeatRequest.meta:type_name -> hyapp.user.v1.RequestMeta
24, // 16: hyapp.user.v1.AppHeartbeatResponse.token:type_name -> hyapp.user.v1.AuthToken
23, // 17: hyapp.user.v1.CompletePendingThirdPartyRegistrationRequest.meta:type_name -> hyapp.user.v1.RequestMeta
0, // 18: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 19: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 20: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 21: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
7, // 22: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
9, // 23: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
11, // 24: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
13, // 25: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:input_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
16, // 26: hyapp.user.v1.AuthService.GetRegisterRiskConfig:input_type -> hyapp.user.v1.GetRegisterRiskConfigRequest
18, // 27: hyapp.user.v1.AuthService.UpdateRegisterRiskConfig:input_type -> hyapp.user.v1.UpdateRegisterRiskConfigRequest
20, // 28: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
22, // 29: hyapp.user.v1.AuthService.CompletePendingThirdPartyRegistration:input_type -> hyapp.user.v1.CompletePendingThirdPartyRegistrationRequest
2, // 30: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 31: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 32: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 33: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
8, // 34: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
10, // 35: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // 36: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // 37: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:output_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
17, // 38: hyapp.user.v1.AuthService.GetRegisterRiskConfig:output_type -> hyapp.user.v1.GetRegisterRiskConfigResponse
19, // 39: hyapp.user.v1.AuthService.UpdateRegisterRiskConfig:output_type -> hyapp.user.v1.UpdateRegisterRiskConfigResponse
21, // 40: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
25, // 41: hyapp.user.v1.AuthService.CompletePendingThirdPartyRegistration:output_type -> hyapp.user.v1.CompleteOnboardingResponse
30, // [30:42] is the sub-list for method output_type
18, // [18:30] is the sub-list for method input_type
18, // [18:18] is the sub-list for extension type_name
18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
}
func init() { file_proto_user_v1_auth_proto_init() }
@ -1947,7 +2073,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: 22,
NumMessages: 23,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -178,6 +178,17 @@ message AppHeartbeatResponse {
AuthToken token = 4;
}
// CompletePendingThirdPartyRegistrationRequest 使 pending token
message CompletePendingThirdPartyRegistrationRequest {
RequestMeta meta = 1;
string pending_registration_id = 2;
string username = 3;
string avatar = 4;
string gender = 5;
string country = 6;
string invite_code = 7;
}
// AuthService token
service AuthService {
rpc LoginPassword(LoginPasswordRequest) returns (AuthResponse);
@ -191,4 +202,5 @@ service AuthService {
rpc GetRegisterRiskConfig(GetRegisterRiskConfigRequest) returns (GetRegisterRiskConfigResponse);
rpc UpdateRegisterRiskConfig(UpdateRegisterRiskConfigRequest) returns (UpdateRegisterRiskConfigResponse);
rpc AppHeartbeat(AppHeartbeatRequest) returns (AppHeartbeatResponse);
rpc CompletePendingThirdPartyRegistration(CompletePendingThirdPartyRegistrationRequest) returns (CompleteOnboardingResponse);
}

View File

@ -19,17 +19,18 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
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_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_CompletePendingThirdPartyRegistration_FullMethodName = "/hyapp.user.v1.AuthService/CompletePendingThirdPartyRegistration"
)
// AuthServiceClient is the client API for AuthService service.
@ -49,6 +50,7 @@ type AuthServiceClient interface {
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)
CompletePendingThirdPartyRegistration(ctx context.Context, in *CompletePendingThirdPartyRegistrationRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error)
}
type authServiceClient struct {
@ -169,6 +171,16 @@ func (c *authServiceClient) AppHeartbeat(ctx context.Context, in *AppHeartbeatRe
return out, nil
}
func (c *authServiceClient) CompletePendingThirdPartyRegistration(ctx context.Context, in *CompletePendingThirdPartyRegistrationRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CompleteOnboardingResponse)
err := c.cc.Invoke(ctx, AuthService_CompletePendingThirdPartyRegistration_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AuthServiceServer is the server API for AuthService service.
// All implementations must embed UnimplementedAuthServiceServer
// for forward compatibility.
@ -186,6 +198,7 @@ type AuthServiceServer interface {
GetRegisterRiskConfig(context.Context, *GetRegisterRiskConfigRequest) (*GetRegisterRiskConfigResponse, error)
UpdateRegisterRiskConfig(context.Context, *UpdateRegisterRiskConfigRequest) (*UpdateRegisterRiskConfigResponse, error)
AppHeartbeat(context.Context, *AppHeartbeatRequest) (*AppHeartbeatResponse, error)
CompletePendingThirdPartyRegistration(context.Context, *CompletePendingThirdPartyRegistrationRequest) (*CompleteOnboardingResponse, error)
mustEmbedUnimplementedAuthServiceServer()
}
@ -229,6 +242,9 @@ func (UnimplementedAuthServiceServer) UpdateRegisterRiskConfig(context.Context,
func (UnimplementedAuthServiceServer) AppHeartbeat(context.Context, *AppHeartbeatRequest) (*AppHeartbeatResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AppHeartbeat not implemented")
}
func (UnimplementedAuthServiceServer) CompletePendingThirdPartyRegistration(context.Context, *CompletePendingThirdPartyRegistrationRequest) (*CompleteOnboardingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CompletePendingThirdPartyRegistration not implemented")
}
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
@ -448,6 +464,24 @@ func _AuthService_AppHeartbeat_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _AuthService_CompletePendingThirdPartyRegistration_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CompletePendingThirdPartyRegistrationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).CompletePendingThirdPartyRegistration(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_CompletePendingThirdPartyRegistration_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).CompletePendingThirdPartyRegistration(ctx, req.(*CompletePendingThirdPartyRegistrationRequest))
}
return interceptor(ctx, in, info, handler)
}
// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -499,6 +533,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AppHeartbeat",
Handler: _AuthService_AppHeartbeat_Handler,
},
{
MethodName: "CompletePendingThirdPartyRegistration",
Handler: _AuthService_CompletePendingThirdPartyRegistration_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/auth.proto",

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,25 @@ message DebitGiftRequest {
string charge_source = 15;
}
// DebitDirectGiftRequest gateway user-service
message DebitDirectGiftRequest {
string command_id = 1;
int64 sender_user_id = 2;
int64 target_user_id = 3;
string gift_id = 4;
int32 gift_count = 5;
string price_version = 6;
string app_code = 7;
// region_id 使 sender region_id
int64 region_id = 8;
int64 sender_region_id = 9;
bool target_is_host = 10;
int64 target_host_region_id = 11;
int64 target_agency_owner_user_id = 12;
string entitlement_id = 13;
string charge_source = 14;
}
// DebitGiftResponse sender COIN
message DebitGiftResponse {
string billing_receipt_id = 1;
@ -57,6 +76,10 @@ message DebitGiftResponse {
repeated string gift_effect_types = 17;
string entitlement_id = 18;
string charge_source = 19;
// gift_income_coin_amount COIN0 /
int64 gift_income_coin_amount = 20;
// gift_income_balance_after COIN 0
int64 gift_income_balance_after = 21;
}
// DebitGiftTarget
@ -1249,6 +1272,30 @@ message UpdateThirdPartyPaymentRateRequest {
int64 operator_user_id = 5;
}
message ThirdPartyPaymentMethodSyncIssue {
string country_code = 1;
string currency_code = 2;
string code = 3;
string message = 4;
}
message SyncThirdPartyPaymentMethodsRequest {
string request_id = 1;
string app_code = 2;
string provider_code = 3;
int64 operator_user_id = 4;
}
message SyncThirdPartyPaymentMethodsResponse {
string provider_code = 1;
int32 scanned_country_count = 2;
int32 fetched_country_count = 3;
int32 created_count = 4;
int32 updated_count = 5;
int32 skipped_count = 6;
repeated ThirdPartyPaymentMethodSyncIssue issues = 7;
}
message H5RechargeOptionsRequest {
string request_id = 1;
string app_code = 2;
@ -2008,6 +2055,7 @@ service WalletCronService {
// WalletService gRPC HTTP gateway-service
service WalletService {
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
rpc DebitDirectGift(DebitDirectGiftRequest) returns (DebitGiftResponse);
rpc BatchDebitGift(BatchDebitGiftRequest) returns (BatchDebitGiftResponse);
rpc DebitRobotGift(DebitRobotGiftRequest) returns (DebitGiftResponse);
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
@ -2062,6 +2110,7 @@ service WalletService {
rpc ListThirdPartyPaymentChannels(ListThirdPartyPaymentChannelsRequest) returns (ListThirdPartyPaymentChannelsResponse);
rpc SetThirdPartyPaymentMethodStatus(SetThirdPartyPaymentMethodStatusRequest) returns (ThirdPartyPaymentMethodResponse);
rpc UpdateThirdPartyPaymentRate(UpdateThirdPartyPaymentRateRequest) returns (ThirdPartyPaymentMethodResponse);
rpc SyncThirdPartyPaymentMethods(SyncThirdPartyPaymentMethodsRequest) returns (SyncThirdPartyPaymentMethodsResponse);
rpc ListH5RechargeOptions(H5RechargeOptionsRequest) returns (H5RechargeOptionsResponse);
rpc CreateH5RechargeOrder(CreateH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
rpc CreateTemporaryRechargeOrder(CreateTemporaryRechargeOrderRequest) returns (H5RechargeOrderResponse);

View File

@ -202,6 +202,7 @@ var WalletCronService_ServiceDesc = grpc.ServiceDesc{
const (
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
WalletService_DebitDirectGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitDirectGift"
WalletService_BatchDebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchDebitGift"
WalletService_DebitRobotGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitRobotGift"
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
@ -256,6 +257,7 @@ const (
WalletService_ListThirdPartyPaymentChannels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListThirdPartyPaymentChannels"
WalletService_SetThirdPartyPaymentMethodStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetThirdPartyPaymentMethodStatus"
WalletService_UpdateThirdPartyPaymentRate_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateThirdPartyPaymentRate"
WalletService_SyncThirdPartyPaymentMethods_FullMethodName = "/hyapp.wallet.v1.WalletService/SyncThirdPartyPaymentMethods"
WalletService_ListH5RechargeOptions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListH5RechargeOptions"
WalletService_CreateH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateH5RechargeOrder"
WalletService_CreateTemporaryRechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateTemporaryRechargeOrder"
@ -299,6 +301,7 @@ const (
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
type WalletServiceClient interface {
DebitGift(ctx context.Context, in *DebitGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
DebitDirectGift(ctx context.Context, in *DebitDirectGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
BatchDebitGift(ctx context.Context, in *BatchDebitGiftRequest, opts ...grpc.CallOption) (*BatchDebitGiftResponse, error)
DebitRobotGift(ctx context.Context, in *DebitRobotGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error)
@ -353,6 +356,7 @@ type WalletServiceClient interface {
ListThirdPartyPaymentChannels(ctx context.Context, in *ListThirdPartyPaymentChannelsRequest, opts ...grpc.CallOption) (*ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, in *SetThirdPartyPaymentMethodStatusRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(ctx context.Context, in *UpdateThirdPartyPaymentRateRequest, opts ...grpc.CallOption) (*ThirdPartyPaymentMethodResponse, error)
SyncThirdPartyPaymentMethods(ctx context.Context, in *SyncThirdPartyPaymentMethodsRequest, opts ...grpc.CallOption) (*SyncThirdPartyPaymentMethodsResponse, error)
ListH5RechargeOptions(ctx context.Context, in *H5RechargeOptionsRequest, opts ...grpc.CallOption) (*H5RechargeOptionsResponse, error)
CreateH5RechargeOrder(ctx context.Context, in *CreateH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
CreateTemporaryRechargeOrder(ctx context.Context, in *CreateTemporaryRechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
@ -407,6 +411,16 @@ func (c *walletServiceClient) DebitGift(ctx context.Context, in *DebitGiftReques
return out, nil
}
func (c *walletServiceClient) DebitDirectGift(ctx context.Context, in *DebitDirectGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DebitGiftResponse)
err := c.cc.Invoke(ctx, WalletService_DebitDirectGift_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) BatchDebitGift(ctx context.Context, in *BatchDebitGiftRequest, opts ...grpc.CallOption) (*BatchDebitGiftResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchDebitGiftResponse)
@ -947,6 +961,16 @@ func (c *walletServiceClient) UpdateThirdPartyPaymentRate(ctx context.Context, i
return out, nil
}
func (c *walletServiceClient) SyncThirdPartyPaymentMethods(ctx context.Context, in *SyncThirdPartyPaymentMethodsRequest, opts ...grpc.CallOption) (*SyncThirdPartyPaymentMethodsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SyncThirdPartyPaymentMethodsResponse)
err := c.cc.Invoke(ctx, WalletService_SyncThirdPartyPaymentMethods_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListH5RechargeOptions(ctx context.Context, in *H5RechargeOptionsRequest, opts ...grpc.CallOption) (*H5RechargeOptionsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(H5RechargeOptionsResponse)
@ -1294,6 +1318,7 @@ func (c *walletServiceClient) RetryRedPacketRefund(ctx context.Context, in *Retr
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
type WalletServiceServer interface {
DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error)
DebitDirectGift(context.Context, *DebitDirectGiftRequest) (*DebitGiftResponse, error)
BatchDebitGift(context.Context, *BatchDebitGiftRequest) (*BatchDebitGiftResponse, error)
DebitRobotGift(context.Context, *DebitRobotGiftRequest) (*DebitGiftResponse, error)
GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error)
@ -1348,6 +1373,7 @@ type WalletServiceServer interface {
ListThirdPartyPaymentChannels(context.Context, *ListThirdPartyPaymentChannelsRequest) (*ListThirdPartyPaymentChannelsResponse, error)
SetThirdPartyPaymentMethodStatus(context.Context, *SetThirdPartyPaymentMethodStatusRequest) (*ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(context.Context, *UpdateThirdPartyPaymentRateRequest) (*ThirdPartyPaymentMethodResponse, error)
SyncThirdPartyPaymentMethods(context.Context, *SyncThirdPartyPaymentMethodsRequest) (*SyncThirdPartyPaymentMethodsResponse, error)
ListH5RechargeOptions(context.Context, *H5RechargeOptionsRequest) (*H5RechargeOptionsResponse, error)
CreateH5RechargeOrder(context.Context, *CreateH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
CreateTemporaryRechargeOrder(context.Context, *CreateTemporaryRechargeOrderRequest) (*H5RechargeOrderResponse, error)
@ -1395,6 +1421,9 @@ type UnimplementedWalletServiceServer struct{}
func (UnimplementedWalletServiceServer) DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DebitGift not implemented")
}
func (UnimplementedWalletServiceServer) DebitDirectGift(context.Context, *DebitDirectGiftRequest) (*DebitGiftResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DebitDirectGift not implemented")
}
func (UnimplementedWalletServiceServer) BatchDebitGift(context.Context, *BatchDebitGiftRequest) (*BatchDebitGiftResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchDebitGift not implemented")
}
@ -1557,6 +1586,9 @@ func (UnimplementedWalletServiceServer) SetThirdPartyPaymentMethodStatus(context
func (UnimplementedWalletServiceServer) UpdateThirdPartyPaymentRate(context.Context, *UpdateThirdPartyPaymentRateRequest) (*ThirdPartyPaymentMethodResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateThirdPartyPaymentRate not implemented")
}
func (UnimplementedWalletServiceServer) SyncThirdPartyPaymentMethods(context.Context, *SyncThirdPartyPaymentMethodsRequest) (*SyncThirdPartyPaymentMethodsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SyncThirdPartyPaymentMethods not implemented")
}
func (UnimplementedWalletServiceServer) ListH5RechargeOptions(context.Context, *H5RechargeOptionsRequest) (*H5RechargeOptionsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListH5RechargeOptions not implemented")
}
@ -1698,6 +1730,24 @@ func _WalletService_DebitGift_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _WalletService_DebitDirectGift_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DebitDirectGiftRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).DebitDirectGift(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_DebitDirectGift_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).DebitDirectGift(ctx, req.(*DebitDirectGiftRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_BatchDebitGift_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchDebitGiftRequest)
if err := dec(in); err != nil {
@ -2670,6 +2720,24 @@ func _WalletService_UpdateThirdPartyPaymentRate_Handler(srv interface{}, ctx con
return interceptor(ctx, in, info, handler)
}
func _WalletService_SyncThirdPartyPaymentMethods_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SyncThirdPartyPaymentMethodsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).SyncThirdPartyPaymentMethods(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_SyncThirdPartyPaymentMethods_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).SyncThirdPartyPaymentMethods(ctx, req.(*SyncThirdPartyPaymentMethodsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListH5RechargeOptions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(H5RechargeOptionsRequest)
if err := dec(in); err != nil {
@ -3293,6 +3361,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DebitGift",
Handler: _WalletService_DebitGift_Handler,
},
{
MethodName: "DebitDirectGift",
Handler: _WalletService_DebitDirectGift_Handler,
},
{
MethodName: "BatchDebitGift",
Handler: _WalletService_BatchDebitGift_Handler,
@ -3509,6 +3581,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpdateThirdPartyPaymentRate",
Handler: _WalletService_UpdateThirdPartyPaymentRate_Handler,
},
{
MethodName: "SyncThirdPartyPaymentMethods",
Handler: _WalletService_SyncThirdPartyPaymentMethods_Handler,
},
{
MethodName: "ListH5RechargeOptions",
Handler: _WalletService_ListH5RechargeOptions_Handler,

View File

@ -78,12 +78,15 @@ type CustomGroupMessage struct {
// CustomUserMessage 表示一条服务端发给单个用户的 TIMCustomElem。
// PayloadJSON 同时写入 Data 和 CloudCustomData客户端必须按 event_id 去重。
type CustomUserMessage struct {
ToAccount string
EventID string
Desc string
Ext string
ToAccount string
EventID string
Desc string
Ext string
// FromAccount 为空时使用管理员账号;用户主动行为要显式传用户 identifier避免消息归属变成系统通知。
FromAccount string
PayloadJSON json.RawMessage
// SyncOtherMachine 透传腾讯 C2C 同步策略0 保持历史默认 2不同步发送方其他终端。
SyncOtherMachine int
PayloadJSON json.RawMessage
}
// AccountProfile 表达腾讯 IM 账号导入所需的最小用户资料。
@ -315,13 +318,18 @@ func (c *RESTClient) PublishUserCustomMessage(ctx context.Context, message Custo
// C2C 服务端消息默认从管理员账号发出,避免调用方每个业务模块复制同一配置。
message.FromAccount = c.cfg.AdminIdentifier
}
syncOtherMachine := message.SyncOtherMachine
if syncOtherMachine == 0 {
// 历史 notice 消息只投递给目标用户;不显式传值时继续保持这个行为,避免改变钱包/CP 通知的漫游面。
syncOtherMachine = 2
}
payload := bytes.TrimSpace(message.PayloadJSON)
if message.ToAccount == "" || message.EventID == "" || len(payload) == 0 {
return fmt.Errorf("user custom message is incomplete")
}
request := sendC2CMsgRequest{
SyncOtherMachine: 2,
SyncOtherMachine: syncOtherMachine,
ToAccount: message.ToAccount,
MsgRandom: randomUint32(),
CloudCustomData: string(payload),

View File

@ -25,7 +25,7 @@ type Spec struct {
var catalog = map[Code]Spec{
InvalidArgument: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidArgument, "invalid argument"),
NotFound: spec(codes.NotFound, httpStatusNotFound, NotFound, "not found"),
Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "conflict"),
Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "request cannot be completed in current state"),
RoomClosed: spec(codes.FailedPrecondition, httpStatusConflict, RoomClosed, "room closed"),
Unauthorized: spec(codes.Unauthenticated, httpStatusUnauthorized, Unauthorized, "unauthorized"),
@ -42,26 +42,26 @@ var catalog = map[Code]Spec{
AuthFailed: spec(codes.Unauthenticated, httpStatusUnauthorized, AuthFailed, "authentication failed"),
AuthLoginBlocked: spec(codes.PermissionDenied, httpStatusForbidden, AuthLoginBlocked, "login blocked"),
PasswordAlreadySet: spec(codes.AlreadyExists, httpStatusConflict, PasswordAlreadySet, "conflict"),
PasswordAlreadySet: spec(codes.AlreadyExists, httpStatusConflict, PasswordAlreadySet, "password already set"),
UserDisabled: spec(codes.PermissionDenied, httpStatusForbidden, UserDisabled, "permission denied"),
SessionExpired: spec(codes.Unauthenticated, httpStatusUnauthorized, SessionExpired, "unauthorized"),
SessionRevoked: spec(codes.Unauthenticated, httpStatusUnauthorized, SessionRevoked, "unauthorized"),
DisplayUserIDInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, DisplayUserIDInvalid, "invalid argument"),
DisplayUserIDExists: spec(codes.AlreadyExists, httpStatusConflict, DisplayUserIDExists, "conflict"),
DisplayUserIDCooldown: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDCooldown, "conflict"),
DisplayUserIDExists: spec(codes.AlreadyExists, httpStatusConflict, DisplayUserIDExists, "display user id already exists"),
DisplayUserIDCooldown: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDCooldown, "display user id change is cooling down"),
DisplayUserIDNotFound: spec(codes.NotFound, httpStatusNotFound, DisplayUserIDNotFound, "not found"),
DisplayUserIDAllocateFailed: spec(codes.Internal, httpStatusInternalServerError, Internal, "internal error"),
DisplayUserIDPrettyNotAvailable: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyNotAvailable, "conflict"),
DisplayUserIDPrettyActive: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyActive, "conflict"),
DisplayUserIDLeaseExpired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDLeaseExpired, "conflict"),
DisplayUserIDPaymentRequired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPaymentRequired, "conflict"),
DisplayUserIDPrettyNotAvailable: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyNotAvailable, "pretty display user id is not available"),
DisplayUserIDPrettyActive: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyActive, "pretty display user id already active"),
DisplayUserIDLeaseExpired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDLeaseExpired, "pretty display user id lease expired"),
DisplayUserIDPaymentRequired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPaymentRequired, "pretty display user id payment required"),
CountryChangeCooldown: spec(codes.FailedPrecondition, httpStatusConflict, CountryChangeCooldown, "country change is cooling down"),
CountryNotFound: spec(codes.NotFound, httpStatusNotFound, CountryNotFound, "not found"),
RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"),
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "conflict"),
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "conflict"),
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "country already belongs to another region"),
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "region is disabled"),
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid invite code"),
DeviceAlreadyRegistered: spec(
codes.FailedPrecondition,
@ -71,38 +71,38 @@ var catalog = map[Code]Spec{
),
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "conflict"),
LedgerConflict: spec(codes.Aborted, httpStatusConflict, LedgerConflict, "conflict"),
CoinSellerNotActive: spec(codes.FailedPrecondition, httpStatusConflict, CoinSellerNotActive, "conflict"),
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"),
LedgerConflict: spec(codes.Aborted, httpStatusConflict, LedgerConflict, "wallet state changed, please retry"),
CoinSellerNotActive: spec(codes.FailedPrecondition, httpStatusConflict, CoinSellerNotActive, "coin seller is not active"),
CoinSellerStockTypeInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockTypeInvalid, "invalid argument"),
CoinSellerStockAmountInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockAmountInvalid, "invalid argument"),
CoinSellerPaymentRefDuplicated: spec(codes.AlreadyExists, httpStatusConflict, CoinSellerPaymentRefDuplicated, "conflict"),
IdempotencyConflict: spec(codes.AlreadyExists, httpStatusConflict, IdempotencyConflict, "conflict"),
CoinSellerPaymentRefDuplicated: spec(codes.AlreadyExists, httpStatusConflict, CoinSellerPaymentRefDuplicated, "payment reference already used"),
IdempotencyConflict: spec(codes.AlreadyExists, httpStatusConflict, IdempotencyConflict, "request payload does not match previous command"),
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "conflict"),
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "conflict"),
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "conflict"),
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "vip level is disabled"),
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "vip downgrade is not allowed"),
VIPRechargeRequired: spec(codes.FailedPrecondition, httpStatusConflict, VIPRechargeRequired, "vip recharge is required"),
RedPacketDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketDisabled, "conflict"),
RedPacketDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketDisabled, "red packet is disabled"),
RedPacketInvalidAmountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidAmountTier, "invalid argument"),
RedPacketInvalidCountTier: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketInvalidCountTier, "invalid argument"),
RedPacketAmountTooSmall: spec(codes.InvalidArgument, httpStatusBadRequest, RedPacketAmountTooSmall, "invalid argument"),
RedPacketDailyLimitReached: spec(codes.ResourceExhausted, 429, RedPacketDailyLimitReached, "rate limited"),
RedPacketNotOpen: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketNotOpen, "conflict"),
RedPacketExpired: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketExpired, "conflict"),
RedPacketAlreadyClaimed: spec(codes.AlreadyExists, httpStatusConflict, RedPacketAlreadyClaimed, "conflict"),
RedPacketSoldOut: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketSoldOut, "conflict"),
RedPacketNotOpen: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketNotOpen, "red packet is not open"),
RedPacketExpired: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketExpired, "red packet expired"),
RedPacketAlreadyClaimed: spec(codes.AlreadyExists, httpStatusConflict, RedPacketAlreadyClaimed, "red packet already claimed"),
RedPacketSoldOut: spec(codes.FailedPrecondition, httpStatusConflict, RedPacketSoldOut, "red packet sold out"),
RuleNotActive: spec(codes.FailedPrecondition, httpStatusConflict, RuleNotActive, "conflict"),
EventAlreadyConsumed: spec(codes.AlreadyExists, httpStatusConflict, EventAlreadyConsumed, "conflict"),
RewardPending: spec(codes.FailedPrecondition, httpStatusConflict, RewardPending, "conflict"),
RuleNotActive: spec(codes.FailedPrecondition, httpStatusConflict, RuleNotActive, "reward rule is not active"),
EventAlreadyConsumed: spec(codes.AlreadyExists, httpStatusConflict, EventAlreadyConsumed, "event already consumed"),
RewardPending: spec(codes.FailedPrecondition, httpStatusConflict, RewardPending, "reward is pending"),
InvalidSection: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidSection, "invalid argument"),
MessageNotFound: spec(codes.NotFound, httpStatusNotFound, MessageNotFound, "not found"),
MessageRecalled: spec(codes.NotFound, httpStatusNotFound, MessageRecalled, "not found"),
PageTokenInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, PageTokenInvalid, "invalid argument"),
RequestConflict: spec(codes.FailedPrecondition, httpStatusConflict, RequestConflict, "conflict"),
ProducerEventConflict: spec(codes.FailedPrecondition, httpStatusConflict, ProducerEventConflict, "conflict"),
RequestConflict: spec(codes.FailedPrecondition, httpStatusConflict, RequestConflict, "request payload does not match existing record"),
ProducerEventConflict: spec(codes.FailedPrecondition, httpStatusConflict, ProducerEventConflict, "producer event payload does not match existing record"),
}
func spec(grpcCode codes.Code, httpStatus int, publicCode Code, publicMessage string) Spec {

36
pkg/xerr/device.go Normal file
View File

@ -0,0 +1,36 @@
package xerr
import (
"fmt"
"strings"
)
// DeviceAlreadyRegisteredMessage 生成同设备注册达到上限时直接展示给 App 的错误文案。
// count 代表当前设备已注册账号数displayUserIDs 按原注册顺序列出短号,便于用户识别原账号。
func DeviceAlreadyRegisteredMessage(count int, displayUserIDs []string) string {
cleaned := make([]string, 0, len(displayUserIDs))
seen := make(map[string]struct{}, len(displayUserIDs))
for _, displayUserID := range displayUserIDs {
displayUserID = strings.TrimSpace(displayUserID)
if displayUserID == "" {
continue
}
if _, ok := seen[displayUserID]; ok {
continue
}
seen[displayUserID] = struct{}{}
cleaned = append(cleaned, displayUserID)
}
if count < len(cleaned) {
// 调用方如果只传了短号列表而没单独计数,展示数量必须至少覆盖实际列出的账号。
count = len(cleaned)
}
if count < 0 {
count = 0
}
if len(cleaned) == 0 {
return fmt.Sprintf("you device already register %d account, login failed", count)
}
return fmt.Sprintf("you device already register %d account %s, login failed", count, strings.Join(cleaned, ","))
}

19
pkg/xerr/device_test.go Normal file
View File

@ -0,0 +1,19 @@
package xerr
import "testing"
func TestDeviceAlreadyRegisteredMessageIncludesCountAndDisplayIDs(t *testing.T) {
got := DeviceAlreadyRegisteredMessage(2, []string{"6400", "168430"})
want := "you device already register 2 account 6400,168430, login failed"
if got != want {
t.Fatalf("DeviceAlreadyRegisteredMessage() = %q, want %q", got, want)
}
}
func TestDeviceAlreadyRegisteredMessageCleansDisplayIDs(t *testing.T) {
got := DeviceAlreadyRegisteredMessage(1, []string{" 6400 ", "", "6400"})
want := "you device already register 1 account 6400, login failed"
if got != want {
t.Fatalf("DeviceAlreadyRegisteredMessage() = %q, want %q", got, want)
}
}

View File

@ -71,7 +71,7 @@ const (
RegionDisabled Code = "REGION_DISABLED"
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
InvalidInviteCode Code = "INVALID_INVITE_CODE"
// DeviceAlreadyRegistered 表示同一注册设备已经创建过账号,不能再次作为三方注册入口
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号
DeviceAlreadyRegistered Code = "DEVICE_ALREADY_REGISTERED"
// InsufficientBalance 表示钱包余额不足。

View File

@ -71,12 +71,12 @@ func TestCatalogMappings(t *testing.T) {
publicMessage: "upstream service error",
},
{
name: "ledger conflict uses aborted grpc but http conflict",
name: "ledger state change uses aborted grpc but actionable http message",
code: LedgerConflict,
grpcCode: codes.Aborted,
httpStatus: httpStatusConflict,
publicCode: string(LedgerConflict),
publicMessage: "conflict",
publicMessage: "wallet state changed, please retry",
},
{
name: "country cooldown keeps actionable app message",
@ -109,6 +109,15 @@ func TestCatalogMappings(t *testing.T) {
}
}
func TestCatalogDoesNotExposeBareConflictMessage(t *testing.T) {
for _, code := range RegisteredCodes() {
spec := SpecOf(code)
if spec.PublicMessage == "conflict" {
t.Fatalf("code %s exposes bare conflict message", code)
}
}
}
func declaredCodesFromSource(t *testing.T) map[Code]string {
t.Helper()

View File

@ -44,6 +44,7 @@ type Client interface {
ListTemporaryRechargeOrders(ctx context.Context, req *walletv1.ListTemporaryRechargeOrdersRequest) (*walletv1.ListTemporaryRechargeOrdersResponse, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
SyncThirdPartyPaymentMethods(ctx context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error)
ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error)
CreateRechargeProduct(ctx context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, req *walletv1.UpdateRechargeProductRequest) (*walletv1.RechargeProductResponse, error)
@ -202,6 +203,10 @@ func (c *GRPCClient) UpdateThirdPartyPaymentRate(ctx context.Context, req *walle
return c.client.UpdateThirdPartyPaymentRate(ctx, req)
}
func (c *GRPCClient) SyncThirdPartyPaymentMethods(ctx context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error) {
return c.client.SyncThirdPartyPaymentMethods(ctx, req)
}
func (c *GRPCClient) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) {
return c.client.ListAdminRechargeProducts(ctx, req)
}

View File

@ -96,6 +96,24 @@ type thirdPartyPaymentMethodDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type thirdPartyPaymentMethodSyncDTO struct {
ProviderCode string `json:"providerCode"`
ScannedCountryCount int32 `json:"scannedCountryCount"`
FetchedCountryCount int32 `json:"fetchedCountryCount"`
CreatedCount int32 `json:"createdCount"`
UpdatedCount int32 `json:"updatedCount"`
SkippedCount int32 `json:"skippedCount"`
FailedCountryCount int `json:"failedCountryCount"`
Issues []thirdPartyPaymentMethodIssueDTO `json:"issues"`
}
type thirdPartyPaymentMethodIssueDTO struct {
CountryCode string `json:"countryCode"`
CurrencyCode string `json:"currencyCode"`
Code string `json:"code"`
Message string `json:"message"`
}
type temporaryPaymentLinkDTO struct {
OrderID string `json:"orderId"`
AppCode string `json:"appCode"`
@ -272,6 +290,31 @@ func thirdPartyPaymentMethodFromProto(item *walletv1.ThirdPartyPaymentMethod) th
}
}
func thirdPartyPaymentMethodSyncFromProto(item *walletv1.SyncThirdPartyPaymentMethodsResponse) thirdPartyPaymentMethodSyncDTO {
if item == nil {
return thirdPartyPaymentMethodSyncDTO{}
}
dto := thirdPartyPaymentMethodSyncDTO{
ProviderCode: item.GetProviderCode(),
ScannedCountryCount: item.GetScannedCountryCount(),
FetchedCountryCount: item.GetFetchedCountryCount(),
CreatedCount: item.GetCreatedCount(),
UpdatedCount: item.GetUpdatedCount(),
SkippedCount: item.GetSkippedCount(),
FailedCountryCount: len(item.GetIssues()),
Issues: make([]thirdPartyPaymentMethodIssueDTO, 0, len(item.GetIssues())),
}
for _, issue := range item.GetIssues() {
dto.Issues = append(dto.Issues, thirdPartyPaymentMethodIssueDTO{
CountryCode: issue.GetCountryCode(),
CurrencyCode: issue.GetCurrencyCode(),
Code: issue.GetCode(),
Message: issue.GetMessage(),
})
}
return dto
}
func temporaryPaymentLinkFromProto(item *walletv1.ExternalRechargeOrder) temporaryPaymentLinkDTO {
if item == nil {
return temporaryPaymentLinkDTO{}

View File

@ -383,6 +383,30 @@ func (h *Handler) UpdateThirdPartyPaymentRate(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) SyncThirdPartyPaymentMethods(c *gin.Context) {
var request thirdPartyPaymentMethodSyncRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "支付方式同步参数不正确")
return
}
}
providerCode := strings.TrimSpace(firstNonEmptyString(request.ProviderCode, firstQuery(c, "provider_code", "providerCode"), "v5pay"))
resp, err := h.wallet.SyncThirdPartyPaymentMethods(c.Request.Context(), &walletv1.SyncThirdPartyPaymentMethodsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ProviderCode: providerCode,
OperatorUserId: actorID(c),
})
if err != nil {
writeWalletError(c, err, "同步支付方式失败")
return
}
dto := thirdPartyPaymentMethodSyncFromProto(resp)
shared.OperationLog(c, h.audit, "sync-third-party-payment-methods", "third_party_payment_methods", "success", dto.ProviderCode)
response.OK(c, dto)
}
func (h *Handler) SyncThirdPartyPaymentRates(c *gin.Context) {
var request thirdPartyPaymentRateSyncRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
@ -473,6 +497,10 @@ type thirdPartyPaymentRateRequest struct {
USDToCurrencyRate string `json:"usdToCurrencyRate"`
}
type thirdPartyPaymentMethodSyncRequest struct {
ProviderCode string `json:"providerCode"`
}
type thirdPartyPaymentRateSyncRequest struct {
MarkupPercent float64 `json:"markupPercent"`
}

View File

@ -248,6 +248,49 @@ func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) {
}
}
func TestSyncThirdPartyPaymentMethodsDefaultsToV5Pay(t *testing.T) {
wallet := &mockPaymentWallet{syncMethodsResp: &walletv1.SyncThirdPartyPaymentMethodsResponse{
ProviderCode: "v5pay",
ScannedCountryCount: 11,
FetchedCountryCount: 8,
CreatedCount: 6,
UpdatedCount: 20,
SkippedCount: 1,
Issues: []*walletv1.ThirdPartyPaymentMethodSyncIssue{{
CountryCode: "SA",
CurrencyCode: "SAR",
Code: "1013",
Message: "app is invalid",
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-methods/sync", bytes.NewBufferString(`{}`))
request.Header.Set("Content-Type", "application/json")
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 wallet.lastSyncMethods == nil ||
wallet.lastSyncMethods.GetAppCode() != "lalu" ||
wallet.lastSyncMethods.GetProviderCode() != "v5pay" ||
wallet.lastSyncMethods.GetOperatorUserId() != 7 {
t.Fatalf("sync methods request mismatch: %+v", wallet.lastSyncMethods)
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != 0 ||
response.Data["providerCode"] != "v5pay" ||
response.Data["createdCount"].(float64) != 6 ||
response.Data["failedCountryCount"].(float64) != 1 {
t.Fatalf("sync methods response mismatch: %+v", response)
}
}
func TestSyncThirdPartyPaymentRatesAppliesMarkupAndCeilsToOneDecimal(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{
@ -368,6 +411,7 @@ func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine {
router.GET("/admin/payment/temporary-links", handler.ListTemporaryPaymentLinks)
router.GET("/admin/payment/temporary-links/:order_id", handler.GetTemporaryPaymentLink)
router.PATCH("/admin/payment/third-party-methods/:method_id/status", handler.SetThirdPartyPaymentMethodStatus)
router.POST("/admin/payment/third-party-methods/sync", handler.SyncThirdPartyPaymentMethods)
router.PATCH("/admin/payment/third-party-rates/:method_id", handler.UpdateThirdPartyPaymentRate)
router.POST("/admin/payment/third-party-rates/sync", handler.SyncThirdPartyPaymentRates)
router.POST("/admin/payment/recharge-products", handler.CreateRechargeProduct)
@ -411,6 +455,8 @@ type mockPaymentWallet struct {
temporaryOrderResp *walletv1.H5RechargeOrderResponse
lastSetMethodStatus *walletv1.SetThirdPartyPaymentMethodStatusRequest
lastUpdateRate *walletv1.UpdateThirdPartyPaymentRateRequest
lastSyncMethods *walletv1.SyncThirdPartyPaymentMethodsRequest
syncMethodsResp *walletv1.SyncThirdPartyPaymentMethodsResponse
updateRateRequests []*walletv1.UpdateThirdPartyPaymentRateRequest
lastCreateProduct *walletv1.CreateRechargeProductRequest
}
@ -481,6 +527,14 @@ func (m *mockPaymentWallet) UpdateThirdPartyPaymentRate(_ context.Context, req *
}}, nil
}
func (m *mockPaymentWallet) SyncThirdPartyPaymentMethods(_ context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error) {
m.lastSyncMethods = req
if m.syncMethodsResp != nil {
return m.syncMethodsResp, nil
}
return &walletv1.SyncThirdPartyPaymentMethodsResponse{ProviderCode: req.GetProviderCode()}, nil
}
type fakeExchangeRateClient struct {
result exchangeRateResult
err error

View File

@ -16,6 +16,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks)
protected.GET("/admin/payment/temporary-links/:order_id", middleware.RequirePermission("payment-temporary-link:view"), h.GetTemporaryPaymentLink)
protected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequirePermission("payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus)
protected.POST("/admin/payment/third-party-methods/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentMethods)
protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate)
protected.POST("/admin/payment/third-party-rates/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentRates)
protected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts)

View File

@ -162,6 +162,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
handler.SetTencentIMAccountImporter(gatewayTencentIMAccountImporter{client: imClient})
handler.SetTencentIMUserMessagePublisher(imClient)
}
handler.SetRoomQueryClient(roomQueryClient)
handler.SetUserDeviceClient(userDeviceClient)

View File

@ -15,6 +15,10 @@ type contextKey string
const (
userContextKey contextKey = "gateway_user_id"
claimsContextKey contextKey = "gateway_claims"
// TokenTypeAccess 是真实用户 access token。
TokenTypeAccess = "access"
// TokenTypePendingRegistration 是只允许完成注册的 pending token。
TokenTypePendingRegistration = "pending_registration"
)
// Claims 是 gateway 从 access token 中消费的最小用户状态快照。
@ -23,6 +27,8 @@ type Claims struct {
AppCode string
UserID int64
SessionID string
TokenType string
PendingRegistrationID string
ProfileCompleted bool
OnboardingStatus string
AccessTokenExpiresAtMS int64
@ -72,6 +78,40 @@ func (v *Verifier) Verify(header string) (Claims, error) {
return Claims{}, fmt.Errorf("invalid claims")
}
tokenType, _ := claims["typ"].(string)
tokenType = strings.TrimSpace(tokenType)
if tokenType == "" {
// 旧测试 token 没有 typ 时按真实用户 access token 处理。
tokenType = TokenTypeAccess
}
profileCompleted, _ := claims["profile_completed"].(bool)
onboardingStatus, _ := claims["onboarding_status"].(string)
sessionID, _ := claims["sid"].(string)
appCode, _ := claims["app_code"].(string)
expiresAtMS := claimUnixSecondsMS(claims["exp"])
if tokenType == TokenTypePendingRegistration {
pendingID, _ := claims["pending_registration_id"].(string)
pendingID = strings.TrimSpace(pendingID)
if pendingID == "" {
return Claims{}, fmt.Errorf("missing pending_registration_id")
}
if strings.TrimSpace(sessionID) == "" {
sessionID = pendingID
}
return Claims{
AppCode: appcode.Normalize(appCode),
SessionID: strings.TrimSpace(sessionID),
TokenType: tokenType,
PendingRegistrationID: pendingID,
ProfileCompleted: false,
OnboardingStatus: strings.TrimSpace(onboardingStatus),
AccessTokenExpiresAtMS: expiresAtMS,
}, nil
}
if tokenType != TokenTypeAccess {
return Claims{}, fmt.Errorf("invalid token type")
}
rawUserID, ok := claims["user_id"].(string)
if !ok || strings.TrimSpace(rawUserID) == "" {
return Claims{}, fmt.Errorf("missing user_id")
@ -81,16 +121,11 @@ func (v *Verifier) Verify(header string) (Claims, error) {
return Claims{}, fmt.Errorf("invalid user_id")
}
profileCompleted, _ := claims["profile_completed"].(bool)
onboardingStatus, _ := claims["onboarding_status"].(string)
sessionID, _ := claims["sid"].(string)
appCode, _ := claims["app_code"].(string)
expiresAtMS := claimUnixSecondsMS(claims["exp"])
return Claims{
AppCode: appcode.Normalize(appCode),
UserID: userID,
SessionID: strings.TrimSpace(sessionID),
TokenType: tokenType,
ProfileCompleted: profileCompleted,
OnboardingStatus: strings.TrimSpace(onboardingStatus),
AccessTokenExpiresAtMS: expiresAtMS,
@ -150,6 +185,18 @@ func SessionIDFromContext(ctx context.Context) string {
return strings.TrimSpace(claims.SessionID)
}
// IsPendingRegistrationFromContext 表示当前请求使用的是待完成注册 token。
func IsPendingRegistrationFromContext(ctx context.Context) bool {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return claims.TokenType == TokenTypePendingRegistration
}
// PendingRegistrationIDFromContext 提取 pending token 对应的注册态 ID。
func PendingRegistrationIDFromContext(ctx context.Context) string {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return strings.TrimSpace(claims.PendingRegistrationID)
}
// ProfileCompletedFromContext 返回 gateway profile gate 的判断依据。
func ProfileCompletedFromContext(ctx context.Context) bool {
claims, _ := ctx.Value(claimsContextKey).(Claims)

View File

@ -19,6 +19,7 @@ type UserAuthClient interface {
RecordLoginBlocked(ctx context.Context, req *userv1.RecordLoginBlockedRequest) (*userv1.RecordLoginBlockedResponse, error)
CheckLoginRiskIPWhitelist(ctx context.Context, req *userv1.CheckLoginRiskIPWhitelistRequest) (*userv1.CheckLoginRiskIPWhitelistResponse, error)
AppHeartbeat(ctx context.Context, req *userv1.AppHeartbeatRequest) (*userv1.AppHeartbeatResponse, error)
CompletePendingThirdPartyRegistration(ctx context.Context, req *userv1.CompletePendingThirdPartyRegistrationRequest) (*userv1.CompleteOnboardingResponse, error)
}
// UserIdentityClient 抽象 gateway 对 user-service 短号能力的依赖。
@ -283,6 +284,10 @@ func (c *grpcUserAuthClient) AppHeartbeat(ctx context.Context, req *userv1.AppHe
return c.client.AppHeartbeat(ctx, req)
}
func (c *grpcUserAuthClient) CompletePendingThirdPartyRegistration(ctx context.Context, req *userv1.CompletePendingThirdPartyRegistrationRequest) (*userv1.CompleteOnboardingResponse, error) {
return c.client.CompletePendingThirdPartyRegistration(ctx, req)
}
func (c *grpcUserIdentityClient) GetUserIdentity(ctx context.Context, req *userv1.GetUserIdentityRequest) (*userv1.GetUserIdentityResponse, error) {
return c.client.GetUserIdentity(ctx, req)
}

View File

@ -11,6 +11,7 @@ import (
// WalletClient 抽象 gateway 对 wallet-service 余额查询能力的依赖。
type WalletClient interface {
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
DebitDirectGift(ctx context.Context, req *walletv1.DebitDirectGiftRequest) (*walletv1.DebitGiftResponse, error)
GetActiveHostSalaryPolicy(ctx context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error)
GetHostSalaryProgress(ctx context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error)
GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error)
@ -72,6 +73,10 @@ func (c *grpcWalletClient) GetBalances(ctx context.Context, req *walletv1.GetBal
return c.client.GetBalances(ctx, req)
}
func (c *grpcWalletClient) DebitDirectGift(ctx context.Context, req *walletv1.DebitDirectGiftRequest) (*walletv1.DebitGiftResponse, error) {
return c.client.DebitDirectGift(ctx, req)
}
func (c *grpcWalletClient) GetActiveHostSalaryPolicy(ctx context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) {
return c.client.GetActiveHostSalaryPolicy(ctx, req)
}

View File

@ -2,6 +2,7 @@ package appapi
import (
"bytes"
"context"
"fmt"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"io"
@ -106,7 +107,7 @@ func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request
return
}
objectKey := buildUploadObjectKey(policy, auth.UserIDFromContext(request.Context()), header.Filename, contentType, time.Now().UTC())
objectKey := buildUploadObjectKey(policy, uploadOwnerSegment(request.Context()), header.Filename, contentType, time.Now().UTC())
objectURL, err := h.objectUploader.PutObject(request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -180,7 +181,15 @@ func isAvatarContentType(contentType string) bool {
}
}
func buildUploadObjectKey(policy uploadPolicy, userID int64, filename string, contentType string, now time.Time) string {
func uploadOwnerSegment(ctx context.Context) string {
if auth.IsPendingRegistrationFromContext(ctx) {
// pending 注册还没有真实 user_id头像只能挂到 pending 命名空间,完成注册后 URL 会写入新用户资料。
return "pending/" + auth.PendingRegistrationIDFromContext(ctx)
}
return fmt.Sprintf("%d", auth.UserIDFromContext(ctx))
}
func buildUploadObjectKey(policy uploadPolicy, ownerSegment string, filename string, contentType string, now time.Time) string {
date := now.UTC().Format("20060102")
extension := normalizedUploadExtension(filename, contentType)
if extension == "" && policy.RequireImage {
@ -188,7 +197,7 @@ func buildUploadObjectKey(policy uploadPolicy, userID int64, filename string, co
}
idPrefix := strings.TrimSuffix(policy.Kind, "s")
return fmt.Sprintf("app/%s/%d/%s/%s%s", policy.Kind, userID, date, idgen.New(idPrefix), extension)
return fmt.Sprintf("app/%s/%s/%s/%s%s", policy.Kind, strings.Trim(ownerSegment, "/"), date, idgen.New(idPrefix), extension)
}
func normalizedUploadExtension(filename string, contentType string) string {

View File

@ -60,6 +60,7 @@ type Handler struct {
leaderboardWalletDB *sql.DB
tencentIM TencentIMConfig
tencentIMImporter TencentIMAccountImporter
tencentIMPublisher TencentIMUserMessagePublisher
tencentRTC TencentRTCConfig
objectUploader ObjectUploader
authRateLimitConfig AuthRateLimitConfig
@ -83,6 +84,11 @@ type TencentIMAccountImporter interface {
ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error
}
// TencentIMUserMessagePublisher 是 gateway 服务端代发 C2C custom message 的最小边界。
type TencentIMUserMessagePublisher interface {
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
}
// TencentRTCConfig 是 gateway transport 层签发客户端腾讯 RTC 进房票据所需的配置。
type TencentRTCConfig struct {
Enabled bool
@ -154,6 +160,11 @@ func (h *Handler) SetTencentIMAccountImporter(importer TencentIMAccountImporter)
h.tencentIMImporter = importer
}
// SetTencentIMUserMessagePublisher 注入服务端 C2C 投递边界nil 时依赖该能力的接口必须 fail-closed。
func (h *Handler) SetTencentIMUserMessagePublisher(publisher TencentIMUserMessagePublisher) {
h.tencentIMPublisher = publisher
}
// SetObjectUploader 注入对象存储上传实现;生产环境使用腾讯云 COS。
func (h *Handler) SetObjectUploader(uploader ObjectUploader) {
h.objectUploader = uploader

View File

@ -111,13 +111,20 @@ func WriteRPCError(writer http.ResponseWriter, request *http.Request, err error)
if err == nil {
return
}
if _, ok := status.FromError(err); !ok {
st, ok := status.FromError(err)
if !ok {
WriteError(writer, request, http.StatusBadGateway, CodeUpstreamError, "upstream service error")
return
}
reason := xerr.ReasonFromGRPC(err)
statusCode, code, message := MapReasonToHTTP(reason)
if reason == xerr.DeviceAlreadyRegistered {
// 设备注册上限错误需要把 user-service 组装的账号数量和短号列表透给客户端。
if detail := strings.TrimSpace(st.Message()); detail != "" {
message = detail
}
}
WriteError(writer, request, statusCode, code, message)
}

View File

@ -250,6 +250,7 @@ type WalletHandlers struct {
ListWalletTransactions http.HandlerFunc
ListCoinSellers http.HandlerFunc
TransferCoinFromSeller http.HandlerFunc
SendDirectGift http.HandlerFunc
GetSalaryWalletOverview http.HandlerFunc
GetSalaryWalletHistory http.HandlerFunc
SearchSalaryWalletSeller http.HandlerFunc
@ -593,6 +594,7 @@ func (r routes) registerWalletRoutes() {
r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions)
r.profile("/wallet/coin-sellers", http.MethodGet, h.ListCoinSellers)
r.profile("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller)
r.profile("/messages/direct-gifts/send", http.MethodPost, h.SendDirectGift)
r.profile("/salary-wallet/overview", http.MethodGet, h.GetSalaryWalletOverview)
r.profile("/salary-wallet/history", http.MethodGet, h.GetSalaryWalletHistory)
r.profile("/salary-wallet/coin-sellers/search", http.MethodGet, h.SearchSalaryWalletSeller)

View File

@ -51,6 +51,14 @@ func (h *Handler) withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) ht
}
ctx := auth.WithClaims(request.Context(), claims)
if claims.TokenType == auth.TokenTypePendingRegistration {
if !pendingRegistrationAllowedPath(request.URL.Path) {
httpkit.WriteError(writer, request.WithContext(ctx), http.StatusForbidden, httpkit.CodeProfileRequired, "profile required")
return
}
next.ServeHTTP(writer, request.WithContext(ctx))
return
}
revoked, err := h.revokedSession(ctx, claims.AppCode, claims.SessionID)
if err != nil {
logx.Warn(ctx, "session_denylist_read_failed", slog.String("component", "gateway_auth"), slog.String("session_id", claims.SessionID), slog.String("error", err.Error()))
@ -63,6 +71,15 @@ func (h *Handler) withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) ht
})
}
func pendingRegistrationAllowedPath(path string) bool {
switch strings.TrimSpace(path) {
case "/api/v1/files/avatar/upload", "/api/v1/users/me/onboarding/complete":
return true
default:
return false
}
}
func (h *Handler) withOptionalAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
header := strings.TrimSpace(request.Header.Get("Authorization"))

View File

@ -25,6 +25,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/appconfig"
"hyapp/services/gateway-service/internal/auth"
@ -295,6 +296,7 @@ type fakeUserAuthClient struct {
lastBlocked *userv1.RecordLoginBlockedRequest
lastWhitelist *userv1.CheckLoginRiskIPWhitelistRequest
lastAppHeartbeat *userv1.AppHeartbeatRequest
lastPendingComplete *userv1.CompletePendingThirdPartyRegistrationRequest
loginPasswordCount int
loginThirdCount int
refreshCount int
@ -302,6 +304,8 @@ type fakeUserAuthClient struct {
blockedCount int
whitelisted bool
loginErr error
loginThirdResp *userv1.AuthResponse
pendingCompleteErr error
}
type fakeUserProfileClient struct {
@ -348,6 +352,16 @@ func (f *fakeTencentIMAccountImporter) ImportAccount(_ context.Context, userID i
return f.err
}
type fakeTencentIMUserMessagePublisher struct {
messages []tencentim.CustomUserMessage
err error
}
func (f *fakeTencentIMUserMessagePublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error {
f.messages = append(f.messages, message)
return f.err
}
type fakeUserIdentityClient struct {
lastResolve *userv1.ResolveDisplayUserIDRequest
resolveByDisplay map[string]int64
@ -486,6 +500,9 @@ type fakeWalletClient struct {
resp *walletv1.GetBalancesResponse
balancesByUserID map[int64]*walletv1.GetBalancesResponse
err error
lastDirectGift *walletv1.DebitDirectGiftRequest
directGiftResp *walletv1.DebitGiftResponse
directGiftErr error
lastHostSalaryPolicy *walletv1.GetActiveHostSalaryPolicyRequest
hostSalaryPolicyResp *walletv1.GetActiveHostSalaryPolicyResponse
hostSalaryPolicyErr error
@ -646,6 +663,13 @@ func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginP
func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error) {
f.lastLoginThirdParty = req
f.loginThirdCount++
if f.loginErr != nil {
return nil, f.loginErr
}
if f.loginThirdResp != nil {
return f.loginThirdResp, nil
}
return &userv1.AuthResponse{
Token: &userv1.AuthToken{
UserId: 10001,
@ -721,6 +745,41 @@ func (f *fakeUserAuthClient) AppHeartbeat(_ context.Context, req *userv1.AppHear
}}, nil
}
func (f *fakeUserAuthClient) CompletePendingThirdPartyRegistration(_ context.Context, req *userv1.CompletePendingThirdPartyRegistrationRequest) (*userv1.CompleteOnboardingResponse, error) {
f.lastPendingComplete = req
if f.pendingCompleteErr != nil {
return nil, f.pendingCompleteErr
}
return &userv1.CompleteOnboardingResponse{
User: &userv1.User{
UserId: 10088,
DisplayUserId: "168888",
Username: req.GetUsername(),
Avatar: req.GetAvatar(),
Gender: req.GetGender(),
Country: req.GetCountry(),
RegionId: 66,
ProfileCompleted: true,
ProfileCompletedAtMs: 1_778_000_006_000,
OnboardingStatus: "completed",
},
Token: &userv1.AuthToken{
UserId: 10088,
DisplayUserId: "168888",
SessionId: "sess-completed",
AccessToken: "access-pending-completed",
RefreshToken: "refresh-pending-completed",
ExpiresInSec: 1800,
TokenType: "Bearer",
ProfileCompleted: true,
OnboardingStatus: "completed",
},
Invite: &userv1.InviteBinding{Bound: true, InviteCode: req.GetInviteCode(), InviterUserId: 10001},
ProfileCompleted: true,
OnboardingStatus: "completed",
}, nil
}
func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
f.lastGet = req
f.getRequests = append(f.getRequests, req)
@ -1649,6 +1708,17 @@ func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalan
return &walletv1.GetBalancesResponse{}, nil
}
func (f *fakeWalletClient) DebitDirectGift(_ context.Context, req *walletv1.DebitDirectGiftRequest) (*walletv1.DebitGiftResponse, error) {
f.lastDirectGift = req
if f.directGiftErr != nil {
return nil, f.directGiftErr
}
if f.directGiftResp != nil {
return f.directGiftResp, nil
}
return &walletv1.DebitGiftResponse{}, nil
}
func (f *fakeWalletClient) GetActiveHostSalaryPolicy(_ context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) {
f.lastHostSalaryPolicy = req
if f.hostSalaryPolicyErr != nil {
@ -5427,6 +5497,52 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
}
}
func TestThirdPartyLoginPendingResponseKeepsFlutterCompatibleEmptyUserIDs(t *testing.T) {
isNewUser := true
userClient := &fakeUserAuthClient{
loginThirdResp: &userv1.AuthResponse{
Token: &userv1.AuthToken{
AppCode: "lalu",
SessionId: "tpr-test",
AccessToken: "pending-access",
ExpiresInSec: 1800,
TokenType: "Bearer",
ProfileCompleted: false,
OnboardingStatus: "profile_required",
},
IsNewUser: isNewUser,
ProfileCompleted: false,
OnboardingStatus: "profile_required",
},
}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader([]byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"dev-1","platform":"ios"}`)))
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())
}
var envelope httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := envelope.Data.(map[string]any)
if !ok {
t.Fatalf("data must be object: %#v", envelope.Data)
}
if data["access_token"] != "pending-access" || data["profile_completed"] != false || data["is_new_user"] != true {
t.Fatalf("pending auth flags mismatch: %#v", data)
}
if _, ok := data["user_id"]; ok {
t.Fatalf("pending response must omit user_id instead of returning 0: %#v", data)
}
if _, ok := data["display_user_id"]; ok {
t.Fatalf("pending response must omit display_user_id before short id allocation: %#v", data)
}
}
func TestAuthClientIPPrefersForwardedPublicIPOverProxyRealIP(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil)
request.RemoteAddr = "10.0.0.9:54123"
@ -8039,6 +8155,113 @@ func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T
}
}
func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) {
imPublisher := &fakeTencentIMUserMessagePublisher{}
walletClient := &fakeWalletClient{directGiftResp: &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt-direct-gift",
TransactionId: "wtx-direct-gift",
CoinSpent: 200,
ChargeAssetType: "COIN",
ChargeAmount: 200,
GiftTypeCode: "normal",
GiftName: "Rose",
GiftIconUrl: "https://cdn.example/gifts/rose.png",
GiftAnimationUrl: "https://cdn.example/gifts/rose.mp4",
GiftEffectTypes: []string{"mp4"},
PriceVersion: "v1",
BalanceAfter: 800,
GiftIncomeCoinAmount: 60,
GiftIncomeBalanceAfter: 460,
HostPeriodDiamondAdded: 200,
HostPeriodCycleKey: "2026-06",
}}
profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 2002}}
hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{
UserId: 900002,
Status: "active",
RegionId: 8801,
CurrentAgencyOwnerUserId: 30001,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
handler.SetTencentIMUserMessagePublisher(imPublisher)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"commandId":"cmd-direct-gift","targetUserId":900002,"giftId":"rose","giftCount":2,"region_id":9999}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/messages/direct-gifts/send", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-direct-gift")
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 walletClient.lastDirectGift == nil ||
walletClient.lastDirectGift.GetCommandId() != "cmd-direct-gift" ||
walletClient.lastDirectGift.GetSenderUserId() != 42 ||
walletClient.lastDirectGift.GetTargetUserId() != 900002 ||
walletClient.lastDirectGift.GetGiftId() != "rose" ||
walletClient.lastDirectGift.GetGiftCount() != 2 ||
walletClient.lastDirectGift.GetRegionId() != 1001 ||
walletClient.lastDirectGift.GetSenderRegionId() != 1001 ||
!walletClient.lastDirectGift.GetTargetIsHost() ||
walletClient.lastDirectGift.GetTargetHostRegionId() != 8801 ||
walletClient.lastDirectGift.GetTargetAgencyOwnerUserId() != 30001 {
t.Fatalf("direct gift wallet request mismatch: %+v", walletClient.lastDirectGift)
}
if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 900002 {
t.Fatalf("direct gift must resolve sender and target through user-service: %+v", profileClient.getRequests)
}
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 900002 {
t.Fatalf("direct gift target host scope was not resolved: %+v", hostClient.lastHost)
}
if len(imPublisher.messages) != 1 {
t.Fatalf("direct gift must publish exactly one C2C message: %+v", imPublisher.messages)
}
imMessage := imPublisher.messages[0]
if imMessage.ToAccount != "900002" ||
imMessage.FromAccount != "42" ||
imMessage.SyncOtherMachine != 1 ||
imMessage.EventID != "direct_gift:lalu:cmd-direct-gift" ||
imMessage.Desc != "gift" ||
imMessage.Ext != "direct_message_gift" {
t.Fatalf("direct gift C2C message mismatch: %+v", imMessage)
}
var imPayload map[string]any
if err := json.Unmarshal(imMessage.PayloadJSON, &imPayload); err != nil {
t.Fatalf("decode direct gift im payload failed: %v", err)
}
if imPayload["type"] != "gift" ||
imPayload["event_id"] != "direct_gift:lalu:cmd-direct-gift" ||
imPayload["billing_receipt_id"] != "receipt-direct-gift" ||
imPayload["gift_income_coin_amount"] != float64(60) ||
imPayload["gift_name"] != "Rose" ||
imPayload["gift_count"] != float64(2) {
t.Fatalf("direct gift im payload mismatch: %+v", imPayload)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
message, messageOK := data["message"].(map[string]any)
if response.Code != httpkit.CodeOK || !ok ||
data["billing_receipt_id"] != "receipt-direct-gift" ||
data["event_id"] != "direct_gift:lalu:cmd-direct-gift" ||
data["coin_balance_after"] != float64(800) ||
data["gift_income_coin_amount"] != float64(60) ||
data["gift_income_balance_after"] != float64(460) ||
data["gift_name"] != "Rose" ||
!messageOK ||
message["custom_description"] != "gift" ||
message["custom_extension"] != "direct_message_gift" ||
message["message_id"] != "direct_gift:lalu:cmd-direct-gift" {
t.Fatalf("direct gift response mismatch: %+v", response)
}
}
func TestCoinSellerTransferAllowsSelfTargetAndSkipsDuplicateRegionLookup(t *testing.T) {
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
TransactionId: "wtx-coin-seller-self",
@ -8249,6 +8472,45 @@ func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *
}
}
func TestPendingRegistrationCompleteUsesAuthClientAndDoesNotRequireUserID(t *testing.T) {
userClient := &fakeUserAuthClient{}
profileClient := &fakeUserProfileClient{}
router := NewHandlerWithClients(&fakeRoomClient{}, userClient, nil, profileClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"male","country":"SG","invite_code":"A1B2C3"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/onboarding/complete", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signPendingRegistrationToken(t, "secret", "tpr-test"))
request.Header.Set("X-Request-ID", "req-pending-onboarding")
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.lastComplete != nil {
t.Fatalf("pending registration must not call profile CompleteOnboarding: %+v", profileClient.lastComplete)
}
if userClient.lastPendingComplete == nil || userClient.lastPendingComplete.GetPendingRegistrationId() != "tpr-test" {
t.Fatalf("pending complete request was not sent: %+v", userClient.lastPendingComplete)
}
if userClient.lastPendingComplete.GetMeta().GetSessionId() != "tpr-test" || userClient.lastPendingComplete.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-pending-onboarding") {
t.Fatalf("pending complete meta mismatch: %+v", userClient.lastPendingComplete.GetMeta())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || data["profile_completed"] != true {
t.Fatalf("unexpected pending complete response: %+v", response)
}
token, ok := data["token"].(map[string]any)
if !ok || token["access_token"] != "access-pending-completed" || token["user_id"] != "10088" || token["profile_completed"] != true {
t.Fatalf("pending complete must return real user token: %+v", data["token"])
}
}
func TestCompleteOnboardingReturnsInvalidInviteCodeMessage(t *testing.T) {
profileClient := &fakeUserProfileClient{
completeErr: xerr.ToGRPCError(xerr.New(xerr.InvalidInviteCode, "invalid invite code")),
@ -8294,6 +8556,18 @@ func TestProfileMutationEndpointsRequireCompletedProfileToken(t *testing.T) {
}
}
func TestPendingRegistrationTokenIsRejectedOutsideRegistrationWhitelist(t *testing.T) {
router := NewHandlerWithClients(&fakeRoomClient{}, &fakeUserAuthClient{}, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/display-id/change", bytes.NewReader([]byte(`{"display_user_id":"168888"}`)))
request.Header.Set("Authorization", "Bearer "+signPendingRegistrationToken(t, "secret", "tpr-test"))
request.Header.Set("X-Request-ID", "req-pending-denied")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeProfileRequired, "req-pending-denied")
}
func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testing.T) {
tests := []struct {
name string
@ -8315,6 +8589,7 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
{name: "message_read", method: http.MethodPost, path: "/api/v1/messages/msg-1/read"},
{name: "message_read_all", method: http.MethodPost, path: "/api/v1/messages/read-all", body: `{"section":"system"}`},
{name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"},
{name: "direct_gift", method: http.MethodPost, path: "/api/v1/messages/direct-gifts/send", body: `{"command_id":"cmd-direct-gift","target_user_id":900002,"gift_id":"rose","gift_count":1}`},
{name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"},
}
@ -8425,6 +8700,29 @@ func TestUploadAvatarAllowsIncompleteProfileAndWritesObject(t *testing.T) {
}
}
func TestUploadAvatarWithPendingRegistrationTokenWritesPendingObjectKey(t *testing.T) {
uploader := &fakeObjectUploader{}
handler := NewHandler(&fakeRoomClient{})
handler.SetObjectUploader(uploader)
router := handler.Routes(auth.NewVerifier("secret"))
payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, 32)...)
body, contentType := multipartUploadBody(t, "file", "avatar.png", "image/png", payload)
request := httptest.NewRequest(http.MethodPost, "/api/v1/files/avatar/upload", body)
request.Header.Set("Content-Type", contentType)
request.Header.Set("Authorization", "Bearer "+signPendingRegistrationToken(t, "secret", "tpr-test"))
request.Header.Set("X-Request-ID", "req-upload-pending-avatar")
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 !strings.HasPrefix(uploader.lastKey, "app/avatars/pending/tpr-test/") || !strings.HasSuffix(uploader.lastKey, ".png") {
t.Fatalf("pending avatar must not use user_id=0 key: %q", uploader.lastKey)
}
}
func TestUploadAvatarRejectsSpoofedImageContentType(t *testing.T) {
uploader := &fakeObjectUploader{}
handler := NewHandler(&fakeRoomClient{})
@ -8472,10 +8770,23 @@ func TestAuthLoginMapsGRPCReason(t *testing.T) {
assertEnvelope(t, recorder, http.StatusUnauthorized, string(xerr.AuthFailed), "req-auth-failed")
}
func TestThirdPartyLoginMapsDeviceAlreadyRegisteredMessage(t *testing.T) {
userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.DeviceAlreadyRegistered, "you device already register 2 account 6400,168430, login failed"))}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"android_91d831f9fa382518","platform":"android"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-device-registered")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.DeviceAlreadyRegistered), "you device already register 2 account 6400,168430, login failed", "req-device-registered")
}
func TestMapReasonToHTTPMapsGenericConflict(t *testing.T) {
// room-service 的麦位占用、幂等 payload 冲突等通用业务冲突必须稳定透出 409。
statusCode, code, message := httpkit.MapReasonToHTTP(xerr.Conflict)
if statusCode != http.StatusConflict || code != string(xerr.Conflict) || message != "conflict" {
if statusCode != http.StatusConflict || code != string(xerr.Conflict) || message != "request cannot be completed in current state" {
t.Fatalf("generic conflict mapping mismatch: status=%d code=%s message=%s", statusCode, code, message)
}
}
@ -9335,6 +9646,27 @@ func signGatewayTokenWithExpiresAt(t *testing.T, secret string, userID int64, pr
return signed
}
func signPendingRegistrationToken(t *testing.T, secret string, pendingID string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"app_code": "lalu",
"sub": pendingID,
"sid": pendingID,
"typ": "pending_registration",
"pending_registration_id": pendingID,
"profile_completed": false,
"onboarding_status": "profile_required",
"exp": time.Now().Add(time.Hour).Unix(),
})
signed, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign pending token failed: %v", err)
}
return signed
}
func multipartUploadBody(t *testing.T, fieldName string, filename string, contentType string, payload []byte) (*bytes.Buffer, string) {
t.Helper()

View File

@ -51,10 +51,13 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
UserIdentityClient: h.userIdentityClient,
UserProfileClient: h.userProfileClient,
UserHostClient: h.userHostClient,
IMAccountImporter: h.tencentIMImporter,
IMPublisher: h.tencentIMPublisher,
IMGroupIDPrefix: h.tencentIM.GroupIDPrefix,
ObjectUploader: h.objectUploader,
})
userAPI := userapi.New(userapi.Config{
UserAuthClient: h.userClient,
UserIdentityClient: h.userIdentityClient,
UserProfileClient: h.userProfileClient,
UserSocialClient: h.userSocialClient,

View File

@ -10,6 +10,7 @@ import (
// Handler owns user-facing HTTP endpoints and relationship endpoints.
// It delegates user facts to user-service and only translates HTTP protocol shape.
type Handler struct {
userAuthClient client.UserAuthClient
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
userSocialClient client.UserSocialClient
@ -23,6 +24,7 @@ type Handler struct {
}
type Config struct {
UserAuthClient client.UserAuthClient
UserIdentityClient client.UserIdentityClient
UserProfileClient client.UserProfileClient
UserSocialClient client.UserSocialClient
@ -37,6 +39,7 @@ type Config struct {
func New(config Config) *Handler {
return &Handler{
userAuthClient: config.UserAuthClient,
userIdentityClient: config.UserIdentityClient,
userProfileClient: config.UserProfileClient,
userSocialClient: config.UserSocialClient,

View File

@ -260,6 +260,10 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http
if !httpkit.Decode(writer, request, &body) {
return
}
if auth.IsPendingRegistrationFromContext(request.Context()) {
h.completePendingThirdPartyRegistration(writer, request, body.Username, body.Avatar, body.Gender, body.Country, body.InviteCode)
return
}
if h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
@ -286,6 +290,37 @@ func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http
})
}
func (h *Handler) completePendingThirdPartyRegistration(writer http.ResponseWriter, request *http.Request, username string, avatar string, gender string, country string, inviteCode string) {
if h.userAuthClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
pendingID := auth.PendingRegistrationIDFromContext(request.Context())
if pendingID == "" {
httpkit.WriteError(writer, request, http.StatusUnauthorized, httpkit.CodeUnauthorized, "unauthorized")
return
}
resp, err := h.userAuthClient.CompletePendingThirdPartyRegistration(request.Context(), &userv1.CompletePendingThirdPartyRegistrationRequest{
Meta: httpkit.UserMeta(request, ""),
PendingRegistrationId: pendingID,
Username: username,
Avatar: avatar,
Gender: gender,
Country: country,
InviteCode: inviteCode,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, completeOnboardingData{
userProfileData: profileData(resp.GetUser(), 0),
Invite: inviteBindingDataFromProto(resp.GetInvite()),
Token: authData(resp.GetToken(), nil),
})
}
// getMyProfile 返回当前登录用户的基础资料。
func (h *Handler) getMyProfile(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {

View File

@ -0,0 +1,417 @@
package walletapi
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const (
directGiftIMDesc = "gift"
directGiftIMExt = "direct_message_gift"
directGiftIMPreviewText = "[Gift]"
)
type directGiftRequestBody struct {
CommandID string `json:"command_id"`
CommandIDCamel string `json:"commandId"`
TargetUserID int64 `json:"target_user_id"`
TargetUserIDCamel int64 `json:"targetUserId"`
GiftID string `json:"gift_id"`
GiftIDCamel string `json:"giftId"`
GiftCount int32 `json:"gift_count"`
GiftCountCamel int32 `json:"giftCount"`
PriceVersion string `json:"price_version"`
PriceVersionCamel string `json:"priceVersion"`
EntitlementID string `json:"entitlement_id"`
EntitlementIDCamel string `json:"entitlementId"`
Source string `json:"source"`
}
type directGiftHostScope struct {
targetIsHost bool
targetHostRegionID int64
targetAgencyOwnerUserID int64
}
type directGiftSettlementData struct {
BillingReceiptID string `json:"billing_receipt_id"`
TransactionID string `json:"transaction_id"`
EventID string `json:"event_id"`
CommandID string `json:"command_id"`
AppCode string `json:"app_code"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
GiftID string `json:"gift_id"`
GiftName string `json:"gift_name"`
GiftIconURL string `json:"gift_icon_url"`
GiftAnimationURL string `json:"gift_animation_url"`
GiftEffectTypes []string `json:"gift_effect_types"`
GiftCount int32 `json:"gift_count"`
GiftTypeCode string `json:"gift_type_code"`
CoinSpent int64 `json:"coin_spent"`
ChargeAssetType string `json:"charge_asset_type"`
ChargeAmount int64 `json:"charge_amount"`
ChargeSource string `json:"charge_source"`
PriceVersion string `json:"price_version"`
BalanceAfter int64 `json:"balance_after"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
GiftIncomeCoinAmount int64 `json:"gift_income_coin_amount"`
GiftIncomeBalanceAfter int64 `json:"gift_income_balance_after"`
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"`
HostPeriodCycleKey string `json:"host_period_cycle_key"`
EntitlementID string `json:"entitlement_id,omitempty"`
Message *directGiftMessageData `json:"message,omitempty"`
}
type directGiftMessageData struct {
MessageID string `json:"message_id"`
PeerUserID string `json:"peer_user_id"`
SenderUserID string `json:"sender_user_id"`
Text string `json:"text"`
CustomData string `json:"custom_data"`
CustomDescription string `json:"custom_description"`
CustomExtension string `json:"custom_extension"`
CloudCustomData string `json:"cloud_custom_data"`
SentAtMS int64 `json:"sent_at_ms"`
IsMine bool `json:"is_mine"`
}
type directGiftIMPayload struct {
Type string `json:"type"`
EventID string `json:"event_id"`
CommandID string `json:"command_id"`
AppCode string `json:"app_code"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
GiftID string `json:"gift_id"`
GiftName string `json:"gift_name"`
GiftIconURL string `json:"gift_icon_url"`
GiftAnimationURL string `json:"gift_animation_url"`
GiftCount int32 `json:"gift_count"`
GiftPriceCoin int64 `json:"gift_price_coin"`
MetadataJSON string `json:"metadata_json"`
EffectTypes []string `json:"effect_types"`
BillingReceiptID string `json:"billing_receipt_id"`
TransactionID string `json:"transaction_id"`
ChargeAssetType string `json:"charge_asset_type"`
ChargeAmount int64 `json:"charge_amount"`
ChargeSource string `json:"source,omitempty"`
PriceVersion string `json:"price_version,omitempty"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
GiftIncomeCoinAmount int64 `json:"gift_income_coin_amount"`
GiftIncomeBalanceAfter int64 `json:"gift_income_balance_after"`
EntitlementID string `json:"entitlement_id,omitempty"`
}
func (b directGiftRequestBody) normalizedCommandID() string {
if commandID := strings.TrimSpace(b.CommandID); commandID != "" {
return commandID
}
return strings.TrimSpace(b.CommandIDCamel)
}
func (b directGiftRequestBody) normalizedTargetUserID() int64 {
if b.TargetUserID > 0 {
return b.TargetUserID
}
return b.TargetUserIDCamel
}
func (b directGiftRequestBody) normalizedGiftID() string {
if giftID := strings.TrimSpace(b.GiftID); giftID != "" {
return giftID
}
return strings.TrimSpace(b.GiftIDCamel)
}
func (b directGiftRequestBody) normalizedGiftCount() int32 {
if b.GiftCount > 0 {
return b.GiftCount
}
return b.GiftCountCamel
}
func (b directGiftRequestBody) normalizedPriceVersion() string {
if priceVersion := strings.TrimSpace(b.PriceVersion); priceVersion != "" {
return priceVersion
}
return strings.TrimSpace(b.PriceVersionCamel)
}
func (b directGiftRequestBody) normalizedEntitlementID() string {
if entitlementID := strings.TrimSpace(b.EntitlementID); entitlementID != "" {
return entitlementID
}
return strings.TrimSpace(b.EntitlementIDCamel)
}
// sendDirectGift 完成私聊送礼账务结算,并由服务端发送腾讯云 IM C2C custom message。
func (h *Handler) sendDirectGift(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
if h.imPublisher == nil {
// 私聊送礼的可见事实现在由服务端 C2C 承担;没有 publisher 时不能先扣费再让客户端补发。
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body directGiftRequestBody
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := body.normalizedCommandID()
targetUserID := body.normalizedTargetUserID()
giftID := body.normalizedGiftID()
giftCount := body.normalizedGiftCount()
if commandID == "" || targetUserID <= 0 || giftID == "" || giftCount <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
senderUserID := auth.UserIDFromContext(request.Context())
sender, senderRegionID, ok := h.resolveDirectGiftSender(writer, request, senderUserID)
if !ok {
return
}
target, ok := h.resolveDirectGiftTarget(writer, request, targetUserID)
if !ok {
return
}
hostScope, err := h.resolveDirectGiftTargetHostScope(request, targetUserID)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
if !h.ensureDirectGiftIMAccounts(writer, request, sender, target) {
return
}
app := appcode.FromContext(request.Context())
resp, err := h.walletClient.DebitDirectGift(request.Context(), &walletv1.DebitDirectGiftRequest{
CommandId: commandID,
SenderUserId: senderUserID,
TargetUserId: targetUserID,
GiftId: giftID,
GiftCount: giftCount,
PriceVersion: body.normalizedPriceVersion(),
AppCode: app,
RegionId: senderRegionID,
SenderRegionId: senderRegionID,
TargetIsHost: hostScope.targetIsHost,
TargetHostRegionId: hostScope.targetHostRegionID,
TargetAgencyOwnerUserId: hostScope.targetAgencyOwnerUserID,
EntitlementId: body.normalizedEntitlementID(),
ChargeSource: strings.TrimSpace(body.Source),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
settlement := directGiftSettlementFromProto(app, commandID, senderUserID, targetUserID, giftID, giftCount, resp)
_, message, ok := h.publishDirectGiftIM(writer, request, senderUserID, targetUserID, settlement)
if !ok {
return
}
settlement.Message = message
httpkit.WriteOK(writer, request, settlement)
}
func (h *Handler) resolveDirectGiftSender(writer http.ResponseWriter, request *http.Request, senderUserID int64) (*userv1.User, int64, bool) {
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: senderUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return nil, 0, false
}
user := resp.GetUser()
if user == nil || user.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return nil, 0, false
}
// 私聊没有房间 visible_region_id结算区域只能来自当前用户资料不能让客户端提交 region_id。
return user, user.GetRegionId(), true
}
func (h *Handler) resolveDirectGiftTarget(writer http.ResponseWriter, request *http.Request, targetUserID int64) (*userv1.User, bool) {
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: targetUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return nil, false
}
if resp.GetUser() == nil || resp.GetUser().GetUserId() <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return nil, false
}
return resp.GetUser(), true
}
func (h *Handler) resolveDirectGiftTargetHostScope(request *http.Request, targetUserID int64) (directGiftHostScope, error) {
if targetUserID <= 0 || h.userHostClient == nil {
// host client 缺失时不阻断普通私聊送礼,但主播周期工资入账必须 fail-closed避免客户端伪造主播身份。
return directGiftHostScope{}, nil
}
resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: targetUserID,
})
if err != nil {
if xerr.ReasonFromGRPC(err) == xerr.NotFound {
return directGiftHostScope{}, nil
}
return directGiftHostScope{}, err
}
profile := resp.GetHostProfile()
if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
return directGiftHostScope{}, nil
}
return directGiftHostScope{
targetIsHost: true,
targetHostRegionID: profile.GetRegionId(),
targetAgencyOwnerUserID: profile.GetCurrentAgencyOwnerUserId(),
}, nil
}
func (h *Handler) ensureDirectGiftIMAccounts(writer http.ResponseWriter, request *http.Request, sender *userv1.User, target *userv1.User) bool {
if h.imAccountImporter == nil {
// 测试 fake publisher 或已有账号环境可以不注入 importer生产 app 初始化会注入 REST importer。
return true
}
for _, user := range []*userv1.User{sender, target} {
if user == nil || user.GetUserId() <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return false
}
if err := h.imAccountImporter.ImportAccount(request.Context(), user.GetUserId(), user.GetUsername(), user.GetAvatar()); err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return false
}
}
return true
}
func (h *Handler) publishDirectGiftIM(writer http.ResponseWriter, request *http.Request, senderUserID int64, targetUserID int64, settlement directGiftSettlementData) (json.RawMessage, *directGiftMessageData, bool) {
payloadJSON, err := directGiftPayloadJSON(settlement)
if err != nil {
httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal error")
return nil, nil, false
}
if err := h.imPublisher.PublishUserCustomMessage(request.Context(), tencentim.CustomUserMessage{
ToAccount: tencentim.FormatUserID(targetUserID),
FromAccount: tencentim.FormatUserID(senderUserID),
SyncOtherMachine: 1,
EventID: settlement.EventID,
Desc: directGiftIMDesc,
Ext: directGiftIMExt,
PayloadJSON: payloadJSON,
}); err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return nil, nil, false
}
sentAtMS := time.Now().UTC().UnixMilli()
message := &directGiftMessageData{
MessageID: settlement.EventID,
PeerUserID: strconv.FormatInt(targetUserID, 10),
SenderUserID: strconv.FormatInt(senderUserID, 10),
Text: directGiftIMPreviewText,
CustomData: string(payloadJSON),
CustomDescription: directGiftIMDesc,
CustomExtension: directGiftIMExt,
CloudCustomData: string(payloadJSON),
SentAtMS: sentAtMS,
IsMine: true,
}
return payloadJSON, message, true
}
func directGiftPayloadJSON(settlement directGiftSettlementData) (json.RawMessage, error) {
payload := directGiftIMPayload{
Type: directGiftIMDesc,
EventID: settlement.EventID,
CommandID: settlement.CommandID,
AppCode: settlement.AppCode,
SenderUserID: settlement.SenderUserID,
TargetUserID: settlement.TargetUserID,
GiftID: settlement.GiftID,
GiftName: settlement.GiftName,
GiftIconURL: settlement.GiftIconURL,
GiftAnimationURL: settlement.GiftAnimationURL,
GiftCount: settlement.GiftCount,
GiftPriceCoin: directGiftUnitCoinPrice(settlement),
MetadataJSON: "",
EffectTypes: settlement.GiftEffectTypes,
BillingReceiptID: settlement.BillingReceiptID,
TransactionID: settlement.TransactionID,
ChargeAssetType: settlement.ChargeAssetType,
ChargeAmount: settlement.ChargeAmount,
ChargeSource: settlement.ChargeSource,
PriceVersion: settlement.PriceVersion,
CoinBalanceAfter: settlement.CoinBalanceAfter,
GiftIncomeCoinAmount: settlement.GiftIncomeCoinAmount,
GiftIncomeBalanceAfter: settlement.GiftIncomeBalanceAfter,
EntitlementID: settlement.EntitlementID,
}
return json.Marshal(payload)
}
func directGiftUnitCoinPrice(settlement directGiftSettlementData) int64 {
if settlement.GiftCount <= 0 || settlement.CoinSpent <= 0 {
return 0
}
return settlement.CoinSpent / int64(settlement.GiftCount)
}
func directGiftSettlementFromProto(app string, commandID string, senderUserID int64, targetUserID int64, giftID string, giftCount int32, resp *walletv1.DebitGiftResponse) directGiftSettlementData {
normalizedCommandID := strings.TrimSpace(commandID)
return directGiftSettlementData{
BillingReceiptID: resp.GetBillingReceiptId(),
TransactionID: resp.GetTransactionId(),
EventID: directGiftEventID(app, normalizedCommandID),
CommandID: normalizedCommandID,
AppCode: appcode.Normalize(app),
SenderUserID: senderUserID,
TargetUserID: targetUserID,
GiftID: strings.TrimSpace(giftID),
GiftName: resp.GetGiftName(),
GiftIconURL: resp.GetGiftIconUrl(),
GiftAnimationURL: resp.GetGiftAnimationUrl(),
GiftEffectTypes: resp.GetGiftEffectTypes(),
GiftCount: giftCount,
GiftTypeCode: resp.GetGiftTypeCode(),
CoinSpent: resp.GetCoinSpent(),
ChargeAssetType: resp.GetChargeAssetType(),
ChargeAmount: resp.GetChargeAmount(),
ChargeSource: resp.GetChargeSource(),
PriceVersion: resp.GetPriceVersion(),
BalanceAfter: resp.GetBalanceAfter(),
CoinBalanceAfter: resp.GetBalanceAfter(),
GiftIncomeCoinAmount: resp.GetGiftIncomeCoinAmount(),
GiftIncomeBalanceAfter: resp.GetGiftIncomeBalanceAfter(),
HostPeriodDiamondAdded: resp.GetHostPeriodDiamondAdded(),
HostPeriodCycleKey: resp.GetHostPeriodCycleKey(),
EntitlementID: resp.GetEntitlementId(),
}
}
func directGiftEventID(app string, commandID string) string {
normalizedApp := appcode.Normalize(app)
if normalizedApp == "" {
normalizedApp = appcode.Default
}
return "direct_gift:" + normalizedApp + ":" + strings.TrimSpace(commandID)
}

View File

@ -5,6 +5,7 @@ import (
"io"
"net/http"
"hyapp/pkg/tencentim"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
@ -18,10 +19,22 @@ type Handler struct {
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
userHostClient client.UserHostClient
imAccountImporter TencentIMAccountImporter
imPublisher TencentIMUserMessagePublisher
imGroupIDPrefix string
objectUploader ObjectUploader
}
// TencentIMAccountImporter 只负责把 app 内部 user_id 补偿导入腾讯 IM 账号体系。
type TencentIMAccountImporter interface {
ImportAccount(ctx context.Context, userID int64, nickname string, faceURL string) error
}
// TencentIMUserMessagePublisher 只暴露服务端 C2C custom message 投递能力。
type TencentIMUserMessagePublisher interface {
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
}
// ObjectUploader 是临时支付链接包装 app scheme 时依赖的对象存储能力。
type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
@ -34,6 +47,8 @@ type Config struct {
UserIdentityClient client.UserIdentityClient
UserProfileClient client.UserProfileClient
UserHostClient client.UserHostClient
IMAccountImporter TencentIMAccountImporter
IMPublisher TencentIMUserMessagePublisher
IMGroupIDPrefix string
ObjectUploader ObjectUploader
}
@ -46,6 +61,8 @@ func New(config Config) *Handler {
userIdentityClient: config.UserIdentityClient,
userProfileClient: config.UserProfileClient,
userHostClient: config.UserHostClient,
imAccountImporter: config.IMAccountImporter,
imPublisher: config.IMPublisher,
imGroupIDPrefix: config.IMGroupIDPrefix,
objectUploader: config.ObjectUploader,
}
@ -73,6 +90,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
ListWalletTransactions: h.listWalletTransactions,
ListCoinSellers: h.listCoinSellers,
TransferCoinFromSeller: h.transferCoinFromSeller,
SendDirectGift: h.sendDirectGift,
GetSalaryWalletOverview: h.getSalaryWalletOverview,
GetSalaryWalletHistory: h.getSalaryWalletHistory,
SearchSalaryWalletSeller: h.searchSalaryWalletSeller,

View File

@ -189,7 +189,7 @@ func (h *Handler) getCurrentRegionIMGroup(writer http.ResponseWriter, request *h
return
}
if user.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "voice room region is missing")
return
}
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(h.imGroupIDPrefix, appcode.FromContext(request.Context()), user.GetRegionId())
@ -227,7 +227,7 @@ func (h *Handler) sendVoiceRoomRedPacket(writer http.ResponseWriter, request *ht
return
}
if user.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "voice room region is missing")
return
}
snapshotResp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
@ -241,7 +241,7 @@ func (h *Handler) sendVoiceRoomRedPacket(writer http.ResponseWriter, request *ht
}
regionID := snapshotResp.GetRoom().GetVisibleRegionId()
if regionID <= 0 {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "voice room region is missing")
return
}
if regionID != user.GetRegionId() {
@ -348,7 +348,7 @@ func (h *Handler) claimVoiceRoomRedPacket(writer http.ResponseWriter, request *h
return
}
if user.GetRegionId() <= 0 {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "voice room region is missing")
return
}
if packet.GetRegionId() > 0 && packet.GetRegionId() != user.GetRegionId() {
@ -879,7 +879,7 @@ func (h *Handler) currentUser(writer http.ResponseWriter, request *http.Request)
func (h *Handler) verifyRedPacketRoomPresence(writer http.ResponseWriter, request *http.Request, roomID string) bool {
if !roomid.ValidStringID(roomID) {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_red_packet_room_presence_required", "conflict")
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_red_packet_room_presence_required", "join the room before using voice room red packet")
return false
}
resp, err := h.roomGuardClient.VerifyRoomPresence(request.Context(), &roomv1.VerifyRoomPresenceRequest{
@ -893,7 +893,7 @@ func (h *Handler) verifyRedPacketRoomPresence(writer http.ResponseWriter, reques
return false
}
if !resp.GetPresent() {
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_red_packet_room_presence_required", "conflict")
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_red_packet_room_presence_required", "join the room before using voice room red packet")
return false
}
return true

View File

@ -221,7 +221,7 @@ func (h *Handler) coinSellerTransferRegions(writer http.ResponseWriter, request
}
if sellerRegionID != targetRegionID {
// 充值政策当前按单一区域生效;跨区转账必须先设计明确的结算区域,不能静默套用任意一方政策。
httpkit.WriteError(writer, request, http.StatusConflict, string(xerr.Conflict), "conflict")
httpkit.WriteError(writer, request, http.StatusConflict, string(xerr.Conflict), "seller and target are in different regions")
return 0, 0, 0, false
}
return sellerRegionID, targetRegionID, targetCountryID, true

View File

@ -1116,6 +1116,44 @@ CREATE TABLE IF NOT EXISTS third_party_identities (
KEY idx_third_party_user_id (app_code, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='第三方身份表';
CREATE TABLE IF NOT EXISTS pending_third_party_registrations (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
pending_id VARCHAR(96) NOT NULL COMMENT '待完成注册 ID',
provider VARCHAR(64) NOT NULL COMMENT '提供方',
provider_subject VARCHAR(191) NOT NULL COMMENT '提供方主体',
provider_union_id VARCHAR(191) NULL COMMENT '提供方联合 ID',
username VARCHAR(64) NULL COMMENT '首登暂存用户名',
gender VARCHAR(32) NULL COMMENT '首登暂存性别',
country VARCHAR(64) NULL COMMENT '首登暂存国家',
invite_code VARCHAR(64) NULL COMMENT '暂存邀请码',
register_ip VARCHAR(64) NULL COMMENT '注册入口 IP',
register_user_agent VARCHAR(512) NULL COMMENT '注册用户代理',
country_by_ip VARCHAR(64) NULL COMMENT 'IP 解析国家',
device_id VARCHAR(128) NULL COMMENT '注册设备 ID',
device VARCHAR(128) NULL COMMENT '注册设备',
os_version VARCHAR(64) NULL COMMENT 'os版本',
avatar VARCHAR(512) NULL COMMENT '首登暂存头像',
birth_date VARCHAR(32) NULL COMMENT '首登暂存生日',
app_version VARCHAR(64) NULL COMMENT '应用版本',
build_number VARCHAR(64) NULL COMMENT '构建编号',
source VARCHAR(64) NULL COMMENT '来源',
install_channel VARCHAR(64) NULL COMMENT '安装渠道',
campaign VARCHAR(128) NULL COMMENT '投放活动',
platform VARCHAR(16) NULL COMMENT '平台',
language VARCHAR(32) NULL COMMENT '语言',
timezone VARCHAR(64) NULL COMMENT '时区',
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '待完成注册状态',
completed_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '完成后的用户 ID',
expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '过期时间UTC epoch ms',
completed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '完成时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pending_id),
UNIQUE KEY uk_pending_third_party_subject (app_code, provider, provider_subject),
KEY idx_pending_third_party_status (app_code, status, expires_at_ms),
KEY idx_pending_third_party_completed_user (app_code, completed_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方待完成注册表';
CREATE TABLE IF NOT EXISTS auth_sessions (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
session_id VARCHAR(96) NOT NULL PRIMARY KEY COMMENT '会话 ID',

View File

@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS pending_third_party_registrations (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
pending_id VARCHAR(96) NOT NULL COMMENT '待完成注册 ID',
provider VARCHAR(64) NOT NULL COMMENT '提供方',
provider_subject VARCHAR(191) NOT NULL COMMENT '提供方主体',
provider_union_id VARCHAR(191) NULL COMMENT '提供方联合 ID',
username VARCHAR(64) NULL COMMENT '首登暂存用户名',
gender VARCHAR(32) NULL COMMENT '首登暂存性别',
country VARCHAR(64) NULL COMMENT '首登暂存国家',
invite_code VARCHAR(64) NULL COMMENT '暂存邀请码',
register_ip VARCHAR(64) NULL COMMENT '注册入口 IP',
register_user_agent VARCHAR(512) NULL COMMENT '注册用户代理',
country_by_ip VARCHAR(64) NULL COMMENT 'IP 解析国家',
device_id VARCHAR(128) NULL COMMENT '注册设备 ID',
device VARCHAR(128) NULL COMMENT '注册设备',
os_version VARCHAR(64) NULL COMMENT 'os版本',
avatar VARCHAR(512) NULL COMMENT '首登暂存头像',
birth_date VARCHAR(32) NULL COMMENT '首登暂存生日',
app_version VARCHAR(64) NULL COMMENT '应用版本',
build_number VARCHAR(64) NULL COMMENT '构建编号',
source VARCHAR(64) NULL COMMENT '来源',
install_channel VARCHAR(64) NULL COMMENT '安装渠道',
campaign VARCHAR(128) NULL COMMENT '投放活动',
platform VARCHAR(16) NULL COMMENT '平台',
language VARCHAR(32) NULL COMMENT '语言',
timezone VARCHAR(64) NULL COMMENT '时区',
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '待完成注册状态',
completed_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '完成后的用户 ID',
expires_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '过期时间UTC epoch ms',
completed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '完成时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pending_id),
UNIQUE KEY uk_pending_third_party_subject (app_code, provider, provider_subject),
KEY idx_pending_third_party_status (app_code, status, expires_at_ms),
KEY idx_pending_third_party_completed_user (app_code, completed_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='三方待完成注册表';

View File

@ -101,6 +101,13 @@ type ThirdPartyIdentity struct {
UpdatedAtMs int64
}
const (
// PendingThirdPartyRegistrationStatusPending 表示三方身份已验证,但还没有完整资料,不能创建 users。
PendingThirdPartyRegistrationStatusPending = "pending"
// PendingThirdPartyRegistrationStatusCompleted 表示 pending 已经消费成真实用户,不能再次创建账号。
PendingThirdPartyRegistrationStatusCompleted = "completed"
)
// LoginAudit 是登录注册链路的审计记录。
// 审计不能包含密码、三方 credential 或 refresh token 原文。
type LoginAudit struct {
@ -192,3 +199,32 @@ type ThirdPartyRegistration struct {
// Timezone 是客户端 IANA 时区,例如 America/Los_Angeles。
Timezone string
}
// PendingThirdPartyRegistration 是旧客户端兼容注册态。
// 它只保存已经验证过的 provider subject 和注册来源快照,不占用 user_id、短号、IM 账号或注册奖励。
type PendingThirdPartyRegistration struct {
// AppCode 是 pending 所属 App必须和 access token claim 一致。
AppCode string
// PendingID 是 pending token 的服务端定位键,不是用户 ID不能暴露成业务身份。
PendingID string
// Provider 是三方平台标识。
Provider string
// ProviderSubject 是 provider 内稳定身份;完成注册时会写入 third_party_identities。
ProviderSubject string
// ProviderUnionID 是 provider 可选联合身份。
ProviderUnionID string
// Registration 保存首登设备、渠道、IP 和可选邀请码等来源快照。
Registration ThirdPartyRegistration
// Status 表示 pending 是否仍可消费。
Status string
// CompletedUserID 是 pending 被消费后的真实用户 ID未完成时为 0。
CompletedUserID int64
// ExpiresAtMs 是 pending 持久化保留期限UTC epoch ms。
ExpiresAtMs int64
// CompletedAtMs 是 pending 成功消费时间UTC epoch ms。
CompletedAtMs int64
// CreatedAtMs 是 pending 创建时间UTC epoch ms。
CreatedAtMs int64
// UpdatedAtMs 是 pending 最近更新时间UTC epoch ms。
UpdatedAtMs int64
}

View File

@ -149,14 +149,16 @@ func (s *Service) ensureDeviceCanCreatePasswordAccount(ctx context.Context, regi
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.CodeOf(err)))
return 0, err
}
count, err := s.authRepository.CountUsersByRegisterDeviceID(ctx, registration.AppCode, registration.DeviceID)
displayUserIDs, err := s.authRepository.ListDisplayUserIDsByRegisterDeviceID(ctx, registration.AppCode, registration.DeviceID)
if err != nil {
// 快捷建号和三方注册共享设备注册上限;读取失败不能放行,否则会突破账号数量约束。
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.CodeOf(err)))
return 0, err
}
if count < int64(config.MaxAccountsPerDevice) {
count := len(displayUserIDs)
if count < int(config.MaxAccountsPerDevice) {
return config.MaxAccountsPerDevice, nil
}
s.audit(ctx, meta, 0, loginPassword, "", resultFailed, string(xerr.DeviceAlreadyRegistered))
return 0, xerr.New(xerr.DeviceAlreadyRegistered, "device already registered")
return 0, xerr.New(xerr.DeviceAlreadyRegistered, xerr.DeviceAlreadyRegisteredMessage(count, displayUserIDs))
}

View File

@ -17,6 +17,10 @@ const (
hashAlgBcrypt = "bcrypt"
// tokenType 是对外 token 类型。
tokenType = "Bearer"
// accessTokenTypeUser 是真实用户会话 tokengateway 会从中读取 user_id 和 session_id。
accessTokenTypeUser = "access"
// accessTokenTypePendingRegistration 是旧 Flutter 兼容注册 token只能访问头像上传和资料完成接口。
accessTokenTypePendingRegistration = "pending_registration"
// loginPassword 是密码登录审计类型。
loginPassword = "password"
// loginThird 是三方登录审计类型。
@ -62,10 +66,18 @@ type AuthRepository interface {
UpdateSessionHeartbeat(ctx context.Context, sessionID string, userID int64, heartbeatAtMs int64, requestID string) (authdomain.Session, error)
// FindThirdPartyIdentity 查找 provider + subject 的绑定。
FindThirdPartyIdentity(ctx context.Context, provider string, providerSubject string) (authdomain.ThirdPartyIdentity, error)
// UpsertPendingThirdPartyRegistration 保存或刷新已验证但未补齐资料的三方注册态,不创建 users 和短号。
UpsertPendingThirdPartyRegistration(ctx context.Context, pending authdomain.PendingThirdPartyRegistration) (authdomain.PendingThirdPartyRegistration, error)
// FindPendingThirdPartyRegistration 按 pending_id 读取待完成注册态,用于 pending token 的资料提交。
FindPendingThirdPartyRegistration(ctx context.Context, appCode string, pendingID string) (authdomain.PendingThirdPartyRegistration, error)
// CountUsersByRegisterDeviceID 统计当前 App 下已使用同一注册设备号的账号数量。
CountUsersByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) (int64, error)
// ListDisplayUserIDsByRegisterDeviceID 按原注册顺序列出同设备已注册账号短号,用于上限判断和失败提示。
ListDisplayUserIDsByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) ([]string, error)
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand, maxAccountsPerDevice int32) error
// CompletePendingThirdPartyUser 原子消费 pending、创建完整用户、默认短号、三方身份和首个 session。
CompletePendingThirdPartyUser(ctx context.Context, pendingID string, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand, maxAccountsPerDevice int32) (userdomain.User, error)
// GetRegisterRiskConfig 读取当前 App 的注册风控配置。
GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, error)
// UpsertRegisterRiskConfig 保存当前 App 的注册风控配置。

View File

@ -309,8 +309,8 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
}
}
func TestCompleteOnboardingEmitsUserRegisteredOutboxWithSelectedCountry(t *testing.T) {
// UserRegistered 是“注册页资料完成”事实,不是三方登录占位账号创建事实;统计国家必须来自最终提交的国家
func TestPendingThirdPartyCompletionCreatesUserAndEmitsUserRegisteredOutboxWithSelectedCountry(t *testing.T) {
// UserRegistered 是“用户填完注册资料”事实;三方首登 pending 不能提前创建 users 或写未知国家 cohort
ctx := context.Background()
repository := mysqltest.NewRepository(t)
country := seedCountry(t, repository, "AE")
@ -325,22 +325,27 @@ func TestCompleteOnboardingEmitsUserRegisteredOutboxWithSelectedCountry(t *testi
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
if !isNewUser || token.ProfileCompleted {
t.Fatalf("third-party login should create profile-required user: token=%+v isNew=%v", token, isNewUser)
if !isNewUser || token.UserID != 0 || token.DisplayUserID != "" || token.AccessToken == "" || token.RefreshToken != "" || token.ProfileCompleted {
t.Fatalf("third-party login should return pending token without user: token=%+v isNew=%v", token, isNewUser)
}
if _, lookupErr := repository.GetUser(ctx, 900031); !xerr.IsCode(lookupErr, xerr.NotFound) {
t.Fatalf("pending third-party login must not create users row, lookupErr=%v", lookupErr)
}
assertUserRegisteredOutboxCount(t, repository, token.UserID, 0)
completedAtMS := int64(2500)
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return time.UnixMilli(completedAtMS).UTC() }))
user, err := userSvc.CompleteOnboarding(ctx, token.UserID, "Amina", "https://cdn.example/amina.png", "female", "ae", "", "req-complete-onboarding")
loginAt = time.UnixMilli(completedAtMS)
user, completedToken, err := authSvc.CompletePendingThirdPartyRegistration(ctx, token.SessionID, "Amina", "https://cdn.example/amina.png", "female", "ae", "", authservice.Meta{AppCode: "lalu", RequestID: "req-complete-onboarding"})
if err != nil {
t.Fatalf("CompleteOnboarding failed: %v", err)
t.Fatalf("CompletePendingThirdPartyRegistration failed: %v", err)
}
if !user.ProfileCompleted || user.Country != "AE" || user.RegionID != region.RegionID || user.ProfileCompletedAtMs != completedAtMS {
if completedToken.UserID != user.UserID || completedToken.RefreshToken == "" || !completedToken.ProfileCompleted {
t.Fatalf("completed token mismatch: token=%+v user=%+v", completedToken, user)
}
if user.UserID != 900031 || !user.ProfileCompleted || user.Country != "AE" || user.RegionID != region.RegionID || user.ProfileCompletedAtMs != completedAtMS {
t.Fatalf("completed user snapshot mismatch: %+v", user)
}
assertUserRegisteredOutboxCount(t, repository, token.UserID, 1)
assertUserRegisteredOutboxFact(t, repository, token.UserID, country.CountryID, region.RegionID, completedAtMS)
assertUserRegisteredOutboxCount(t, repository, user.UserID, 1)
assertUserRegisteredOutboxFact(t, repository, user.UserID, country.CountryID, region.RegionID, completedAtMS)
}
func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
@ -1042,10 +1047,57 @@ func TestThirdPartyRegisterRejectsReusedDeviceID(t *testing.T) {
// 第二个 provider subject 会创建新账号,必须被注册设备唯一性拦截。
t.Fatalf("expected DEVICE_ALREADY_REGISTERED for reused device, got %v", err)
}
if !strings.Contains(err.Error(), "you device already register 1 account 163000, login failed") {
t.Fatalf("device registered error should include account count and display id, got %v", err)
}
if _, lookupErr := repository.GetUser(ctx, 900021); !xerr.IsCode(lookupErr, xerr.NotFound) {
t.Fatalf("failed registration must not create empty user, lookupErr=%v", lookupErr)
}
}
func TestThirdPartyRegisterAllowsMinimalProfileFields(t *testing.T) {
// 三方首次登录只需要认证材料、设备和平台;公开资料由后续 onboarding 原子提交。
func TestThirdPartyRegisterAllowsUntilConfiguredDeviceLimit(t *testing.T) {
// 三方注册仍遵守 max_accounts_per_device未达到上限时放行达到上限时才失败并展示全部短号。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(2000)
svc := newAuthService(repository, &now, []int64{900030, 900031, 900032}, []string{"100030", "100031", "100032"})
if _, err := svc.UpdateRegisterRiskConfig(ctx, 2, 700001); err != nil {
t.Fatalf("UpdateRegisterRiskConfig failed: %v", err)
}
registration := thirdPartyRegistration("android")
registration.DeviceID = "device-two-account-limit"
firstToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-limit-first", registration, authservice.Meta{RequestID: "req-limit-first"})
if err != nil {
t.Fatalf("first LoginThirdParty failed: %v", err)
}
if !isNewUser || firstToken.UserID != 900030 {
t.Fatalf("unexpected first registration: token=%+v isNew=%v", firstToken, isNewUser)
}
secondToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-limit-second", registration, authservice.Meta{RequestID: "req-limit-second"})
if err != nil {
t.Fatalf("second LoginThirdParty should be allowed below configured limit: %v", err)
}
if !isNewUser || secondToken.UserID != 900031 {
t.Fatalf("unexpected second registration: token=%+v isNew=%v", secondToken, isNewUser)
}
_, _, err = svc.LoginThirdParty(ctx, "wechat", "openid-limit-third", registration, authservice.Meta{RequestID: "req-limit-third"})
if !xerr.IsCode(err, xerr.DeviceAlreadyRegistered) {
t.Fatalf("expected DEVICE_ALREADY_REGISTERED after reaching configured limit, got %v", err)
}
if !strings.Contains(err.Error(), "you device already register 2 account 163000,163001, login failed") {
t.Fatalf("limit error should include count and all display ids, got %v", err)
}
if _, lookupErr := repository.GetUser(ctx, 900032); !xerr.IsCode(lookupErr, xerr.NotFound) {
t.Fatalf("failed over-limit registration must not create empty user, lookupErr=%v", lookupErr)
}
}
func TestThirdPartyRegisterWithMinimalProfileCreatesOnlyPendingRegistration(t *testing.T) {
// 三方首次登录只验证认证材料、设备和平台;公开资料缺失时只返回 pending token不创建空 users。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := time.UnixMilli(1000)
@ -1058,8 +1110,48 @@ func TestThirdPartyRegisterAllowsMinimalProfileFields(t *testing.T) {
if err != nil {
t.Fatalf("LoginThirdParty with minimal registration failed: %v", err)
}
if !isNewUser || token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("minimal third-party registration must create profile-required user: token=%+v isNew=%v", token, isNewUser)
if !isNewUser || token.UserID != 0 || token.DisplayUserID != "" || token.RefreshToken != "" || token.AccessToken == "" || token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("minimal third-party registration must return pending token only: token=%+v isNew=%v", token, isNewUser)
}
if _, err := repository.GetUser(ctx, 900012); !xerr.IsCode(err, xerr.NotFound) {
t.Fatalf("minimal third-party registration must not create users row, got %v", err)
}
pending, err := repository.FindPendingThirdPartyRegistration(ctx, "lalu", token.SessionID)
if err != nil {
t.Fatalf("pending registration was not persisted: %v", err)
}
if pending.Provider != "wechat" || pending.ProviderSubject != "openid-minimal" || pending.Registration.DeviceID != "dev-ios" {
t.Fatalf("pending registration snapshot mismatch: %+v", pending)
}
}
func TestPendingThirdPartyCompletionRejectsMissingProfileFieldsWithoutCreatingUser(t *testing.T) {
// 资料完成接口收到缺字段请求时只返回参数错误,不能因为 pending 已存在就提前创建 users。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900013}, []string{"100013"})
token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-pending-missing", authdomain.ThirdPartyRegistration{
DeviceID: "dev-ios",
Platform: "ios",
}, authservice.Meta{AppCode: "lalu", RequestID: "req-pending-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
_, _, err = svc.CompletePendingThirdPartyRegistration(ctx, token.SessionID, "Amina", "https://cdn.example/amina.png", "female", "", "", authservice.Meta{AppCode: "lalu", RequestID: "req-pending-missing"})
if !xerr.IsCode(err, xerr.InvalidArgument) || !strings.Contains(err.Error(), "country") {
t.Fatalf("missing country must return INVALID_ARGUMENT with field name, got %v", err)
}
if _, lookupErr := repository.GetUser(ctx, 900013); !xerr.IsCode(lookupErr, xerr.NotFound) {
t.Fatalf("missing profile completion must not create user, lookupErr=%v", lookupErr)
}
pending, err := repository.FindPendingThirdPartyRegistration(ctx, "lalu", token.SessionID)
if err != nil {
t.Fatalf("pending registration should remain after invalid completion: %v", err)
}
if pending.Status != authdomain.PendingThirdPartyRegistrationStatusPending {
t.Fatalf("pending status changed after invalid completion: %+v", pending)
}
}
@ -1123,6 +1215,9 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
if !isNewUser {
t.Fatalf("first third-party login must create user")
}
if !token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
t.Fatalf("complete third-party registration must return completed token: %+v", token)
}
user, err := repository.GetUser(ctx, token.UserID)
if err != nil {
@ -1143,6 +1238,7 @@ func TestThirdPartyRegisterStoresRegistrationProfile(t *testing.T) {
if imImporter.userID != token.UserID || imImporter.nickname != "hy" || imImporter.faceURL != "https://cdn.example/avatar.png" {
t.Fatalf("third-party registration must import IM account: %+v", imImporter)
}
assertUserRegisteredOutboxCount(t, repository, token.UserID, 1)
}
func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *testing.T) {

View File

@ -10,6 +10,7 @@ import (
"unicode/utf8"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
@ -91,6 +92,11 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti
if err != nil {
return authdomain.Token{}, false, err
}
if err := validateThirdPartyRegistrationRequiredForCreate(registration); err != nil {
// 旧 Flutter 首次三方登录不会提交昵称、头像、性别和国家;此时只保存已验证 provider subject
// 返回 profile_required pending token 让客户端继续走原资料页,禁止提前创建 users 空账号。
return s.createPendingThirdPartyRegistration(ctx, provider, profile, registration, meta)
}
return s.createThirdPartyUser(ctx, provider, profile, registration, meta, maxAccountsPerDevice)
}
@ -102,19 +108,20 @@ func (s *Service) ensureDeviceCanRegisterThirdParty(ctx context.Context, registr
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return 0, err
}
count, err := s.authRepository.CountUsersByRegisterDeviceID(ctx, registration.AppCode, registration.DeviceID)
displayUserIDs, err := s.authRepository.ListDisplayUserIDsByRegisterDeviceID(ctx, registration.AppCode, registration.DeviceID)
if err != nil {
// 设备查重失败不能退化为注册,否则并发和依赖异常都会突破一机一号约束
// 设备查重和短号列表必须来自同一读模型;读取失败不能退化为注册,否则会再次制造空资料账号
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return 0, err
}
if count < int64(config.MaxAccountsPerDevice) {
count := len(displayUserIDs)
if count < int(config.MaxAccountsPerDevice) {
return config.MaxAccountsPerDevice, nil
}
// 已达到配置上限时拒绝创建新账号;审计不写已有 user_id避免客户端枚举设备归属
// 已达到配置上限时拒绝创建新账号;错误文案只列当前设备已注册短号,不暴露内部 user_id
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.DeviceAlreadyRegistered))
return 0, xerr.New(xerr.DeviceAlreadyRegistered, "device already registered")
return 0, xerr.New(xerr.DeviceAlreadyRegistered, xerr.DeviceAlreadyRegisteredMessage(count, displayUserIDs))
}
func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdomain.ThirdPartyIdentity, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
@ -155,6 +162,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
// 三方首次登录要同时生成 user_id 和默认短号,短号冲突时有限重试。
user, displayIdentity := s.newUserWithIdentity(attempt)
user = applyThirdPartyRegistration(user, registration)
user = completeRegistrationUser(user, user.CreatedAtMs)
displayUserID, err := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode)
if err != nil {
return authdomain.Token{}, false, err
@ -231,6 +239,196 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
return authdomain.Token{}, false, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
}
func completeRegistrationUser(user userdomain.User, completedAtMs int64) userdomain.User {
// 能进入真实建号事务的注册资料已经包含 username/avatar/gender/country
// 因此 users 必须直接落 completedUserRegistered outbox 才能携带正确国家 cohort。
user.ProfileCompleted = true
user.ProfileCompletedAtMs = completedAtMs
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
user.UpdatedAtMs = completedAtMs
return user
}
func (s *Service) createPendingThirdPartyRegistration(ctx context.Context, provider string, profile authdomain.ThirdPartyProfile, registration authdomain.ThirdPartyRegistration, meta Meta) (authdomain.Token, bool, error) {
nowMs := s.now().UnixMilli()
pending := authdomain.PendingThirdPartyRegistration{
AppCode: registration.AppCode,
PendingID: idgen.New("tpr"),
Provider: provider,
ProviderSubject: profile.ProviderSubject,
ProviderUnionID: profile.ProviderUnionID,
Registration: registration,
Status: authdomain.PendingThirdPartyRegistrationStatusPending,
ExpiresAtMs: nowMs + s.cfg.RefreshTokenTTLSec*1000,
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
CompletedUserID: 0,
CompletedAtMs: 0,
}
// provider subject 已通过三方 verifierpending 只持久化 subject 和来源快照,不保存 credential 原文。
pending, err := s.authRepository.UpsertPendingThirdPartyRegistration(ctx, pending)
if err != nil {
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
if pending.Status == authdomain.PendingThirdPartyRegistrationStatusCompleted {
// 理论上已完成 pending 会先命中 third_party_identities若读模型短暂不一致拒绝再次创建 users。
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.Conflict))
return authdomain.Token{}, false, xerr.New(xerr.Conflict, "pending registration already completed")
}
token, err := s.issuePendingRegistrationToken(pending)
if err != nil {
return authdomain.Token{}, false, err
}
s.audit(ctx, meta, 0, loginThird, provider, resultSuccess, "")
return token, true, nil
}
func (s *Service) CompletePendingThirdPartyRegistration(ctx context.Context, pendingID string, username string, avatar string, gender string, country string, inviteCode string, meta Meta) (userdomain.User, authdomain.Token, error) {
meta.AppCode = appcode.Normalize(meta.AppCode)
pendingID = strings.TrimSpace(pendingID)
if pendingID == "" {
return userdomain.User{}, authdomain.Token{}, xerr.New(xerr.InvalidArgument, "pending_registration_id is required")
}
if s.authRepository == nil {
return userdomain.User{}, authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
if s.userRepository == nil {
return userdomain.User{}, authdomain.Token{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
pending, err := s.authRepository.FindPendingThirdPartyRegistration(ctx, meta.AppCode, pendingID)
if err != nil {
return userdomain.User{}, authdomain.Token{}, err
}
if pending.Status != authdomain.PendingThirdPartyRegistrationStatusPending {
return userdomain.User{}, authdomain.Token{}, xerr.New(xerr.Conflict, "pending registration already completed")
}
nowMs := s.now().UnixMilli()
if pending.ExpiresAtMs > 0 && pending.ExpiresAtMs <= nowMs {
return userdomain.User{}, authdomain.Token{}, xerr.New(xerr.SessionExpired, "pending registration expired")
}
registration := pending.Registration
registration.Username = strings.TrimSpace(username)
registration.Avatar = strings.TrimSpace(avatar)
registration.Gender = strings.TrimSpace(gender)
registration.Country = strings.ToUpper(strings.TrimSpace(country))
if normalizedInvite := invitedomain.NormalizeCode(inviteCode); normalizedInvite != "" {
registration.InviteCode = normalizedInvite
}
registration = normalizeThirdPartyRegistration(registration, meta)
if err := validateThirdPartyRegistration(registration); err != nil {
return userdomain.User{}, authdomain.Token{}, err
}
if err := validateThirdPartyRegistrationRequiredForCreate(registration); err != nil {
// pending 必须保持待完成状态,方便旧 Flutter 用户补完缺失项后用同一 token 再提交。
return userdomain.User{}, authdomain.Token{}, err
}
maxAccountsPerDevice, err := s.ensureDeviceCanRegisterThirdParty(ctx, registration, meta, pending.Provider)
if err != nil {
return userdomain.User{}, authdomain.Token{}, err
}
registration, err = s.resolveRegistrationCountry(ctx, registration)
if err != nil {
return userdomain.User{}, authdomain.Token{}, err
}
var user userdomain.User
var session authdomain.Session
var refreshToken string
var lastErr error
completed := false
for attempt := 0; attempt < s.allocateMaxAttempts; attempt++ {
var displayIdentity userdomain.Identity
user, displayIdentity = s.newUserWithIdentity(attempt)
user = applyThirdPartyRegistration(user, registration)
user = completeRegistrationUser(user, nowMs)
displayUserID, allocErr := s.authRepository.AllocateDisplayUserID(ctx, registration.AppCode)
if allocErr != nil {
return userdomain.User{}, authdomain.Token{}, allocErr
}
user.DefaultDisplayUserID = displayUserID
user.CurrentDisplayUserID = displayUserID
displayIdentity.AppCode = registration.AppCode
displayIdentity.DisplayUserID = displayUserID
displayIdentity.DefaultDisplayUserID = displayUserID
if !userdomain.ValidDisplayUserID(displayIdentity.DisplayUserID) {
continue
}
var sessionErr error
session, refreshToken, sessionErr = s.newSession(registration.AppCode, user.UserID, registration.DeviceID)
if sessionErr != nil {
return userdomain.User{}, authdomain.Token{}, sessionErr
}
thirdParty := authdomain.ThirdPartyIdentity{
AppCode: registration.AppCode,
UserID: user.UserID,
Provider: pending.Provider,
ProviderSubject: pending.ProviderSubject,
ProviderUnionID: pending.ProviderUnionID,
CreatedAtMs: user.CreatedAtMs,
UpdatedAtMs: user.UpdatedAtMs,
}
inviteBind := invitedomain.BindCommand{
AppCode: registration.AppCode,
InvitedUserID: user.UserID,
InvitedRegionID: registration.RegionID,
InviteCode: registration.InviteCode,
Source: "pending_third_party_registration",
RequestID: meta.RequestID,
BoundAtMs: user.CreatedAtMs,
}
user, err = s.authRepository.CompletePendingThirdPartyUser(ctx, pendingID, user, displayIdentity, thirdParty, session, inviteBind, maxAccountsPerDevice)
if err == nil {
lastErr = nil
completed = true
break
}
if xerr.IsCode(err, xerr.DisplayUserIDExists) {
lastErr = err
continue
}
if xerr.IsCode(err, xerr.Conflict) {
if identity, findErr := s.authRepository.FindThirdPartyIdentity(ctx, pending.Provider, pending.ProviderSubject); findErr == nil {
token, _, loginErr := s.loginExistingThirdParty(ctx, identity, registration, meta)
if loginErr != nil {
return userdomain.User{}, authdomain.Token{}, loginErr
}
existing, userErr := s.freshUser(ctx, identity.UserID, s.now().UnixMilli(), meta.RequestID)
return existing, token, userErr
}
}
s.audit(ctx, meta, 0, loginThird, pending.Provider, resultFailed, string(xerr.CodeOf(err)))
return userdomain.User{}, authdomain.Token{}, err
}
if lastErr != nil {
s.audit(ctx, meta, 0, loginThird, pending.Provider, resultFailed, string(xerr.DisplayUserIDAllocateFailed))
return userdomain.User{}, authdomain.Token{}, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
}
if !completed {
s.audit(ctx, meta, 0, loginThird, pending.Provider, resultFailed, string(xerr.DisplayUserIDAllocateFailed))
return userdomain.User{}, authdomain.Token{}, xerr.New(xerr.DisplayUserIDAllocateFailed, "display_user_id allocation failed")
}
inviteBinding := user.InviteBinding
fresh, err := s.freshUser(ctx, user.UserID, s.now().UnixMilli(), meta.RequestID)
if err != nil {
return userdomain.User{}, authdomain.Token{}, err
}
fresh.InviteBinding = inviteBinding
user = fresh
token, err := s.issueToken(user, session.SessionID, refreshToken)
if err != nil {
return userdomain.User{}, authdomain.Token{}, err
}
s.audit(ctx, meta, user.UserID, loginThird, pending.Provider, resultSuccess, "")
s.importIMAccountAfterCommit(ctx, user, meta)
s.enqueueLoginIPRiskJob(ctx, meta, token, normalizeThirdPartyChannel(pending.Provider), registration.Platform, registration.Language, registration.Timezone)
s.issueRegistrationRewardAfterCommit(ctx, registration.AppCode, user.UserID, meta)
s.issueRegistrationLevelBadgesAfterCommit(ctx, registration.AppCode, user.UserID, meta)
return user, token, nil
}
func (s *Service) issueRegistrationRewardAfterCommit(ctx context.Context, appCode string, userID int64, meta Meta) {
if s.registrationRewardIssuer == nil {
return
@ -341,9 +539,22 @@ func validateThirdPartyRegistration(registration authdomain.ThirdPartyRegistrati
}
func validateThirdPartyRegistrationRequiredForCreate(registration authdomain.ThirdPartyRegistration) error {
if registration.Username == "" || registration.Country == "" || registration.Gender == "" || registration.Avatar == "" {
missing := make([]string, 0, 4)
if strings.TrimSpace(registration.Username) == "" {
missing = append(missing, "username")
}
if strings.TrimSpace(registration.Avatar) == "" {
missing = append(missing, "avatar")
}
if strings.TrimSpace(registration.Gender) == "" {
missing = append(missing, "gender")
}
if strings.TrimSpace(registration.Country) == "" {
missing = append(missing, "country")
}
if len(missing) > 0 {
// 注册即落用户主数据;最小公开资料不完整时拒绝创建,避免后台出现空昵称、空头像或 GLOBAL 国家用户。
return xerr.New(xerr.InvalidArgument, "username, country, gender and avatar are required")
return xerr.New(xerr.InvalidArgument, strings.Join(missing, ", ")+" are required")
}
return nil

View File

@ -210,7 +210,7 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
"profile_completed": user.ProfileCompleted,
"onboarding_status": onboardingStatus,
"sid": sessionID,
"typ": "access",
"typ": accessTokenTypeUser,
"iat": now.Unix(),
"exp": expiresAt.Unix(),
}
@ -239,6 +239,42 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
}, nil
}
func (s *Service) issuePendingRegistrationToken(pending authdomain.PendingThirdPartyRegistration) (authdomain.Token, error) {
now := s.now()
expiresAt := now.Add(time.Duration(s.cfg.AccessTokenTTLSec) * time.Second)
pendingID := strings.TrimSpace(pending.PendingID)
// pending token 只证明“provider subject 已验证且等待补资料”,不是用户会话;
// 不写 user_id防止 gateway 或下游服务把它当真实用户参与业务。
claims := jwt.MapClaims{
"iss": s.cfg.Issuer,
"app_code": appcode.Normalize(pending.AppCode),
"sub": pendingID,
"pending_registration_id": pendingID,
"profile_completed": false,
"onboarding_status": string(userdomain.OnboardingStatusProfileRequired),
"sid": pendingID,
"typ": accessTokenTypePendingRegistration,
"iat": now.Unix(),
"exp": expiresAt.Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
accessToken, err := token.SignedString([]byte(s.cfg.SigningSecret))
if err != nil {
return authdomain.Token{}, err
}
return authdomain.Token{
AppCode: appcode.Normalize(pending.AppCode),
ProfileCompleted: false,
OnboardingStatus: string(userdomain.OnboardingStatusProfileRequired),
SessionID: pendingID,
AccessToken: accessToken,
ExpiresInSec: s.cfg.AccessTokenTTLSec,
TokenType: tokenType,
}, nil
}
func tokenOnboardingStatus(user userdomain.User) string {
// 旧测试数据或手工种子可能没有显式状态token 按 profile_completed 推导安全默认值。
if user.OnboardingStatus != "" {

View File

@ -57,9 +57,10 @@ func ensureRegisterDeviceQuotaTx(ctx context.Context, tx *sql.Tx, appCode string
maxAccountsPerDevice = 1
}
rows, err := tx.QueryContext(ctx, `
SELECT user_id
SELECT current_display_user_id
FROM users
WHERE app_code = ? AND register_device_id = ?
ORDER BY created_at_ms ASC, user_id ASC
FOR UPDATE
`, appCode, deviceID)
if err != nil {
@ -68,15 +69,21 @@ func ensureRegisterDeviceQuotaTx(ctx context.Context, tx *sql.Tx, appCode string
defer rows.Close()
var count int32
displayUserIDs := make([]string, 0)
for rows.Next() {
// FOR UPDATE 锁住同设备的已有用户行;读完后再判断,确保等待中的并发事务能看到最新提交结果。
var displayUserID string
if err := rows.Scan(&displayUserID); err != nil {
return err
}
count++
displayUserIDs = append(displayUserIDs, displayUserID)
}
if err := rows.Err(); err != nil {
return err
}
if count >= maxAccountsPerDevice {
return xerr.New(xerr.DeviceAlreadyRegistered, "device already registered")
return xerr.New(xerr.DeviceAlreadyRegistered, xerr.DeviceAlreadyRegisteredMessage(int(count), displayUserIDs))
}
return nil
}

View File

@ -38,6 +38,7 @@ func (r *Repository) FindUserIDByRegisterDeviceID(ctx context.Context, appCode s
SELECT user_id
FROM users
WHERE app_code = ? AND register_device_id = ?
ORDER BY created_at_ms ASC, user_id ASC
LIMIT 1
`, appcode.Normalize(appCode), deviceID).Scan(&userID)
if err == sql.ErrNoRows {
@ -51,6 +52,34 @@ func (r *Repository) FindUserIDByRegisterDeviceID(ctx context.Context, appCode s
return userID, nil
}
func (r *Repository) ListDisplayUserIDsByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT current_display_user_id
FROM users
WHERE app_code = ? AND register_device_id = ?
ORDER BY created_at_ms ASC, user_id ASC
`, appcode.Normalize(appCode), deviceID)
if err != nil {
return nil, err
}
defer rows.Close()
displayUserIDs := make([]string, 0)
for rows.Next() {
var displayUserID string
if err := rows.Scan(&displayUserID); err != nil {
return nil, err
}
// 这里不去重,账号数量以 users 行为准;文案 helper 会只清洗展示列表。
displayUserIDs = append(displayUserIDs, displayUserID)
}
if err := rows.Err(); err != nil {
return nil, err
}
return displayUserIDs, nil
}
func (r *Repository) CountUsersByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) (int64, error) {
var count int64
if err := r.db.QueryRowContext(ctx, `
@ -64,6 +93,65 @@ func (r *Repository) CountUsersByRegisterDeviceID(ctx context.Context, appCode s
return count, nil
}
func (r *Repository) UpsertPendingThirdPartyRegistration(ctx context.Context, pending authdomain.PendingThirdPartyRegistration) (authdomain.PendingThirdPartyRegistration, error) {
// 同一个 provider subject 重复登录只刷新待注册来源快照,不新建 users也不制造多条 pending 垃圾数据。
_, err := r.db.ExecContext(ctx, `
INSERT INTO pending_third_party_registrations (
app_code, pending_id, provider, provider_subject, provider_union_id,
username, gender, country, invite_code, register_ip, register_user_agent, country_by_ip,
device_id, device, os_version, avatar, birth_date, app_version, build_number, source,
install_channel, campaign, platform, language, timezone, status, completed_user_id,
expires_at_ms, completed_at_ms, created_at_ms, updated_at_ms
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
provider_union_id = IF(status = 'pending', VALUES(provider_union_id), provider_union_id),
username = IF(status = 'pending', VALUES(username), username),
gender = IF(status = 'pending', VALUES(gender), gender),
country = IF(status = 'pending', VALUES(country), country),
invite_code = IF(status = 'pending', VALUES(invite_code), invite_code),
register_ip = IF(status = 'pending', VALUES(register_ip), register_ip),
register_user_agent = IF(status = 'pending', VALUES(register_user_agent), register_user_agent),
country_by_ip = IF(status = 'pending', VALUES(country_by_ip), country_by_ip),
device_id = IF(status = 'pending', VALUES(device_id), device_id),
device = IF(status = 'pending', VALUES(device), device),
os_version = IF(status = 'pending', VALUES(os_version), os_version),
avatar = IF(status = 'pending', VALUES(avatar), avatar),
birth_date = IF(status = 'pending', VALUES(birth_date), birth_date),
app_version = IF(status = 'pending', VALUES(app_version), app_version),
build_number = IF(status = 'pending', VALUES(build_number), build_number),
source = IF(status = 'pending', VALUES(source), source),
install_channel = IF(status = 'pending', VALUES(install_channel), install_channel),
campaign = IF(status = 'pending', VALUES(campaign), campaign),
platform = IF(status = 'pending', VALUES(platform), platform),
language = IF(status = 'pending', VALUES(language), language),
timezone = IF(status = 'pending', VALUES(timezone), timezone),
expires_at_ms = IF(status = 'pending', VALUES(expires_at_ms), expires_at_ms),
updated_at_ms = IF(status = 'pending', VALUES(updated_at_ms), updated_at_ms)
`, appcode.Normalize(pending.AppCode), pending.PendingID, pending.Provider, pending.ProviderSubject, shared.NullableString(pending.ProviderUnionID),
shared.NullableString(pending.Registration.Username), shared.NullableString(pending.Registration.Gender), shared.NullableString(pending.Registration.Country), shared.NullableString(pending.Registration.InviteCode), shared.NullableString(pending.Registration.IP), shared.NullableString(pending.Registration.UserAgent), shared.NullableString(pending.Registration.CountryByIP),
shared.NullableString(pending.Registration.DeviceID), shared.NullableString(pending.Registration.Device), shared.NullableString(pending.Registration.OSVersion), shared.NullableString(pending.Registration.Avatar), shared.NullableString(pending.Registration.BirthDate), shared.NullableString(pending.Registration.AppVersion), shared.NullableString(pending.Registration.BuildNumber), shared.NullableString(pending.Registration.Source),
shared.NullableString(pending.Registration.InstallChannel), shared.NullableString(pending.Registration.Campaign), shared.NullableString(pending.Registration.Platform), shared.NullableString(pending.Registration.Language), shared.NullableString(pending.Registration.Timezone), authdomain.PendingThirdPartyRegistrationStatusPending,
pending.ExpiresAtMs, pending.CreatedAtMs, pending.UpdatedAtMs)
if err != nil {
return authdomain.PendingThirdPartyRegistration{}, mapAuthDuplicateError(err)
}
return r.findPendingThirdPartyRegistrationByProvider(ctx, pending.AppCode, pending.Provider, pending.ProviderSubject)
}
func (r *Repository) FindPendingThirdPartyRegistration(ctx context.Context, appCode string, pendingID string) (authdomain.PendingThirdPartyRegistration, error) {
return r.scanPendingThirdPartyRegistration(ctx, r.db.QueryRowContext(ctx, pendingThirdPartyRegistrationSelectSQL()+`
WHERE app_code = ? AND pending_id = ?
`, appcode.Normalize(appCode), pendingID))
}
func (r *Repository) findPendingThirdPartyRegistrationByProvider(ctx context.Context, appCode string, provider string, providerSubject string) (authdomain.PendingThirdPartyRegistration, error) {
return r.scanPendingThirdPartyRegistration(ctx, r.db.QueryRowContext(ctx, pendingThirdPartyRegistrationSelectSQL()+`
WHERE app_code = ? AND provider = ? AND provider_subject = ?
`, appcode.Normalize(appCode), provider, providerSubject))
}
// CreateThirdPartyUser 原子创建用户、默认短号、三方身份和首个 session。
func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, inviteBind invitedomain.BindCommand, maxAccountsPerDevice int32) error {
@ -88,6 +176,10 @@ func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.U
if _, err := invitestorage.BindRelation(ctx, tx, inviteBind); err != nil {
return err
}
if err := userstorage.InsertUserRegisteredOutbox(ctx, tx, user); err != nil {
// 直接带完整资料注册时UserRegistered 必须与用户/三方身份同事务提交,统计国家不能事后补猜。
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO third_party_identities (app_code, user_id, provider, provider_subject, provider_union_id, created_at_ms, updated_at_ms)
@ -104,4 +196,135 @@ func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.U
return tx.Commit()
}
func (r *Repository) CompletePendingThirdPartyUser(ctx context.Context, pendingID string, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, inviteBind invitedomain.BindCommand, maxAccountsPerDevice int32) (userdomain.User, error) {
// pending 消费、真实用户创建、默认短号、三方绑定、session、邀请码和注册事件必须同事务
// 任何一步失败都不能留下半个真实账号。
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userdomain.User{}, err
}
defer tx.Rollback()
pending, err := r.scanPendingThirdPartyRegistration(ctx, tx.QueryRowContext(ctx, pendingThirdPartyRegistrationSelectSQL()+`
WHERE app_code = ? AND pending_id = ?
FOR UPDATE
`, appcode.Normalize(user.AppCode), pendingID))
if err != nil {
return userdomain.User{}, err
}
if pending.Status != authdomain.PendingThirdPartyRegistrationStatusPending {
return userdomain.User{}, xerr.New(xerr.Conflict, "pending registration already completed")
}
if pending.ExpiresAtMs > 0 && pending.ExpiresAtMs <= user.CreatedAtMs {
return userdomain.User{}, xerr.New(xerr.SessionExpired, "pending registration expired")
}
if pending.Provider != thirdParty.Provider || pending.ProviderSubject != thirdParty.ProviderSubject {
return userdomain.User{}, xerr.New(xerr.Conflict, "pending registration identity mismatch")
}
if err := ensureRegisterDeviceQuotaTx(ctx, tx, user.AppCode, user.RegisterDeviceID, maxAccountsPerDevice); err != nil {
return userdomain.User{}, err
}
if err := userstorage.InsertUserIdentity(ctx, tx, user, identity); err != nil {
return userdomain.User{}, mapAuthDuplicateError(err)
}
if _, err := invitestorage.EnsurePrimaryCodeForUser(ctx, tx, user.AppCode, user.UserID, user.CreatedAtMs); err != nil {
return userdomain.User{}, err
}
if binding, err := invitestorage.BindRelation(ctx, tx, inviteBind); err != nil {
return userdomain.User{}, err
} else {
user.InviteBinding = binding
if binding.InviteCode != "" {
user.InviteCode = binding.InviteCode
}
}
if err := userstorage.InsertUserRegisteredOutbox(ctx, tx, user); err != nil {
return userdomain.User{}, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO third_party_identities (app_code, user_id, provider, provider_subject, provider_union_id, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, appcode.Normalize(thirdParty.AppCode), thirdParty.UserID, thirdParty.Provider, thirdParty.ProviderSubject, shared.NullableString(thirdParty.ProviderUnionID), thirdParty.CreatedAtMs, thirdParty.UpdatedAtMs); err != nil {
return userdomain.User{}, mapAuthDuplicateError(err)
}
if err := insertSession(ctx, tx, session); err != nil {
return userdomain.User{}, err
}
if _, err := tx.ExecContext(ctx, `
UPDATE pending_third_party_registrations
SET status = ?, completed_user_id = ?, completed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND pending_id = ?
`, authdomain.PendingThirdPartyRegistrationStatusCompleted, user.UserID, user.CreatedAtMs, user.CreatedAtMs, appcode.Normalize(user.AppCode), pendingID); err != nil {
return userdomain.User{}, err
}
if err := tx.Commit(); err != nil {
return userdomain.User{}, err
}
return user, nil
}
func pendingThirdPartyRegistrationSelectSQL() string {
return `
SELECT app_code, pending_id, provider, provider_subject, COALESCE(provider_union_id, ''),
COALESCE(username, ''), COALESCE(gender, ''), COALESCE(country, ''), COALESCE(invite_code, ''),
COALESCE(register_ip, ''), COALESCE(register_user_agent, ''), COALESCE(country_by_ip, ''),
COALESCE(device_id, ''), COALESCE(device, ''), COALESCE(os_version, ''), COALESCE(avatar, ''),
COALESCE(birth_date, ''), COALESCE(app_version, ''), COALESCE(build_number, ''), COALESCE(source, ''),
COALESCE(install_channel, ''), COALESCE(campaign, ''), COALESCE(platform, ''), COALESCE(language, ''),
COALESCE(timezone, ''), status, completed_user_id, expires_at_ms, completed_at_ms, created_at_ms, updated_at_ms
FROM pending_third_party_registrations
`
}
type pendingThirdPartyScanner interface {
Scan(dest ...any) error
}
func (r *Repository) scanPendingThirdPartyRegistration(_ context.Context, row pendingThirdPartyScanner) (authdomain.PendingThirdPartyRegistration, error) {
var pending authdomain.PendingThirdPartyRegistration
err := row.Scan(
&pending.AppCode,
&pending.PendingID,
&pending.Provider,
&pending.ProviderSubject,
&pending.ProviderUnionID,
&pending.Registration.Username,
&pending.Registration.Gender,
&pending.Registration.Country,
&pending.Registration.InviteCode,
&pending.Registration.IP,
&pending.Registration.UserAgent,
&pending.Registration.CountryByIP,
&pending.Registration.DeviceID,
&pending.Registration.Device,
&pending.Registration.OSVersion,
&pending.Registration.Avatar,
&pending.Registration.BirthDate,
&pending.Registration.AppVersion,
&pending.Registration.BuildNumber,
&pending.Registration.Source,
&pending.Registration.InstallChannel,
&pending.Registration.Campaign,
&pending.Registration.Platform,
&pending.Registration.Language,
&pending.Registration.Timezone,
&pending.Status,
&pending.CompletedUserID,
&pending.ExpiresAtMs,
&pending.CompletedAtMs,
&pending.CreatedAtMs,
&pending.UpdatedAtMs,
)
if err == sql.ErrNoRows {
return authdomain.PendingThirdPartyRegistration{}, xerr.New(xerr.NotFound, "pending registration not found")
}
if err != nil {
return authdomain.PendingThirdPartyRegistration{}, err
}
pending.AppCode = appcode.Normalize(pending.AppCode)
pending.Registration.AppCode = pending.AppCode
return pending, nil
}
// RecordLoginAudit 写登录审计;调用方不会让审计失败影响主链路。

View File

@ -354,16 +354,36 @@ func (r *Repository) FindThirdPartyIdentity(ctx context.Context, provider string
return r.Repository.AuthRepository().FindThirdPartyIdentity(ctx, provider, providerSubject)
}
// UpsertPendingThirdPartyRegistration 让测试 wrapper 继续满足 pending 三方注册接口。
func (r *Repository) UpsertPendingThirdPartyRegistration(ctx context.Context, pending authdomain.PendingThirdPartyRegistration) (authdomain.PendingThirdPartyRegistration, error) {
return r.Repository.AuthRepository().UpsertPendingThirdPartyRegistration(ctx, pending)
}
// FindPendingThirdPartyRegistration 让测试 wrapper 继续满足 pending 三方注册接口。
func (r *Repository) FindPendingThirdPartyRegistration(ctx context.Context, appCode string, pendingID string) (authdomain.PendingThirdPartyRegistration, error) {
return r.Repository.AuthRepository().FindPendingThirdPartyRegistration(ctx, appCode, pendingID)
}
// CountUsersByRegisterDeviceID 让测试 wrapper 继续满足认证接口。
func (r *Repository) CountUsersByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) (int64, error) {
return r.Repository.AuthRepository().CountUsersByRegisterDeviceID(ctx, appCode, deviceID)
}
// ListDisplayUserIDsByRegisterDeviceID 让测试 wrapper 继续满足认证接口。
func (r *Repository) ListDisplayUserIDsByRegisterDeviceID(ctx context.Context, appCode string, deviceID string) ([]string, error) {
return r.Repository.AuthRepository().ListDisplayUserIDsByRegisterDeviceID(ctx, appCode, deviceID)
}
// CreateThirdPartyUser 让测试 wrapper 继续满足认证接口。
func (r *Repository) CreateThirdPartyUser(ctx context.Context, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand, maxAccountsPerDevice int32) error {
return r.Repository.AuthRepository().CreateThirdPartyUser(ctx, user, identity, thirdParty, session, invite, maxAccountsPerDevice)
}
// CompletePendingThirdPartyUser 让测试 wrapper 继续满足 pending 三方完成注册接口。
func (r *Repository) CompletePendingThirdPartyUser(ctx context.Context, pendingID string, user userdomain.User, identity userdomain.Identity, thirdParty authdomain.ThirdPartyIdentity, session authdomain.Session, invite invitedomain.BindCommand, maxAccountsPerDevice int32) (userdomain.User, error) {
return r.Repository.AuthRepository().CompletePendingThirdPartyUser(ctx, pendingID, user, identity, thirdParty, session, invite, maxAccountsPerDevice)
}
// GetRegisterRiskConfig 让测试 wrapper 继续满足认证接口。
func (r *Repository) GetRegisterRiskConfig(ctx context.Context, appCode string) (authdomain.RegisterRiskConfig, error) {
return r.Repository.AuthRepository().GetRegisterRiskConfig(ctx, appCode)

View File

@ -139,6 +139,30 @@ func (s *Server) LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPart
return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser, ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil
}
// CompletePendingThirdPartyRegistration 消费 pending token 对应的三方注册态,并一次性创建真实用户。
func (s *Server) CompletePendingThirdPartyRegistration(ctx context.Context, req *userv1.CompletePendingThirdPartyRegistrationRequest) (*userv1.CompleteOnboardingResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
if s.authSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "auth service is not configured"))
}
if !s.authSvc.TokenConfigValid() {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "token service is not configured"))
}
user, token, err := s.authSvc.CompletePendingThirdPartyRegistration(ctx, req.GetPendingRegistrationId(), req.GetUsername(), req.GetAvatar(), req.GetGender(), req.GetCountry(), req.GetInviteCode(), authMeta(req.GetMeta()))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.CompleteOnboardingResponse{
User: toProtoUser(user),
ProfileCompleted: user.ProfileCompleted,
ProfileCompletedAtMs: user.ProfileCompletedAtMs,
OnboardingStatus: string(user.OnboardingStatus),
Token: toProtoToken(token),
Invite: toProtoInviteBinding(user.InviteBinding),
}, nil
}
// SetPassword 为已登录用户首次写入密码身份。
func (s *Server) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())

View File

@ -204,7 +204,10 @@ func (c *Client) ListProductTypes(ctx context.Context, req walletservice.V5PayPr
PayinList []struct {
ProductType string `json:"productType"`
ProductName string `json:"productName"`
Description string `json:"description"`
Logo string `json:"logo"`
Currency string `json:"currency"`
ProductMode any `json:"productMode"`
SupportCashierMode int `json:"supportCashierMode"`
} `json:"payinList"`
}
@ -233,7 +236,10 @@ func (c *Client) ListProductTypes(ctx context.Context, req walletservice.V5PayPr
products = append(products, walletservice.V5PayProductType{
ProductType: product.ProductType,
ProductName: product.ProductName,
Description: product.Description,
Logo: product.Logo,
Currency: product.Currency,
ProductMode: valueToString(product.ProductMode),
SupportCashierMode: product.SupportCashierMode,
})
}

View File

@ -25,6 +25,8 @@ type DebitGiftCommand struct {
TargetAgencyOwnerUserID int64
// RobotGift 表示本次扣费只服务机器人房间展示:扣 ROBOT_COIN不给主播钻石不进入真实礼物墙投影。
RobotGift bool
// DirectGift 表示本次扣费来自私聊送礼:没有 Room Cell 命令和房间 outbox但仍复用真实扣费、返币、礼物墙和主播周期结算。
DirectGift bool
// EntitlementID 非空表示本次送礼来自用户背包礼物权益,账务只扣库存但仍按礼物价格计算房间贡献。
EntitlementID string
// ChargeSource 区分 coin/bag为空按 coin 兼容旧链路。
@ -87,6 +89,10 @@ type Receipt struct {
HostPeriodCycleKey string
EntitlementID string
ChargeSource string
// GiftIncomeCoinAmount 是收礼人按区域/礼物类型返还的 COIN用于私聊送礼回执和客户端展示同步。
GiftIncomeCoinAmount int64
// GiftIncomeBalanceAfter 是收礼人 COIN 入账后的余额;没有返币时为 0。
GiftIncomeBalanceAfter int64
}
// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。

View File

@ -147,6 +147,32 @@ type ThirdPartyPaymentRateCommand struct {
OperatorUserID int64
}
// ThirdPartyPaymentMethodSyncCommand 表示后台要求 wallet-service 从支付商实时拉取可用方式并同步到本地配置表。
type ThirdPartyPaymentMethodSyncCommand struct {
AppCode string
ProviderCode string
OperatorUserID int64
}
// ThirdPartyPaymentMethodSyncIssue 记录某个国家/币种同步失败或被支付商拒绝的原因;同步整体可部分成功。
type ThirdPartyPaymentMethodSyncIssue struct {
CountryCode string
CurrencyCode string
Code string
Message string
}
// ThirdPartyPaymentMethodSyncResult 是同步操作的可审计摘要,避免后台只知道请求成功却不知道实际写入了什么。
type ThirdPartyPaymentMethodSyncResult struct {
ProviderCode string
ScannedCountryCount int
FetchedCountryCount int
CreatedCount int
UpdatedCount int
SkippedCount int
Issues []ThirdPartyPaymentMethodSyncIssue
}
// H5RechargeOptionsQuery 是站外充值页在账号确认后读取商品和支付方式的最小条件。
type H5RechargeOptionsQuery struct {
AppCode string

View File

@ -0,0 +1,207 @@
package wallet
import (
"context"
"encoding/json"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
)
type v5PayProductSyncCountry struct {
CountryCode string
CountryName string
CurrencyCode string
SortBase int32
}
var v5PayProductSyncCountries = []v5PayProductSyncCountry{
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", SortBase: 2000},
{CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", SortBase: 2300},
{CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", SortBase: 2400},
{CountryCode: "AE", CountryName: "United Arab Emirates", CurrencyCode: "AED", SortBase: 2500},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", SortBase: 2600},
{CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", SortBase: 2800},
{CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", SortBase: 2900},
{CountryCode: "TR", CountryName: "Turkey", CurrencyCode: "TRY", SortBase: 3000},
{CountryCode: "MX", CountryName: "Mexico", CurrencyCode: "MXN", SortBase: 3100},
{CountryCode: "VN", CountryName: "Vietnam", CurrencyCode: "VND", SortBase: 3200},
{CountryCode: "BR", CountryName: "Brazil", CurrencyCode: "BRL", SortBase: 3300},
}
// SyncThirdPartyPaymentMethods 从支付商实时拉取已开通方式并落本地配置表;当前只有 V5Pay 提供 productTypes 能力。
func (s *Service) SyncThirdPartyPaymentMethods(ctx context.Context, command ledger.ThirdPartyPaymentMethodSyncCommand) (ledger.ThirdPartyPaymentMethodSyncResult, error) {
command.AppCode = appcode.Normalize(command.AppCode)
command.ProviderCode = ledger.NormalizePaymentProvider(command.ProviderCode)
result := ledger.ThirdPartyPaymentMethodSyncResult{ProviderCode: command.ProviderCode}
if command.ProviderCode == "" {
command.ProviderCode = ledger.PaymentProviderV5Pay
result.ProviderCode = command.ProviderCode
}
if command.OperatorUserID <= 0 {
return result, xerr.New(xerr.InvalidArgument, "payment method sync operator is required")
}
if command.ProviderCode != ledger.PaymentProviderV5Pay {
return result, xerr.New(xerr.InvalidArgument, "payment provider does not support method sync")
}
if s.repository == nil {
return result, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if s.v5Pay == nil {
return result, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
methods, skipped, issues := s.fetchV5PayPaymentMethodsForSync(ctx)
result.ScannedCountryCount = len(v5PayProductSyncCountries)
result.FetchedCountryCount = len(v5PayProductSyncCountries) - len(issues)
result.SkippedCount = skipped
result.Issues = issues
if len(methods) == 0 {
return result, nil
}
created, updated, err := s.repository.UpsertThirdPartyPaymentMethods(ctx, command.AppCode, ledger.PaymentProviderV5Pay, methods)
if err != nil {
return result, err
}
result.CreatedCount = created
result.UpdatedCount = updated
return result, nil
}
func (s *Service) fetchV5PayPaymentMethodsForSync(ctx context.Context) ([]ledger.ThirdPartyPaymentMethod, int, []ledger.ThirdPartyPaymentMethodSyncIssue) {
methods := make([]ledger.ThirdPartyPaymentMethod, 0)
issues := make([]ledger.ThirdPartyPaymentMethodSyncIssue, 0)
skipped := 0
seen := map[string]struct{}{}
for _, country := range v5PayProductSyncCountries {
// V5Pay 的 productTypes 接口必须逐国家/币种查询,没有“全部国家”接口;候选集覆盖当前种子国家和后台已开通截图里的新增国家。
products, err := s.v5Pay.ListProductTypes(ctx, V5PayProductTypesRequest{
CountryCode: country.CountryCode,
CurrencyCode: country.CurrencyCode,
})
if err != nil {
issues = append(issues, v5PayProductSyncIssue(country, err))
continue
}
for index, product := range products.PayinList {
method, ok := v5PayProductToPaymentMethod(country, product, index)
if !ok {
skipped++
continue
}
key := thirdPartyPaymentMethodSyncKey(method)
if _, exists := seen[key]; exists {
skipped++
continue
}
seen[key] = struct{}{}
methods = append(methods, method)
}
}
return methods, skipped, issues
}
func v5PayProductToPaymentMethod(country v5PayProductSyncCountry, product V5PayProductType, index int) (ledger.ThirdPartyPaymentMethod, bool) {
productType := strings.ToUpper(strings.TrimSpace(product.ProductType))
currency := strings.ToUpper(strings.TrimSpace(product.Currency))
if currency == "" {
currency = country.CurrencyCode
}
if productType == "" || product.SupportCashierMode == 0 || !strings.EqualFold(currency, country.CurrencyCode) {
return ledger.ThirdPartyPaymentMethod{}, false
}
methodName := strings.TrimSpace(product.ProductName)
if methodName == "" {
methodName = productType
}
return ledger.ThirdPartyPaymentMethod{
ProviderCode: ledger.PaymentProviderV5Pay,
CountryCode: country.CountryCode,
CountryName: country.CountryName,
CurrencyCode: currency,
PayWay: v5PayProductPayWay(product),
PayType: productType,
MethodName: methodName,
LogoURL: strings.TrimSpace(product.Logo),
Status: ledger.ThirdPartyPaymentStatusActive,
USDToCurrencyRate: "0",
SortOrder: country.SortBase + int32(index+1)*10,
}, true
}
func v5PayProductPayWay(product V5PayProductType) string {
if value := normalizeV5PayProductMode(product.ProductMode); value != "" {
return value
}
value := strings.ToUpper(strings.TrimSpace(product.ProductType + " " + product.ProductName + " " + product.Description))
switch {
case strings.Contains(value, "CARD"):
return "Card"
case strings.Contains(value, "QR"):
return "QR"
case strings.Contains(value, "BANK") || strings.Contains(value, "TRANSFER") || strings.Contains(value, "_VA") || strings.Contains(value, "_ONLINE"):
return "BankTransfer"
case strings.Contains(value, "UPI"):
return "UPI"
case strings.Contains(value, "BILL"):
return "Billing"
default:
return "Ewallet"
}
}
func normalizeV5PayProductMode(value string) string {
mode := strings.ReplaceAll(strings.TrimSpace(value), "_", " ")
mode = strings.ReplaceAll(mode, "-", " ")
switch strings.ToLower(strings.ReplaceAll(mode, " ", "")) {
case "":
return ""
case "card":
return "Card"
case "bank", "banktransfer", "bankonline", "onlinebanking":
return "BankTransfer"
case "ewallet", "wallet":
return "Ewallet"
case "qr":
return "QR"
case "upi":
return "UPI"
case "billing", "bill":
return "Billing"
default:
return strings.TrimSpace(value)
}
}
func v5PayProductSyncIssue(country v5PayProductSyncCountry, err error) ledger.ThirdPartyPaymentMethodSyncIssue {
issue := ledger.ThirdPartyPaymentMethodSyncIssue{
CountryCode: country.CountryCode,
CurrencyCode: country.CurrencyCode,
Code: "REQUEST_FAILED",
Message: err.Error(),
}
if raw := providerPayloadJSON(err); raw != "" {
var payload struct {
Code string `json:"code"`
Message string `json:"message"`
}
if json.Unmarshal([]byte(raw), &payload) == nil {
if strings.TrimSpace(payload.Code) != "" {
issue.Code = strings.TrimSpace(payload.Code)
}
if strings.TrimSpace(payload.Message) != "" {
issue.Message = strings.TrimSpace(payload.Message)
}
}
}
return issue
}
func thirdPartyPaymentMethodSyncKey(method ledger.ThirdPartyPaymentMethod) string {
return strings.ToUpper(strings.TrimSpace(method.CountryCode)) + "|" +
strings.TrimSpace(method.PayWay) + "|" +
strings.ToUpper(strings.TrimSpace(method.PayType))
}

View File

@ -14,6 +14,15 @@ func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand
return s.giftLedger.DebitGift(ctx, command)
}
// DebitDirectGift 校验私聊送礼扣费命令,并标记为 direct gift 账务场景后复用同一条落账链路。
func (s *Service) DebitDirectGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
if s == nil || s.giftLedger == nil {
return ledger.Receipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.DirectGift = true
return s.giftLedger.DebitGift(ctx, command)
}
// DebitRobotGift 校验机器人房间专用送礼扣费命令,并强制使用 ROBOT_COIN 账务语义。
func (s *Service) DebitRobotGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
if s == nil || s.giftLedger == nil {

View File

@ -134,7 +134,10 @@ type V5PayProductTypesRequest struct {
type V5PayProductType struct {
ProductType string
ProductName string
Description string
Logo string
Currency string
ProductMode string
SupportCashierMode int
}

View File

@ -107,6 +107,7 @@ type ExternalRechargeStore interface {
ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error)
SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error)
UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error)
UpsertThirdPartyPaymentMethods(ctx context.Context, appCode string, providerCode string, methods []ledger.ThirdPartyPaymentMethod) (int, int, error)
GetThirdPartyPaymentMethod(ctx context.Context, appCode string, methodID int64) (ledger.ThirdPartyPaymentMethod, error)
ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error)
ListTemporaryExternalRechargeOrders(ctx context.Context, query ledger.ListTemporaryExternalRechargeOrdersQuery) ([]ledger.ExternalRechargeOrder, int64, error)

View File

@ -100,6 +100,66 @@ func TestDebitGiftUsesServerPriceWithoutGiftPointCredit(t *testing.T) {
}
}
func TestDebitDirectGiftCreditsReceiverAndKeepsDirectBizType(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(12001, 1000)
repository.SetGiftPrice("direct-rose", "v1", 100, 0, 100)
svc := walletservice.New(repository)
receipt, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-direct-rose",
SenderUserID: 12001,
SenderRegionID: 1001,
RegionID: 1001,
TargetUserID: 12002,
TargetIsHost: true,
TargetHostRegionID: 7701,
GiftID: "direct-rose",
GiftCount: 1,
PriceVersion: "v1",
})
if err != nil {
t.Fatalf("DebitDirectGift failed: %v", err)
}
if receipt.CoinSpent != 100 || receipt.BalanceAfter != 900 || receipt.GiftIncomeCoinAmount != 30 || receipt.GiftIncomeBalanceAfter != 30 || receipt.HostPeriodDiamondAdded != 100 {
t.Fatalf("direct gift settlement mismatch: %+v", receipt)
}
assertBalance(t, svc, 12001, ledger.AssetCoin, 900)
assertBalance(t, svc, 12002, ledger.AssetCoin, 30)
if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND biz_type = ?", receipt.TransactionID, "direct_gift_debit"); got != 1 {
t.Fatalf("direct gift transaction must use direct biz type, got %d", got)
}
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND room_id = ''", receipt.TransactionID); got != 2 {
t.Fatalf("direct gift entries must not pretend to belong to a room, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 {
t.Fatalf("direct gift should publish receiver income fact, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPeriodDiamondCredited"); got != 1 {
t.Fatalf("direct gift to active host should credit host period diamonds, got %d", got)
}
replayed, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{
CommandID: "cmd-direct-rose",
SenderUserID: 12001,
SenderRegionID: 1001,
RegionID: 1001,
TargetUserID: 12002,
TargetIsHost: true,
TargetHostRegionID: 7701,
GiftID: "direct-rose",
GiftCount: 1,
PriceVersion: "v1",
})
if err != nil {
t.Fatalf("DebitDirectGift replay failed: %v", err)
}
if replayed.TransactionID != receipt.TransactionID || replayed.GiftIncomeCoinAmount != receipt.GiftIncomeCoinAmount {
t.Fatalf("direct gift replay receipt mismatch: got %+v want %+v", replayed, receipt)
}
}
// TestBatchDebitGiftSettlesAllTargetsAtomically 验证多目标送礼一次扣总额,并为每个接收方写独立收礼事实。
func TestBatchDebitGiftSettlesAllTargetsAtomically(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -2427,6 +2487,70 @@ func TestH5RechargeOptionsFilterV5PayByRuntimeProductTypes(t *testing.T) {
}
}
func TestSyncThirdPartyPaymentMethodsAddsV5PayRuntimeProducts(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
phCoinsMethodID := repository.ThirdPartyProviderPaymentMethodID(ledger.PaymentProviderV5Pay, "PH", "Ewallet", "COINS_ONLINE")
repository.SetThirdPartyPaymentRate(phCoinsMethodID, "52.50000000")
v5Pay := &fakeV5PayClient{
productTypes: map[string]walletservice.V5PayProductTypesResponse{
"PH|PHP": {
PayinList: []walletservice.V5PayProductType{
{ProductType: "COINS_ONLINE", ProductName: "Coins.ph Online Updated", Currency: "PHP", ProductMode: "Ewallet", SupportCashierMode: 1},
},
},
"TR|TRY": {
PayinList: []walletservice.V5PayProductType{
{ProductType: "CREDIT_CARD", ProductName: "Credit Card", Currency: "TRY", ProductMode: "Card", SupportCashierMode: 1},
{ProductType: "PAPARA_ONLINE", ProductName: "Papara Online", Currency: "TRY", ProductMode: "Ewallet", SupportCashierMode: 1},
{ProductType: "PAPARA_QR", ProductName: "Papara QR", Currency: "TRY", ProductMode: "QR", SupportCashierMode: 0},
},
},
},
productErrs: map[string]error{
"MX|MXN": fakeProviderPayloadError{message: "v5pay product types rejected", rawJSON: `{"code":"1013","message":"app is invalid"}`},
},
}
svc.SetV5PayClient(v5Pay)
result, err := svc.SyncThirdPartyPaymentMethods(context.Background(), ledger.ThirdPartyPaymentMethodSyncCommand{
AppCode: appcode.Default,
ProviderCode: ledger.PaymentProviderV5Pay,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("SyncThirdPartyPaymentMethods failed: %v", err)
}
if result.CreatedCount != 2 || result.UpdatedCount != 1 || result.SkippedCount != 1 || len(result.Issues) != 1 || result.Issues[0].Code != "1013" {
t.Fatalf("sync result mismatch: %+v", result)
}
if !v5PayProductRequestSeen(v5Pay.productReqs, "TR", "TRY") || !v5PayProductRequestSeen(v5Pay.productReqs, "MX", "MXN") {
t.Fatalf("sync must scan configured V5Pay country/currency candidates: %+v", v5Pay.productReqs)
}
channels, err := svc.ListThirdPartyPaymentChannels(context.Background(), ledger.ListThirdPartyPaymentChannelsQuery{
AppCode: appcode.Default,
ProviderCode: ledger.PaymentProviderV5Pay,
IncludeDisabledMethods: true,
})
if err != nil {
t.Fatalf("ListThirdPartyPaymentChannels after sync failed: %v", err)
}
methods := channels[0].Methods
creditCard, ok := thirdPartyPaymentMethodByKey(methods, "TR", "Card", "CREDIT_CARD")
if !ok || creditCard.CurrencyCode != "TRY" || creditCard.USDToCurrencyRate != "0.00000000" {
t.Fatalf("TR credit card should be inserted with unset rate: %+v ok=%v", creditCard, ok)
}
papara, ok := thirdPartyPaymentMethodByKey(methods, "TR", "Ewallet", "PAPARA_ONLINE")
if !ok || papara.MethodName != "Papara Online" {
t.Fatalf("TR Papara should be inserted from V5Pay product: %+v ok=%v", papara, ok)
}
phCoins, ok := thirdPartyPaymentMethodByKey(methods, "PH", "Ewallet", "COINS_ONLINE")
if !ok || phCoins.MethodName != "Coins.ph Online Updated" || phCoins.USDToCurrencyRate != "52.50000000" {
t.Fatalf("existing PH method should update display fields but preserve rate: %+v ok=%v", phCoins, ok)
}
}
// TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry 锁定下单阶段再次校验国家,防止前端伪造 method_id 绕过 options 过滤。
func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -5864,6 +5988,24 @@ func paymentMethodsContain(methods []ledger.ThirdPartyPaymentMethod, countryCode
return false
}
func thirdPartyPaymentMethodByKey(methods []ledger.ThirdPartyPaymentMethod, countryCode string, payWay string, payType string) (ledger.ThirdPartyPaymentMethod, bool) {
for _, method := range methods {
if method.CountryCode == countryCode && method.PayWay == payWay && method.PayType == payType {
return method, true
}
}
return ledger.ThirdPartyPaymentMethod{}, false
}
func v5PayProductRequestSeen(requests []walletservice.V5PayProductTypesRequest, countryCode string, currencyCode string) bool {
for _, request := range requests {
if strings.EqualFold(request.CountryCode, countryCode) && strings.EqualFold(request.CurrencyCode, currencyCode) {
return true
}
}
return false
}
func giftWallItemByID(items []ledger.GiftWallItem, giftID string) (ledger.GiftWallItem, bool) {
for _, item := range items {
if item.GiftID == giftID {
@ -5996,6 +6138,19 @@ type fakeV5PayClient struct {
err error
}
type fakeProviderPayloadError struct {
message string
rawJSON string
}
func (e fakeProviderPayloadError) Error() string {
return e.message
}
func (e fakeProviderPayloadError) ProviderPayloadJSON() string {
return e.rawJSON
}
func (f *fakeV5PayClient) CreateOrder(_ context.Context, req walletservice.V5PayCreateOrderRequest) (walletservice.V5PayCreateOrderResponse, error) {
f.createReq = req
if f.err != nil {

View File

@ -3,6 +3,7 @@ package mysql
const (
transactionStatusSucceeded = "succeeded"
bizTypeGiftDebit = "gift_debit"
bizTypeDirectGiftDebit = "direct_gift_debit"
bizTypeRobotGiftDebit = "robot_gift_debit"
bizTypeManualCredit = "manual_credit"
bizTypeTaskReward = "task_reward"

View File

@ -217,6 +217,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
GiftPointAmount: 0,
HeatUnitValue: price.CoinPrice,
RobotGift: command.RobotGift,
DirectGift: command.DirectGift,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
return ledger.Receipt{}, err

View File

@ -11,6 +11,9 @@ func giftDebitBizType(command ledger.DebitGiftCommand) string {
if command.RobotGift {
return bizTypeRobotGiftDebit
}
if command.DirectGift {
return bizTypeDirectGiftDebit
}
return bizTypeGiftDebit
}
@ -75,6 +78,27 @@ func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Rece
func debitRequestHash(command ledger.DebitGiftCommand) string {
if command.DirectGift {
// direct gift 没有 Room Cell 命令,必须把场景写进幂等摘要,避免和空 room_id 的内部兼容调用互相复用。
return stableHash(fmt.Sprintf("direct_gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t|%s|%s",
appcode.Normalize(command.AppCode),
command.RoomID,
command.SenderUserID,
command.TargetUserID,
command.GiftID,
command.GiftCount,
strings.TrimSpace(command.PriceVersion),
command.RegionID,
command.SenderRegionID,
command.TargetIsHost,
command.TargetHostRegionID,
command.TargetAgencyOwnerUserID,
command.RobotGift,
strings.TrimSpace(command.EntitlementID),
normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID),
))
}
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t|%s|%s",
appcode.Normalize(command.AppCode),
command.RoomID,

View File

@ -52,6 +52,7 @@ type giftMetadata struct {
RoomContributionRatioPercent string `json:"room_contribution_ratio_percent"`
RoomContributionRatioRegionID int64 `json:"room_contribution_ratio_region_id"`
RobotGift bool `json:"robot_gift,omitempty"`
DirectGift bool `json:"direct_gift,omitempty"`
}
type adminCreditMetadata struct {

View File

@ -671,6 +671,7 @@ func giftIncomeCreditedEvent(transactionID string, commandID string, metadata gi
"user_id": metadata.TargetUserID,
"sender_user_id": metadata.SenderUserID,
"room_id": metadata.RoomID,
"direct_gift": metadata.DirectGift,
"gift_id": metadata.GiftID,
"gift_count": metadata.GiftCount,
"gift_type_code": metadata.GiftTypeCode,

View File

@ -108,6 +108,107 @@ func (r *Repository) UpdateThirdPartyPaymentRate(ctx context.Context, command le
return r.GetThirdPartyPaymentMethod(ctx, appcode.FromContext(ctx), command.MethodID)
}
func (r *Repository) UpsertThirdPartyPaymentMethods(ctx context.Context, appCode string, providerCode string, methods []ledger.ThirdPartyPaymentMethod) (int, int, error) {
if r == nil || r.db == nil {
return 0, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, appCode)
app := appcode.FromContext(ctx)
provider := ledger.NormalizePaymentProvider(providerCode)
if provider == "" {
return 0, 0, xerr.New(xerr.InvalidArgument, "payment provider is invalid")
}
if len(methods) == 0 {
return 0, 0, nil
}
existing, err := r.loadThirdPartyPaymentMethodKeys(ctx, app, provider)
if err != nil {
return 0, 0, err
}
nowMS := time.Now().UnixMilli()
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, err
}
defer tx.Rollback()
// 同步按钮只保证渠道行存在;如果运营曾手动关闭渠道,不在同步过程中重新打开。
if _, err := tx.ExecContext(ctx, `
INSERT INTO third_party_payment_channels (
app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 'active', ?, ?, ?)
ON DUPLICATE KEY UPDATE provider_name = VALUES(provider_name), updated_at_ms = VALUES(updated_at_ms)`,
app, provider, thirdPartyPaymentProviderName(provider), thirdPartyPaymentProviderSortOrder(provider), nowMS, nowMS,
); err != nil {
return 0, 0, err
}
created := 0
updated := 0
seen := map[string]struct{}{}
for _, method := range methods {
method = normalizeThirdPartyPaymentMethodForUpsert(app, provider, method, nowMS)
if method.CountryCode == "" || method.CurrencyCode == "" || method.PayWay == "" || method.PayType == "" || method.MethodName == "" {
continue
}
key := thirdPartyPaymentMethodKey(method.CountryCode, method.PayWay, method.PayType)
if _, duplicate := seen[key]; duplicate {
continue
}
seen[key] = struct{}{}
if _, err := tx.ExecContext(ctx, `
INSERT INTO third_party_payment_methods (
app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type,
method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
currency_code = VALUES(currency_code),
method_name = VALUES(method_name),
logo_url = CASE WHEN VALUES(logo_url) <> '' THEN VALUES(logo_url) ELSE logo_url END,
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms)`,
method.AppCode, method.ProviderCode, method.CountryCode, method.CountryName, method.CurrencyCode, method.PayWay, method.PayType,
method.MethodName, method.LogoURL, method.USDToCurrencyRate, method.SortOrder, method.CreatedAtMS, method.UpdatedAtMS,
); err != nil {
return 0, 0, err
}
if _, exists := existing[key]; exists {
updated++
} else {
created++
}
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
return created, updated, nil
}
func (r *Repository) loadThirdPartyPaymentMethodKeys(ctx context.Context, appCode string, providerCode string) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT country_code, pay_way, pay_type
FROM third_party_payment_methods
WHERE app_code = ? AND provider_code = ?`,
appCode, providerCode,
)
if err != nil {
return nil, err
}
defer rows.Close()
keys := map[string]struct{}{}
for rows.Next() {
var countryCode string
var payWay string
var payType string
if err := rows.Scan(&countryCode, &payWay, &payType); err != nil {
return nil, err
}
keys[thirdPartyPaymentMethodKey(countryCode, payWay, payType)] = struct{}{}
}
return keys, rows.Err()
}
func (r *Repository) GetThirdPartyPaymentMethod(ctx context.Context, appCode string, methodID int64) (ledger.ThirdPartyPaymentMethod, error) {
ctx = contextWithCommandApp(ctx, appCode)
row := r.db.QueryRowContext(ctx, thirdPartyPaymentMethodSelectSQL()+`
@ -232,3 +333,58 @@ func decimalRateIsPositive(rate string) bool {
value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64)
return err == nil && value > 0
}
func normalizeThirdPartyPaymentMethodForUpsert(appCode string, providerCode string, method ledger.ThirdPartyPaymentMethod, nowMS int64) ledger.ThirdPartyPaymentMethod {
method.AppCode = appcode.Normalize(appCode)
method.ProviderCode = providerCode
method.CountryCode = strings.ToUpper(strings.TrimSpace(method.CountryCode))
method.CountryName = strings.TrimSpace(method.CountryName)
method.CurrencyCode = strings.ToUpper(strings.TrimSpace(method.CurrencyCode))
method.PayWay = strings.TrimSpace(method.PayWay)
method.PayType = strings.ToUpper(strings.TrimSpace(method.PayType))
method.MethodName = strings.TrimSpace(method.MethodName)
method.LogoURL = strings.TrimSpace(method.LogoURL)
method.USDToCurrencyRate = strings.TrimSpace(method.USDToCurrencyRate)
if method.USDToCurrencyRate == "" || !validNonNegativeDecimalForRepository(method.USDToCurrencyRate) {
method.USDToCurrencyRate = "0"
}
if method.Status == "" {
method.Status = ledger.ThirdPartyPaymentStatusActive
}
method.CreatedAtMS = nowMS
method.UpdatedAtMS = nowMS
return method
}
func thirdPartyPaymentMethodKey(countryCode string, payWay string, payType string) string {
return strings.ToUpper(strings.TrimSpace(countryCode)) + "|" +
strings.ToLower(strings.TrimSpace(payWay)) + "|" +
strings.ToUpper(strings.TrimSpace(payType))
}
func thirdPartyPaymentProviderName(providerCode string) string {
switch providerCode {
case ledger.PaymentProviderV5Pay:
return "V5Pay"
case ledger.PaymentProviderMifaPay:
return "MiFaPay"
default:
return providerCode
}
}
func thirdPartyPaymentProviderSortOrder(providerCode string) int {
switch providerCode {
case ledger.PaymentProviderMifaPay:
return 10
case ledger.PaymentProviderV5Pay:
return 20
default:
return 100
}
}
func validNonNegativeDecimalForRepository(rate string) bool {
value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64)
return err == nil && value >= 0
}

View File

@ -58,6 +58,8 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger
HostPeriodCycleKey: metadata.HostPeriodCycleKey,
EntitlementID: strings.TrimSpace(metadata.EntitlementID),
ChargeSource: normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID),
GiftIncomeCoinAmount: metadata.GiftIncomeCoinAmount,
GiftIncomeBalanceAfter: metadata.GiftIncomeBalanceAfter,
}
}

View File

@ -58,6 +58,32 @@ func (s *Server) UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.
return &walletv1.ThirdPartyPaymentMethodResponse{Method: thirdPartyPaymentMethodToProto(method)}, nil
}
// SyncThirdPartyPaymentMethods 从支付商同步当前商户实际开通的支付方式;写库仍由 wallet-service 统一负责。
func (s *Server) SyncThirdPartyPaymentMethods(ctx context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
result, err := s.svc.SyncThirdPartyPaymentMethods(ctx, ledger.ThirdPartyPaymentMethodSyncCommand{
AppCode: req.GetAppCode(),
ProviderCode: req.GetProviderCode(),
OperatorUserID: req.GetOperatorUserId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &walletv1.SyncThirdPartyPaymentMethodsResponse{
ProviderCode: result.ProviderCode,
ScannedCountryCount: int32(result.ScannedCountryCount),
FetchedCountryCount: int32(result.FetchedCountryCount),
CreatedCount: int32(result.CreatedCount),
UpdatedCount: int32(result.UpdatedCount),
SkippedCount: int32(result.SkippedCount),
Issues: make([]*walletv1.ThirdPartyPaymentMethodSyncIssue, 0, len(result.Issues)),
}
for _, issue := range result.Issues {
resp.Issues = append(resp.Issues, thirdPartyPaymentMethodSyncIssueToProto(issue))
}
return resp, nil
}
// ListH5RechargeOptions 返回 H5 当前账号的商品和支付方式,普通用户和币商由 audience_type 分流。
func (s *Server) ListH5RechargeOptions(ctx context.Context, req *walletv1.H5RechargeOptionsRequest) (*walletv1.H5RechargeOptionsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
@ -243,6 +269,15 @@ func thirdPartyPaymentChannelToProto(channel ledger.ThirdPartyPaymentChannel) *w
return resp
}
func thirdPartyPaymentMethodSyncIssueToProto(issue ledger.ThirdPartyPaymentMethodSyncIssue) *walletv1.ThirdPartyPaymentMethodSyncIssue {
return &walletv1.ThirdPartyPaymentMethodSyncIssue{
CountryCode: issue.CountryCode,
CurrencyCode: issue.CurrencyCode,
Code: issue.Code,
Message: issue.Message,
}
}
func thirdPartyPaymentMethodToProto(method ledger.ThirdPartyPaymentMethod) *walletv1.ThirdPartyPaymentMethod {
return &walletv1.ThirdPartyPaymentMethod{
MethodId: method.MethodID,

View File

@ -37,6 +37,33 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest)
return debitGiftResponseFromReceipt(receipt), nil
}
// DebitDirectGift 处理私聊送礼账务命令;私聊没有房间状态,区域和主播身份必须由 gateway 注入。
func (s *Server) DebitDirectGift(ctx context.Context, req *walletv1.DebitDirectGiftRequest) (*walletv1.DebitGiftResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.DebitDirectGift(ctx, ledger.DebitGiftCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
SenderUserID: req.GetSenderUserId(),
TargetUserID: req.GetTargetUserId(),
GiftID: req.GetGiftId(),
GiftCount: req.GetGiftCount(),
PriceVersion: req.GetPriceVersion(),
RegionID: req.GetRegionId(),
SenderRegionID: req.GetSenderRegionId(),
TargetIsHost: req.GetTargetIsHost(),
TargetHostRegionID: req.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
EntitlementID: req.GetEntitlementId(),
ChargeSource: req.GetChargeSource(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return debitGiftResponseFromReceipt(receipt), nil
}
// BatchDebitGift 只负责批量目标 protobuf 转换;同事务扣费和幂等由 wallet service/repository 保证。
func (s *Server) BatchDebitGift(ctx context.Context, req *walletv1.BatchDebitGiftRequest) (*walletv1.BatchDebitGiftResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
@ -123,5 +150,7 @@ func debitGiftResponseFromReceipt(receipt ledger.Receipt) *walletv1.DebitGiftRes
HostPeriodCycleKey: receipt.HostPeriodCycleKey,
EntitlementId: receipt.EntitlementID,
ChargeSource: receipt.ChargeSource,
GiftIncomeCoinAmount: receipt.GiftIncomeCoinAmount,
GiftIncomeBalanceAfter: receipt.GiftIncomeBalanceAfter,
}
}