用户锁国家

This commit is contained in:
zhx 2026-07-21 12:51:46 +08:00
parent d67213fca6
commit a844c7b738
10 changed files with 49 additions and 4 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 |

View File

@ -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"),

View File

@ -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 表示区域主数据不存在。

View File

@ -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,

View File

@ -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) {

View File

@ -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)

View File

@ -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)
}