强制用户输入联系方式

This commit is contained in:
zhx 2026-06-29 18:27:32 +08:00
parent db71bbfe09
commit 8df263195a
28 changed files with 4126 additions and 3617 deletions

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -86,6 +86,7 @@ message User {
string pretty_id = 29;
string pretty_display_user_id = 30;
string profile_bg_img = 31;
string contact_info = 32;
}
// InviteOverview read model
@ -733,6 +734,18 @@ message UpdateUserProfileBackgroundResponse {
User user = 1;
}
// UpdateUserContactInfoRequest
message UpdateUserContactInfoRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string contact_info = 3;
}
// UpdateUserContactInfoResponse
message UpdateUserContactInfoResponse {
User user = 1;
}
// ChangeUserCountryRequest App
message ChangeUserCountryRequest {
RequestMeta meta = 1;
@ -1272,6 +1285,7 @@ service UserService {
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
rpc UpdateUserProfileBackground(UpdateUserProfileBackgroundRequest) returns (UpdateUserProfileBackgroundResponse);
rpc UpdateUserContactInfo(UpdateUserContactInfoRequest) returns (UpdateUserContactInfoResponse);
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc AdminChangeUserCountry(AdminChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc SetUserStatus(SetUserStatusRequest) returns (SetUserStatusResponse);

View File

@ -28,6 +28,7 @@ const (
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_UpdateUserProfileBackground_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfileBackground"
UserService_UpdateUserContactInfo_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserContactInfo"
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_AdminChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/AdminChangeUserCountry"
UserService_SetUserStatus_FullMethodName = "/hyapp.user.v1.UserService/SetUserStatus"
@ -52,6 +53,7 @@ type UserServiceClient interface {
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(ctx context.Context, in *UpdateUserProfileBackgroundRequest, opts ...grpc.CallOption) (*UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(ctx context.Context, in *UpdateUserContactInfoRequest, opts ...grpc.CallOption) (*UpdateUserContactInfoResponse, error)
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(ctx context.Context, in *AdminChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
SetUserStatus(ctx context.Context, in *SetUserStatusRequest, opts ...grpc.CallOption) (*SetUserStatusResponse, error)
@ -159,6 +161,16 @@ func (c *userServiceClient) UpdateUserProfileBackground(ctx context.Context, in
return out, nil
}
func (c *userServiceClient) UpdateUserContactInfo(ctx context.Context, in *UpdateUserContactInfoRequest, opts ...grpc.CallOption) (*UpdateUserContactInfoResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateUserContactInfoResponse)
err := c.cc.Invoke(ctx, UserService_UpdateUserContactInfo_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ChangeUserCountryResponse)
@ -244,6 +256,7 @@ type UserServiceServer interface {
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(context.Context, *UpdateUserProfileBackgroundRequest) (*UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(context.Context, *UpdateUserContactInfoRequest) (*UpdateUserContactInfoResponse, error)
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
AdminChangeUserCountry(context.Context, *AdminChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
SetUserStatus(context.Context, *SetUserStatusRequest) (*SetUserStatusResponse, error)
@ -288,6 +301,9 @@ func (UnimplementedUserServiceServer) UpdateUserProfile(context.Context, *Update
func (UnimplementedUserServiceServer) UpdateUserProfileBackground(context.Context, *UpdateUserProfileBackgroundRequest) (*UpdateUserProfileBackgroundResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserProfileBackground not implemented")
}
func (UnimplementedUserServiceServer) UpdateUserContactInfo(context.Context, *UpdateUserContactInfoRequest) (*UpdateUserContactInfoResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserContactInfo not implemented")
}
func (UnimplementedUserServiceServer) ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ChangeUserCountry not implemented")
}
@ -492,6 +508,24 @@ func _UserService_UpdateUserProfileBackground_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _UserService_UpdateUserContactInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateUserContactInfoRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).UpdateUserContactInfo(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_UpdateUserContactInfo_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).UpdateUserContactInfo(ctx, req.(*UpdateUserContactInfoRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_ChangeUserCountry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ChangeUserCountryRequest)
if err := dec(in); err != nil {
@ -661,6 +695,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "UpdateUserProfileBackground",
Handler: _UserService_UpdateUserProfileBackground_Handler,
},
{
MethodName: "UpdateUserContactInfo",
Handler: _UserService_UpdateUserContactInfo_Handler,
},
{
MethodName: "ChangeUserCountry",
Handler: _UserService_ChangeUserCountry_Handler,

View File

@ -186,6 +186,7 @@ type User struct {
ISONumeric string `json:"isoNumeric"`
PhoneCountryCode string `json:"phoneCountryCode"`
CountryEnabled bool `json:"countryEnabled"`
ContactInfo string `json:"contactInfo"`
}
type GRPCClient struct {
@ -415,6 +416,7 @@ func fromProtoUser(user *userv1.User) *User {
ISONumeric: user.GetIsoNumeric(),
PhoneCountryCode: user.GetPhoneCountryCode(),
CountryEnabled: user.GetCountryEnabled(),
ContactInfo: user.GetContactInfo(),
}
}
@ -566,6 +568,7 @@ type BDProfile struct {
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Contact string `json:"contact"`
RegionName string `json:"regionName"`
ParentLeaderDisplayID string `json:"parentLeaderDisplayUserId"`
ParentLeaderUsername string `json:"parentLeaderUsername"`
@ -593,6 +596,7 @@ type Agency struct {
OwnerDisplayUserID string `json:"ownerDisplayUserId"`
OwnerUsername string `json:"ownerUsername"`
OwnerAvatar string `json:"ownerAvatar"`
Contact string `json:"contact"`
ParentBDDisplayUserID string `json:"parentBdDisplayUserId"`
ParentBDUsername string `json:"parentBdUsername"`
ParentBDAvatar string `json:"parentBdAvatar"`
@ -614,6 +618,7 @@ type HostProfile struct {
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Contact string `json:"contact"`
RegionName string `json:"regionName"`
CurrentAgencyName string `json:"currentAgencyName"`
Diamond int64 `json:"diamond"`

View File

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

View File

@ -42,6 +42,7 @@ type UserProfileClient interface {
CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error)
UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error)
UpdateUserProfileBackground(ctx context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(ctx context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error)
ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error)
AdminChangeUserCountry(ctx context.Context, req *userv1.AdminChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error)
CreateManagerUserBlock(ctx context.Context, req *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error)
@ -346,6 +347,10 @@ func (c *grpcUserProfileClient) UpdateUserProfileBackground(ctx context.Context,
return c.client.UpdateUserProfileBackground(ctx, req)
}
func (c *grpcUserProfileClient) UpdateUserContactInfo(ctx context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) {
return c.client.UpdateUserContactInfo(ctx, req)
}
func (c *grpcUserProfileClient) ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
return c.client.ChangeUserCountry(ctx, req)
}

View File

@ -101,6 +101,7 @@ type UserHandlers struct {
CompleteMyOnboarding http.HandlerFunc
UpdateMyProfile http.HandlerFunc
UpdateMyProfileBackground http.HandlerFunc
UpdateMyContactInfo http.HandlerFunc
ChangeMyCountry http.HandlerFunc
ChangeMyDisplayUserID http.HandlerFunc
ApplyMyPrettyDisplayUserID http.HandlerFunc
@ -447,6 +448,7 @@ func (r routes) registerUserRoutes() {
r.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
r.profile("/users/me/profile-bg-img", http.MethodPost, h.UpdateMyProfileBackground)
r.profile("/users/me/contact-info", http.MethodPost, h.UpdateMyContactInfo)
r.profile("/users/me/country/change", "", h.ChangeMyCountry)
r.auth("/users/me/display-id/change", "", h.ChangeMyDisplayUserID)
r.auth("/users/me/display-id/pretty/apply", "", h.ApplyMyPrettyDisplayUserID)

View File

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

View File

@ -82,6 +82,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile,
UpdateMyProfileBackground: h.updateMyProfileBackground,
UpdateMyContactInfo: h.updateMyContactInfo,
ChangeMyCountry: h.changeMyCountry,
ChangeMyDisplayUserID: h.changeMyDisplayUserID,
ApplyMyPrettyDisplayUserID: h.applyMyPrettyDisplayUserID,

View File

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

View File

@ -3,6 +3,7 @@ package walletapi
import (
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
@ -23,6 +24,8 @@ type coinSellerData struct {
RegionName string `json:"region_name,omitempty"`
Status string `json:"status"`
MerchantAssetType string `json:"merchant_asset_type"`
WhatsappPhone string `json:"whatsapp_phone,omitempty"`
WhatsappURL string `json:"whatsapp_url,omitempty"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
@ -54,6 +57,7 @@ func coinSellerFromProto(item *userv1.CoinSellerListItem) coinSellerData {
if item == nil {
return coinSellerData{}
}
contactInfo := strings.TrimSpace(item.GetContactInfo())
return coinSellerData{
UserID: int64IDString(item.GetUserId()),
DisplayUserID: item.GetDisplayUserId(),
@ -68,10 +72,36 @@ func coinSellerFromProto(item *userv1.CoinSellerListItem) coinSellerData {
RegionName: item.GetRegionName(),
Status: item.GetStatus(),
MerchantAssetType: item.GetMerchantAssetType(),
WhatsappPhone: contactInfo,
WhatsappURL: coinSellerWhatsAppURL(contactInfo),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func coinSellerWhatsAppURL(raw string) string {
contact := strings.TrimSpace(raw)
if contact == "" {
return ""
}
lower := strings.ToLower(contact)
for _, prefix := range []string{"https://wa.me/", "http://wa.me/", "whatsapp://", "https://api.whatsapp.com/", "http://api.whatsapp.com/"} {
if strings.HasPrefix(lower, prefix) {
return contact
}
}
var digits strings.Builder
for _, r := range contact {
if r >= '0' && r <= '9' {
digits.WriteRune(r)
}
}
if digits.Len() == 0 {
return ""
}
return "https://wa.me/" + digits.String()
}
func int64IDString(value int64) string {
if value <= 0 {
return ""

View File

@ -65,6 +65,7 @@ CREATE TABLE IF NOT EXISTS users (
os_version VARCHAR(64) NULL COMMENT 'os版本',
avatar VARCHAR(512) NULL COMMENT '头像',
profile_bg_img VARCHAR(512) NOT NULL DEFAULT '' COMMENT '个人主页信息卡背景图',
contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户联系信息',
birth_date DATE NULL COMMENT '出生日期',
app_version VARCHAR(64) NULL COMMENT '应用版本',
build_number VARCHAR(64) NULL COMMENT '构建编号',

View File

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

View File

@ -235,6 +235,7 @@ type CoinSellerListItem struct {
RegionName string
Status string
MerchantAssetType string
ContactInfo string
UpdatedAtMs int64
}

View File

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

View File

@ -258,6 +258,8 @@ type User struct {
Avatar string
// ProfileBgImg 是用户主页信息卡背景图片 URL属于用户主资料不属于钱包装扮或 profile_card 资源。
ProfileBgImg string
// ContactInfo 是用户级联系方式Host、BD、Agency owner 和币商等经营身份共同展示同一份用户主资料。
ContactInfo string
// BirthDate 使用 yyyy-mm-dd 字符串表达生日,避免时区把日期偏移成前后一天。
BirthDate string
// RegisterAppVersion 是注册客户端版本。
@ -642,6 +644,18 @@ type ProfileBackgroundUpdateCommand struct {
UpdatedAtMs int64
}
// ProfileContactUpdateCommand 描述用户级联系方式的专用修改。
type ProfileContactUpdateCommand struct {
// AppCode 是资料修改所属 App。
AppCode string
// UserID 是要修改联系方式的用户,只能来自当前登录态或后台已解析出的目标用户。
UserID int64
// ContactInfo 是用于后台和经营中心展示的联系方式;空字符串表示清空。
ContactInfo string
// UpdatedAtMs 是本次修改时间。
UpdatedAtMs int64
}
// CompleteOnboardingCommand 描述注册页一次性补齐资料的事务输入。
type CompleteOnboardingCommand struct {
// AppCode 是注册资料固化所属 App。

View File

@ -229,6 +229,11 @@ func (r *fakeModerationRepository) UpdateUserProfileBackground(_ context.Context
return r.user, nil
}
func (r *fakeModerationRepository) UpdateUserContactInfo(_ context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error) {
r.user.ContactInfo = command.ContactInfo
return r.user, nil
}
func (r *fakeModerationRepository) CompleteOnboarding(context.Context, userdomain.CompleteOnboardingCommand) (userdomain.User, error) {
return r.user, nil
}

View File

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

View File

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

View File

@ -55,6 +55,8 @@ type UserRepository interface {
UpdateUserProfile(ctx context.Context, command userdomain.ProfileUpdateCommand) (userdomain.User, error)
// UpdateUserProfileBackground 原子修改个人信息页背景图。
UpdateUserProfileBackground(ctx context.Context, command userdomain.ProfileBackgroundUpdateCommand) (userdomain.User, error)
// UpdateUserContactInfo 原子修改用户级联系方式。
UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error)
// CompleteOnboarding 原子写注册页必填资料并标记 profile_completed。
CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error)
// ChangeUserCountry 原子修改用户国家、写变更日志,并返回需要立即失效的 auth session。

View File

@ -183,6 +183,7 @@ func (r *Repository) ListActiveCoinSellersInMyRegion(ctx context.Context, userID
COALESCE(region.name, ''),
seller.status,
seller.merchant_asset_type,
COALESCE(NULLIF(u.contact_info, ''), seller.contact_info, ''),
seller.updated_at_ms
FROM users u
INNER JOIN coin_seller_profiles seller
@ -220,6 +221,7 @@ func (r *Repository) ListActiveCoinSellersInMyRegion(ctx context.Context, userID
&item.RegionName,
&item.Status,
&item.MerchantAssetType,
&item.ContactInfo,
&item.UpdatedAtMs,
); err != nil {
return nil, err

View File

@ -106,6 +106,7 @@ const userSelectColumns = `
COALESCE(os_version, ''),
COALESCE(avatar, ''),
COALESCE(profile_bg_img, ''),
COALESCE(contact_info, ''),
COALESCE(DATE_FORMAT(birth_date, '%Y-%m-%d'), ''),
COALESCE(app_version, ''),
COALESCE(build_number, ''),
@ -173,6 +174,7 @@ func ScanUser(scanner interface {
&user.RegisterOSVersion,
&user.Avatar,
&user.ProfileBgImg,
&user.ContactInfo,
&user.BirthDate,
&user.RegisterAppVersion,
&user.RegisterBuildNumber,
@ -246,6 +248,7 @@ func InsertUserIdentity(ctx context.Context, tx *sql.Tx, user userdomain.User, i
os_version,
avatar,
profile_bg_img,
contact_info,
birth_date,
app_version,
build_number,
@ -264,10 +267,10 @@ func InsertUserIdentity(ctx context.Context, tx *sql.Tx, user userdomain.User, i
)
VALUES (
?, ?, ?, ?, ?, NULL,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?
)
`, appcode.Normalize(user.AppCode), user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), shared.NullableString(user.Username), shared.NullableString(user.Gender), shared.NullableString(user.Country), shared.NullableRegionID(user.RegionID), shared.NullableString(user.InviteCode), shared.NullableString(user.RegisterIP), shared.NullableString(user.RegisterUserAgent), shared.NullableString(user.CountryByIP), shared.NullableString(user.RegisterDeviceID), shared.NullableString(user.RegisterDevice), shared.NullableString(user.RegisterOSVersion), shared.NullableString(user.Avatar), profileBgImg, shared.NullableString(user.BirthDate), shared.NullableString(user.RegisterAppVersion), shared.NullableString(user.RegisterBuildNumber), shared.NullableString(user.RegisterSource), shared.NullableString(user.RegisterInstallChannel), shared.NullableString(user.RegisterCampaign), shared.NullableString(user.RegisterPlatform), shared.NullableString(user.Language), shared.NullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, onboardingStatusForInsert(user), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
`, appcode.Normalize(user.AppCode), user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), shared.NullableString(user.Username), shared.NullableString(user.Gender), shared.NullableString(user.Country), shared.NullableRegionID(user.RegionID), shared.NullableString(user.InviteCode), shared.NullableString(user.RegisterIP), shared.NullableString(user.RegisterUserAgent), shared.NullableString(user.CountryByIP), shared.NullableString(user.RegisterDeviceID), shared.NullableString(user.RegisterDevice), shared.NullableString(user.RegisterOSVersion), shared.NullableString(user.Avatar), profileBgImg, strings.TrimSpace(user.ContactInfo), shared.NullableString(user.BirthDate), shared.NullableString(user.RegisterAppVersion), shared.NullableString(user.RegisterBuildNumber), shared.NullableString(user.RegisterSource), shared.NullableString(user.RegisterInstallChannel), shared.NullableString(user.RegisterCampaign), shared.NullableString(user.RegisterPlatform), shared.NullableString(user.Language), shared.NullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, onboardingStatusForInsert(user), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
if err != nil {
return err
}

View File

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

View File

@ -149,6 +149,11 @@ func (r *Repository) UpdateUserProfileBackground(ctx context.Context, command us
return r.Repository.UserRepository().UpdateUserProfileBackground(ctx, command)
}
// UpdateUserContactInfo 让测试 wrapper 继续满足业务层用户级联系方式更新接口。
func (r *Repository) UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error) {
return r.Repository.UserRepository().UpdateUserContactInfo(ctx, command)
}
// CompleteOnboarding 让测试 wrapper 继续满足业务层注册资料接口。
func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error) {
return r.Repository.UserRepository().CompleteOnboarding(ctx, command)
@ -463,11 +468,11 @@ func (r *Repository) PutUser(user userdomain.User) {
app_code, user_id, default_display_user_id, current_display_user_id, current_display_user_id_kind,
current_display_user_id_expires_at_ms, username, gender, country, region_id, invite_code,
register_ip, register_user_agent, country_by_ip, register_device_id, register_device,
os_version, avatar, profile_bg_img, birth_date, app_version, build_number, source, install_channel,
os_version, avatar, profile_bg_img, contact_info, birth_date, app_version, build_number, source, install_channel,
campaign, platform, language, timezone, profile_completed, profile_completed_at_ms,
onboarding_status, status, created_at_ms, updated_at_ms
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
app_code = VALUES(app_code),
default_display_user_id = VALUES(default_display_user_id),
@ -487,6 +492,7 @@ func (r *Repository) PutUser(user userdomain.User) {
os_version = VALUES(os_version),
avatar = VALUES(avatar),
profile_bg_img = VALUES(profile_bg_img),
contact_info = VALUES(contact_info),
birth_date = VALUES(birth_date),
app_version = VALUES(app_version),
build_number = VALUES(build_number),
@ -501,7 +507,7 @@ func (r *Repository) PutUser(user userdomain.User) {
onboarding_status = VALUES(onboarding_status),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)
`, user.AppCode, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableInt64(user.CurrentDisplayUserIDExpiresAtMs), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableInt64(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.ProfileBgImg), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, string(user.OnboardingStatus), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
`, user.AppCode, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableInt64(user.CurrentDisplayUserIDExpiresAtMs), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableInt64(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.ProfileBgImg), strings.TrimSpace(user.ContactInfo), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, string(user.OnboardingStatus), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
if err != nil {
r.t.Fatalf("seed user %d failed: %v", user.UserID, err)
}

View File

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

View File

@ -672,6 +672,18 @@ func (s *Server) UpdateUserProfileBackground(ctx context.Context, req *userv1.Up
return &userv1.UpdateUserProfileBackgroundResponse{User: toProtoUser(user)}, nil
}
// UpdateUserContactInfo 修改当前用户级联系方式。
func (s *Server) UpdateUserContactInfo(ctx context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
// 联系方式是用户主资料字段,不落到具体经营身份表,避免同一用户多身份时展示不一致。
user, err := s.userSvc.UpdateUserContactInfo(ctx, req.GetUserId(), req.GetContactInfo())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.UpdateUserContactInfoResponse{User: toProtoUser(user)}, nil
}
// ChangeUserCountry 是 App 自助改国家入口;受限经营身份会被 service 层按当前角色摘要拦截。
func (s *Server) ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())