diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index 81016524..9aebb55f 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -3251,6 +3251,7 @@ definitions: - DISPLAY_USER_ID_EXISTS - DISPLAY_USER_ID_COOLDOWN - COUNTRY_CHANGE_COOLDOWN + - COUNTRY_CHANGE_IDENTITY_LOCKED - DISPLAY_USER_ID_NOT_FOUND - DISPLAY_USER_ID_PRETTY_NOT_AVAILABLE - DISPLAY_USER_ID_PRETTY_ACTIVE diff --git a/docs/openapi/user.swagger.yaml b/docs/openapi/user.swagger.yaml index a79b00ac..ddb6d63f 100644 --- a/docs/openapi/user.swagger.yaml +++ b/docs/openapi/user.swagger.yaml @@ -1682,6 +1682,7 @@ definitions: - DISPLAY_USER_ID_EXISTS - DISPLAY_USER_ID_COOLDOWN - COUNTRY_CHANGE_COOLDOWN + - COUNTRY_CHANGE_IDENTITY_LOCKED - DISPLAY_USER_ID_NOT_FOUND - DISPLAY_USER_ID_ALLOCATE_FAILED - DISPLAY_USER_ID_PRETTY_NOT_AVAILABLE diff --git a/docs/服务基础技术设计.md b/docs/服务基础技术设计.md index 2f6122d1..c7996899 100644 --- a/docs/服务基础技术设计.md +++ b/docs/服务基础技术设计.md @@ -161,6 +161,7 @@ User 专用 reason: | --- | --- | --- | | `INVALID_INVITE_CODE` | `InvalidArgument` | `400` | | `COUNTRY_CHANGE_COOLDOWN` | `FailedPrecondition` | `409` | +| `COUNTRY_CHANGE_IDENTITY_LOCKED` | `PermissionDenied` | `403` | | `USER_PROFILE_REQUIRED` | `FailedPrecondition` | `403` | Wallet 专用 reason: diff --git a/docs/登录注册技术设计.md b/docs/登录注册技术设计.md index a4a881dd..e1d38f15 100644 --- a/docs/登录注册技术设计.md +++ b/docs/登录注册技术设计.md @@ -1007,6 +1007,7 @@ gateway HTTP envelope 的 `code` 不使用数字。 | `PROFILE_REQUIRED` | 403 | authenticated user has not completed required registration profile | | `PASSWORD_ALREADY_SET` | 409 | user already has password identity | | `COUNTRY_CHANGE_COOLDOWN` | 409 | country change is within the 30-day cooldown window | +| `COUNTRY_CHANGE_IDENTITY_LOCKED` | 403 | an existing host or business identity requires customer-service country changes | | `USER_DISABLED` | 403 | user is disabled or banned | | `SESSION_EXPIRED` | 401 | refresh token expired | | `SESSION_REVOKED` | 401 | refresh token revoked | diff --git a/pkg/xerr/catalog.go b/pkg/xerr/catalog.go index 81553e84..2ca7af6c 100644 --- a/pkg/xerr/catalog.go +++ b/pkg/xerr/catalog.go @@ -70,6 +70,12 @@ var catalog = map[Code]Spec{ DisplayUserIDPaymentRequired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPaymentRequired, "pretty display user id payment required"), CountryChangeCooldown: spec(codes.FailedPrecondition, httpStatusConflict, CountryChangeCooldown, "country change is cooling down"), + CountryChangeIdentityLocked: spec( + codes.PermissionDenied, + httpStatusForbidden, + CountryChangeIdentityLocked, + "You already have a host identity. Please contact customer service to change your country.", + ), CountryNotFound: spec(codes.NotFound, httpStatusNotFound, CountryNotFound, "not found"), RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"), RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "country already belongs to another region"), diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index 55a09219..c70576fe 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -66,6 +66,8 @@ const ( DisplayUserIDPaymentRequired Code = "DISPLAY_USER_ID_PAYMENT_REQUIRED" // CountryChangeCooldown 表示用户仍处在国家修改冷却期。 CountryChangeCooldown Code = "COUNTRY_CHANGE_COOLDOWN" + // CountryChangeIdentityLocked 表示用户已有经营身份,国家只能由客服治理入口修改。 + CountryChangeIdentityLocked Code = "COUNTRY_CHANGE_IDENTITY_LOCKED" // CountryNotFound 表示国家主数据不存在或已经停用。 CountryNotFound Code = "COUNTRY_NOT_FOUND" // RegionNotFound 表示区域主数据不存在。 diff --git a/pkg/xerr/grpc_test.go b/pkg/xerr/grpc_test.go index 68495b20..2c178f1e 100644 --- a/pkg/xerr/grpc_test.go +++ b/pkg/xerr/grpc_test.go @@ -159,6 +159,14 @@ func TestCatalogMappings(t *testing.T) { publicCode: string(CountryChangeCooldown), publicMessage: "country change is cooling down", }, + { + name: "country identity lock tells user how to proceed", + code: CountryChangeIdentityLocked, + grpcCode: codes.PermissionDenied, + httpStatus: httpStatusForbidden, + publicCode: string(CountryChangeIdentityLocked), + publicMessage: "You already have a host identity. Please contact customer service to change your country.", + }, { name: "registered device conflict exposes concrete login reason", code: DeviceAlreadyRegistered, diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index a2f940f2..9f01ae04 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -11105,6 +11105,27 @@ func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) { router.ServeHTTP(recorder, request) assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "country change is cooling down", "req-country-cooldown") + + profileClient = &fakeUserProfileClient{countryErr: xerr.ToGRPCError(xerr.New( + xerr.CountryChangeIdentityLocked, + "You already have a host identity. Please contact customer service to change your country.", + ))} + router = NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) + request = httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-country-identity-locked") + recorder = httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + assertEnvelopeMessage( + t, + recorder, + http.StatusForbidden, + string(xerr.CountryChangeIdentityLocked), + "You already have a host identity. Please contact customer service to change your country.", + "req-country-identity-locked", + ) } func TestChangeCountryRemovesOldRegionBroadcastMember(t *testing.T) { diff --git a/services/user-service/internal/service/user/profile.go b/services/user-service/internal/service/user/profile.go index 54f2f304..a1a32572 100644 --- a/services/user-service/internal/service/user/profile.go +++ b/services/user-service/internal/service/user/profile.go @@ -272,7 +272,11 @@ func (s *Service) ChangeUserCountry(ctx context.Context, userID int64, country s } if role := blockedCountryChangeRole(summary); role != "" { // App 自助入口不能改变已有经营身份的区域归属,否则房间、组织和币商读模型会在用户侧无审计地漂移。 - return userdomain.User{}, 0, xerr.New(xerr.PermissionDenied, "you have "+role+" id, not allow modify country") + // 使用独立公开 reason,避免 gateway 把普通 PERMISSION_DENIED 脱敏成语义不明的固定文案。 + return userdomain.User{}, 0, xerr.New( + xerr.CountryChangeIdentityLocked, + "You already have a host identity. Please contact customer service to change your country.", + ) } } resolvedCountry, regionID, err := s.resolveCountryRegionForChange(ctx, country) diff --git a/services/user-service/internal/service/user/profile_test.go b/services/user-service/internal/service/user/profile_test.go index 1c966085..03c1b200 100644 --- a/services/user-service/internal/service/user/profile_test.go +++ b/services/user-service/internal/service/user/profile_test.go @@ -114,10 +114,10 @@ func TestChangeUserCountryRejectsProtectedRoles(t *testing.T) { ) _, _, err := svc.ChangeUserCountry(appcode.WithContext(context.Background(), "lalu"), 10001, "ID", "req-protected") - if xerr.CodeOf(err) != xerr.PermissionDenied { - t.Fatalf("protected role must be permission denied: role=%s err=%v", tc.role, err) + if xerr.CodeOf(err) != xerr.CountryChangeIdentityLocked { + t.Fatalf("protected role must return identity lock: role=%s err=%v", tc.role, err) } - want := "you have " + tc.role + " id, not allow modify country" + want := "You already have a host identity. Please contact customer service to change your country." if !strings.Contains(err.Error(), want) { t.Fatalf("protected role message mismatch: got=%q want contains=%q", err.Error(), want) }