后台解除封禁以及经理修改用户国家
This commit is contained in:
parent
36b01aebb7
commit
b5f85ec5c5
File diff suppressed because it is too large
Load Diff
@ -933,9 +933,10 @@ message AdminBanUserResponse {
|
|||||||
SetUserStatusResponse status = 2;
|
SetUserStatusResponse status = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUnbanUserRequest 释放一条管理员封禁;ban_id 留空时按目标用户释放最新一条,其他管理员或经理封禁仍会阻止账号恢复。
|
// AdminUnbanUserRequest 由后台管理员强制释放目标用户全部管理员、经理和 legacy 封禁来源。
|
||||||
message AdminUnbanUserRequest {
|
message AdminUnbanUserRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
|
// ban_id 已废弃;保留字段号用于滚动发布兼容,强制解封始终按 target_user_id 处理全部有效事实。
|
||||||
string ban_id = 2;
|
string ban_id = 2;
|
||||||
int64 target_user_id = 3;
|
int64 target_user_id = 3;
|
||||||
int64 operator_admin_id = 4;
|
int64 operator_admin_id = 4;
|
||||||
@ -943,8 +944,12 @@ message AdminUnbanUserRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message AdminUnbanUserResponse {
|
message AdminUnbanUserResponse {
|
||||||
|
// ban 仅为旧客户端保留;强制解封可能只释放经理或 legacy 来源,因此新调用方不应依赖该字段。
|
||||||
AdminUserBan ban = 1;
|
AdminUserBan ban = 1;
|
||||||
SetUserStatusResponse status = 2;
|
SetUserStatusResponse status = 2;
|
||||||
|
int64 released_admin_ban_count = 3;
|
||||||
|
int64 released_manager_block_count = 4;
|
||||||
|
bool status_restored = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ManagerUserBlock {
|
message ManagerUserBlock {
|
||||||
|
|||||||
@ -1078,16 +1078,6 @@ func (s *Service) AdminUnbanUser(ctx context.Context, userID int64, operatorAdmi
|
|||||||
if requestID == "" {
|
if requestID == "" {
|
||||||
return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
|
return SetUserStatusResult{}, fmt.Errorf("%w: request_id 不能为空", ErrInvalidArgument)
|
||||||
}
|
}
|
||||||
current, err := s.GetUser(ctx, userID)
|
|
||||||
if err != nil {
|
|
||||||
return SetUserStatusResult{}, err
|
|
||||||
}
|
|
||||||
// 历史 banned/disabled 用户没有独立 ban_id;这类永久兼容记录只能沿用旧状态接口手动恢复。
|
|
||||||
if strings.TrimSpace(current.Ban.ID) == "" {
|
|
||||||
if current.Ban.Active && current.Ban.Source == "legacy" {
|
|
||||||
return s.SetUserStatus(ctx, userID, "active", operatorAdminID, requestID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reason := strings.TrimSpace(req.Reason)
|
reason := strings.TrimSpace(req.Reason)
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = "admin_app_user_unban"
|
reason = "admin_app_user_unban"
|
||||||
@ -1100,8 +1090,7 @@ func (s *Service) AdminUnbanUser(ctx context.Context, userID int64, operatorAdmi
|
|||||||
SentAtMs: nowMs,
|
SentAtMs: nowMs,
|
||||||
AppCode: appctx.FromContext(ctx),
|
AppCode: appctx.FromContext(ctx),
|
||||||
},
|
},
|
||||||
// 摘要可能选中一条更晚到期的 manager 封禁;留空由 user-service 选择最新 admin 事实。
|
// user-service 以 target_user_id 为边界原子释放全部 admin/manager 事实;不再由聚合摘要猜测具体来源。
|
||||||
BanId: "",
|
|
||||||
TargetUserId: userID,
|
TargetUserId: userID,
|
||||||
OperatorAdminId: operatorAdminID,
|
OperatorAdminId: operatorAdminID,
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
|
|||||||
@ -903,7 +903,12 @@ func TestTransferManagerUserCountryUsesCrossRegionManagerScope(t *testing.T) {
|
|||||||
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "MX", RegionId: 1001},
|
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "MX", RegionId: 1001},
|
||||||
}}
|
}}
|
||||||
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_region": true}}
|
hostClient := &fakeUserHostClient{capabilityAllowed: map[string]bool{"manager_cross_region": true}}
|
||||||
router := newManagerCenterTestRouter(&fakeWalletClient{}, hostClient, profileClient)
|
broadcastClient := &fakeBroadcastClient{}
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||||
|
handler.SetWalletClient(&fakeWalletClient{})
|
||||||
|
handler.SetUserHostClient(hostClient)
|
||||||
|
handler.SetBroadcastClient(broadcastClient)
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
body := []byte(`{"command_id":"mgr-country-1","target_user_id":"900001","country":"CA"}`)
|
body := []byte(`{"command_id":"mgr-country-1","target_user_id":"900001","country":"CA"}`)
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/users/country", bytes.NewReader(body))
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/users/country", bytes.NewReader(body))
|
||||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
@ -922,6 +927,9 @@ func TestTransferManagerUserCountryUsesCrossRegionManagerScope(t *testing.T) {
|
|||||||
if !hostClient.sawCapability("manager_transfer_user_country") {
|
if !hostClient.sawCapability("manager_transfer_user_country") {
|
||||||
t.Fatalf("country transfer must check manager_transfer_user_country, saw %#v", hostClient.capabilities)
|
t.Fatalf("country transfer must check manager_transfer_user_country, saw %#v", hostClient.capabilities)
|
||||||
}
|
}
|
||||||
|
if req := broadcastClient.lastRemove; req == nil || req.GetUserId() != 900001 || req.GetRegionId() != 1001 || req.GetReason() != "manager_user_country_changed" || req.GetMeta().GetAppCode() != "lalu" {
|
||||||
|
t.Fatalf("manager country transfer must remove the old region broadcast member: %+v", req)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func crossRegionManagerProfileClient() *fakeUserProfileClient {
|
func crossRegionManagerProfileClient() *fakeUserProfileClient {
|
||||||
|
|||||||
@ -13,6 +13,8 @@ type Handler struct {
|
|||||||
userHostAdminClient client.UserHostAdminClient
|
userHostAdminClient client.UserHostAdminClient
|
||||||
userProfileClient client.UserProfileClient
|
userProfileClient client.UserProfileClient
|
||||||
growthLevelClient client.GrowthLevelClient
|
growthLevelClient client.GrowthLevelClient
|
||||||
|
// broadcastClient 只负责国家跨区域后的旧群清理;国家和房间迁移事实仍由 user-service outbox 驱动。
|
||||||
|
broadcastClient client.BroadcastClient
|
||||||
// dashboardCache 缓存经理团队工资统计,避免每次进入经理中心都做组织树 + 周期账户双聚合。
|
// dashboardCache 缓存经理团队工资统计,避免每次进入经理中心都做组织树 + 周期账户双聚合。
|
||||||
dashboardCache managerDashboardCache
|
dashboardCache managerDashboardCache
|
||||||
}
|
}
|
||||||
@ -23,6 +25,7 @@ type Config struct {
|
|||||||
UserHostAdminClient client.UserHostAdminClient
|
UserHostAdminClient client.UserHostAdminClient
|
||||||
UserProfileClient client.UserProfileClient
|
UserProfileClient client.UserProfileClient
|
||||||
GrowthLevelClient client.GrowthLevelClient
|
GrowthLevelClient client.GrowthLevelClient
|
||||||
|
BroadcastClient client.BroadcastClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(config Config) *Handler {
|
func New(config Config) *Handler {
|
||||||
@ -32,6 +35,7 @@ func New(config Config) *Handler {
|
|||||||
userHostAdminClient: config.UserHostAdminClient,
|
userHostAdminClient: config.UserHostAdminClient,
|
||||||
userProfileClient: config.UserProfileClient,
|
userProfileClient: config.UserProfileClient,
|
||||||
growthLevelClient: config.GrowthLevelClient,
|
growthLevelClient: config.GrowthLevelClient,
|
||||||
|
broadcastClient: config.BroadcastClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -552,6 +552,27 @@ func (h *Handler) grantManagerVIP(writer http.ResponseWriter, request *http.Requ
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) removeManagerOldRegionBroadcastMemberBestEffort(request *http.Request, userID int64, oldRegionID int64, newRegionID int64) {
|
||||||
|
if h == nil || h.broadcastClient == nil || userID <= 0 || oldRegionID <= 0 || oldRegionID == newRegionID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := h.broadcastClient.RemoveRegionBroadcastMember(request.Context(), &activityv1.RemoveRegionBroadcastMemberRequest{
|
||||||
|
Meta: httpkit.ActivityMeta(request),
|
||||||
|
UserId: userID,
|
||||||
|
RegionId: oldRegionID,
|
||||||
|
Reason: "manager_user_country_changed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// 国家和房间迁移事实已经提交,腾讯 IM 旧群清理失败不能把已成功的国家修改伪装成失败。
|
||||||
|
logx.Warn(request.Context(), "manager_im_region_group_member_remove_failed",
|
||||||
|
slog.Int64("user_id", userID),
|
||||||
|
slog.Int64("old_region_id", oldRegionID),
|
||||||
|
slog.Int64("new_region_id", newRegionID),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) managerBlocks(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) managerBlocks(writer http.ResponseWriter, request *http.Request) {
|
||||||
switch request.Method {
|
switch request.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
@ -853,6 +874,8 @@ func (h *Handler) transferManagerUserCountry(writer http.ResponseWriter, request
|
|||||||
httpkit.WriteRPCError(writer, request, err)
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 经理入口和 App/后台入口必须保持国家变更副作用一致;房间迁移由 user_outbox 保证,旧区域 IM 群清理在提交后尽力执行。
|
||||||
|
h.removeManagerOldRegionBroadcastMemberBestEffort(request, targetUserID, resp.GetOldRegionId(), resp.GetNewRegionId())
|
||||||
httpkit.WriteOK(writer, request, map[string]any{
|
httpkit.WriteOK(writer, request, map[string]any{
|
||||||
"user": managerUserFromProto(resp.GetUser(), nil),
|
"user": managerUserFromProto(resp.GetUser(), nil),
|
||||||
"old_region_id": resp.GetOldRegionId(),
|
"old_region_id": resp.GetOldRegionId(),
|
||||||
|
|||||||
@ -121,6 +121,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
UserHostAdminClient: h.userHostAdminClient,
|
UserHostAdminClient: h.userHostAdminClient,
|
||||||
UserProfileClient: h.userProfileClient,
|
UserProfileClient: h.userProfileClient,
|
||||||
GrowthLevelClient: h.growthLevelClient,
|
GrowthLevelClient: h.growthLevelClient,
|
||||||
|
BroadcastClient: h.broadcastClient,
|
||||||
})
|
})
|
||||||
appAPI := appapi.New(appapi.Config{
|
appAPI := appapi.New(appapi.Config{
|
||||||
AppConfigReader: h.appConfigReader,
|
AppConfigReader: h.appConfigReader,
|
||||||
|
|||||||
@ -97,4 +97,41 @@ func TestHandleUserRegionChangedUpdatesOwnerRoomRegionIdempotently(t *testing.T)
|
|||||||
if len(oldRegionEntries) != 0 {
|
if len(oldRegionEntries) != 0 {
|
||||||
t.Fatalf("old region list must not contain migrated room: %+v", oldRegionEntries)
|
t.Fatalf("old region list must not contain migrated room: %+v", oldRegionEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同一区域内再次换国家时 visible_region_id 不变,但国家 tab 仍必须从旧国家迁到新国家。
|
||||||
|
sameRegionPayload, err := json.Marshal(map[string]any{
|
||||||
|
"user_id": ownerID,
|
||||||
|
"old_country": "BD",
|
||||||
|
"new_country": "PK",
|
||||||
|
"old_region_id": 2,
|
||||||
|
"new_region_id": 2,
|
||||||
|
"source": "app",
|
||||||
|
"request_id": "req-user-country-sync-same-region",
|
||||||
|
"changed_at_ms": int64(1_700_000_790_000),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal same-region payload failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.HandleUserOutboxMessage(ctx, usermq.UserOutboxMessage{
|
||||||
|
AppCode: appcode.Default,
|
||||||
|
EventID: "UserRegionChanged:app:163212:BD:PK:2:2:1700000790000",
|
||||||
|
EventType: "UserRegionChanged",
|
||||||
|
AggregateType: "user",
|
||||||
|
AggregateID: ownerID,
|
||||||
|
PayloadJSON: string(sameRegionPayload),
|
||||||
|
OccurredAtMS: 1_700_000_790_000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("same-region HandleUserOutboxMessage failed: %v", err)
|
||||||
|
}
|
||||||
|
oldCountryEntries, err := repository.ListRoomListEntries(ctx, roomservice.RoomListQuery{VisibleRegionID: 2, CountryCode: "BD", Tab: "new", Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list old country entries failed: %v", err)
|
||||||
|
}
|
||||||
|
newCountryEntries, err := repository.ListRoomListEntries(ctx, roomservice.RoomListQuery{VisibleRegionID: 2, CountryCode: "PK", Tab: "new", Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list same-region new country entries failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(oldCountryEntries) != 0 || len(newCountryEntries) != 1 || newCountryEntries[0].RoomID != roomID || newCountryEntries[0].OwnerCountryCode != "PK" {
|
||||||
|
t.Fatalf("same-region country projection mismatch: old=%+v new=%+v", oldCountryEntries, newCountryEntries)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -253,6 +253,24 @@ CREATE TABLE IF NOT EXISTS admin_user_ban_release_commands (
|
|||||||
KEY idx_admin_ban_release_resolved (app_code, resolved_ban_id)
|
KEY idx_admin_ban_release_resolved (app_code, resolved_ban_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台管理员解封命令幂等事实表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台管理员解封命令幂等事实表';
|
||||||
|
|
||||||
|
-- 后台强制解封会同时释放管理员和经理封禁;独立命令事实保证 gRPC 超时重试不会释放操作完成后新产生的封禁。
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_user_force_unban_commands (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '稳定强制解封命令 ID,取 request_id',
|
||||||
|
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
|
||||||
|
operator_admin_id BIGINT NOT NULL COMMENT '执行强制解封的后台管理员 ID',
|
||||||
|
reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '管理员解封原因快照,用于校验幂等载荷',
|
||||||
|
released_admin_ban_count BIGINT NOT NULL DEFAULT 0 COMMENT '本命令释放的管理员封禁数量',
|
||||||
|
released_manager_block_count BIGINT NOT NULL DEFAULT 0 COMMENT '本命令释放的经理封禁数量',
|
||||||
|
old_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '首次执行前用户主状态',
|
||||||
|
new_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '首次执行后用户主状态',
|
||||||
|
status_restored BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否把 banned/disabled 恢复为 active',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, command_id),
|
||||||
|
KEY idx_admin_force_unban_target (app_code, target_user_id, created_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台管理员强制解封幂等事实表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_reports (
|
CREATE TABLE IF NOT EXISTS user_reports (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
report_id VARCHAR(96) NOT NULL COMMENT '举报单业务 ID',
|
report_id VARCHAR(96) NOT NULL COMMENT '举报单业务 ID',
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE hyapp_user;
|
||||||
|
|
||||||
|
-- 后台强制解封会同时释放管理员和经理封禁;独立命令事实保证 gRPC 超时重试不会释放操作完成后新产生的封禁。
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_user_force_unban_commands (
|
||||||
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
|
command_id VARCHAR(128) NOT NULL COMMENT '稳定强制解封命令 ID,取 request_id',
|
||||||
|
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
|
||||||
|
operator_admin_id BIGINT NOT NULL COMMENT '执行强制解封的后台管理员 ID',
|
||||||
|
reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '管理员解封原因快照,用于校验幂等载荷',
|
||||||
|
released_admin_ban_count BIGINT NOT NULL DEFAULT 0 COMMENT '本命令释放的管理员封禁数量',
|
||||||
|
released_manager_block_count BIGINT NOT NULL DEFAULT 0 COMMENT '本命令释放的经理封禁数量',
|
||||||
|
old_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '首次执行前用户主状态',
|
||||||
|
new_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '首次执行后用户主状态',
|
||||||
|
status_restored BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否把 banned/disabled 恢复为 active',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (app_code, command_id),
|
||||||
|
KEY idx_admin_force_unban_target (app_code, target_user_id, created_at_ms)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台管理员强制解封幂等事实表';
|
||||||
@ -28,7 +28,8 @@ type AdminUserBanCommand struct {
|
|||||||
NowMs int64
|
NowMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseAdminUserBanCommand 只释放指定 ban_id,避免一个管理员操作覆盖其他管理员的独立封禁事实。
|
// ReleaseAdminUserBanCommand 只用于单条管理员封禁的自动到期和底层兼容释放。
|
||||||
|
// 后台人工解封必须走 AdminForceUnbanCommand,保证经理封禁和重叠管理员封禁在同一事务内一起释放。
|
||||||
type ReleaseAdminUserBanCommand struct {
|
type ReleaseAdminUserBanCommand struct {
|
||||||
BanID string
|
BanID string
|
||||||
TargetUserID int64
|
TargetUserID int64
|
||||||
@ -39,6 +40,25 @@ type ReleaseAdminUserBanCommand struct {
|
|||||||
NowMs int64
|
NowMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminForceUnbanCommand 是后台管理员覆盖全部用户治理封禁来源的命令。
|
||||||
|
// RequestID 是跨 gRPC 重试的幂等边界,避免一次超时重试误释放原操作完成后新产生的封禁。
|
||||||
|
type AdminForceUnbanCommand struct {
|
||||||
|
TargetUserID int64
|
||||||
|
OperatorAdminID int64
|
||||||
|
Reason string
|
||||||
|
RequestID string
|
||||||
|
NowMs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminForceUnbanResult 返回本次命令实际释放的事实数量和最终用户主状态。
|
||||||
|
// StatusRestored 只描述首次执行时是否发生 banned/disabled -> active,不因幂等重试重复写状态日志。
|
||||||
|
type AdminForceUnbanResult struct {
|
||||||
|
User userdomain.User
|
||||||
|
ReleasedAdminBanCount int64
|
||||||
|
ReleasedManagerBlockCount int64
|
||||||
|
StatusRestored bool
|
||||||
|
}
|
||||||
|
|
||||||
// AdminUserBan 是后台管理员封禁事实;TargetOldStatus 用于审计,不作为自动恢复的唯一判断依据。
|
// AdminUserBan 是后台管理员封禁事实;TargetOldStatus 用于审计,不作为自动恢复的唯一判断依据。
|
||||||
type AdminUserBan struct {
|
type AdminUserBan struct {
|
||||||
BanID string
|
BanID string
|
||||||
@ -110,33 +130,35 @@ func (s *Service) AdminBanUser(ctx context.Context, command AdminUserBanCommand)
|
|||||||
return persisted.Ban, s.statusResultAfterPersistence(ctx, statusCommand, persisted.Status, persisted.StatusChanged), nil
|
return persisted.Ban, s.statusResultAfterPersistence(ctx, statusCommand, persisted.Status, persisted.StatusChanged), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUnbanUser 手动释放一条管理员封禁。
|
// AdminUnbanUser 是后台人工强制解封入口。
|
||||||
// repository 在同一事务内重新检查其他管理员和经理封禁;没有满足恢复条件时仅释放事实,不改 users.status。
|
// repository 原子释放全部管理员/经理封禁,并覆盖没有独立事实表记录的 legacy banned/disabled 状态。
|
||||||
func (s *Service) AdminUnbanUser(ctx context.Context, command ReleaseAdminUserBanCommand) (AdminUserBan, UserStatusResult, error) {
|
func (s *Service) AdminUnbanUser(ctx context.Context, command AdminForceUnbanCommand) (AdminForceUnbanResult, UserStatusResult, error) {
|
||||||
if s.moderationRepository == nil {
|
if s.moderationRepository == nil {
|
||||||
return AdminUserBan{}, UserStatusResult{}, xerr.New(xerr.Unavailable, "moderation repository is not configured")
|
return AdminForceUnbanResult{}, UserStatusResult{}, xerr.New(xerr.Unavailable, "moderation repository is not configured")
|
||||||
}
|
}
|
||||||
command.BanID = strings.TrimSpace(command.BanID)
|
|
||||||
command.Reason = strings.TrimSpace(command.Reason)
|
command.Reason = strings.TrimSpace(command.Reason)
|
||||||
if command.TargetUserID <= 0 || command.OperatorAdminID <= 0 {
|
if command.TargetUserID <= 0 || command.OperatorAdminID <= 0 {
|
||||||
return AdminUserBan{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "admin unban command is incomplete")
|
return AdminForceUnbanResult{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "admin unban command is incomplete")
|
||||||
}
|
}
|
||||||
// 解封审计和状态恢复统一使用 owner 时钟,避免跨服务时钟偏差污染事实顺序。
|
// 解封审计和状态恢复统一使用 owner 时钟,避免跨服务时钟偏差污染事实顺序。
|
||||||
command.NowMs = s.now().UTC().UnixMilli()
|
command.NowMs = s.now().UTC().UnixMilli()
|
||||||
command.RequestID = strings.TrimSpace(command.RequestID)
|
command.RequestID = strings.TrimSpace(command.RequestID)
|
||||||
if command.RequestID == "" {
|
if command.RequestID == "" {
|
||||||
return AdminUserBan{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "admin unban request_id is required")
|
return AdminForceUnbanResult{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "admin unban request_id is required")
|
||||||
}
|
}
|
||||||
if command.Reason == "" {
|
if command.Reason == "" {
|
||||||
command.Reason = "admin_user_unban"
|
command.Reason = "admin_force_unban"
|
||||||
|
}
|
||||||
|
// release_reason 还会附带操作类型和管理员 ID,给前缀预留空间,避免多字节原因触发 MySQL 截断。
|
||||||
|
if len(command.Reason) > 200 {
|
||||||
|
return AdminForceUnbanResult{}, UserStatusResult{}, xerr.New(xerr.InvalidArgument, "admin unban reason is too long")
|
||||||
}
|
}
|
||||||
command.Status = AdminUserBanStatusReleased
|
|
||||||
|
|
||||||
release, err := s.moderationRepository.ReleaseAdminUserBan(ctx, command)
|
release, err := s.moderationRepository.ForceReleaseUserBans(ctx, command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AdminUserBan{}, UserStatusResult{}, err
|
return AdminForceUnbanResult{}, UserStatusResult{}, err
|
||||||
}
|
}
|
||||||
return release.Ban, UserStatusResult{User: release.User, AccessTokenRevoked: true}, nil
|
return release, UserStatusResult{User: release.User, AccessTokenRevoked: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpireAdminUserBans 处理一批已到期的限时管理员封禁;永久封禁的 expires_at_ms=0 永远不会被 claim。
|
// ExpireAdminUserBans 处理一批已到期的限时管理员封禁;永久封禁的 expires_at_ms=0 永远不会被 claim。
|
||||||
|
|||||||
@ -135,3 +135,72 @@ func TestAdminChangeUserCountryWritesOutboxWhenRegionUnchanged(t *testing.T) {
|
|||||||
t.Fatalf("same-region outbox payload mismatch: %v", payload)
|
t.Fatalf("same-region outbox payload mismatch: %v", payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAppChangeUserCountryKeepsSameMillisecondSameRegionOutboxEventsDistinct(t *testing.T) {
|
||||||
|
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
for _, countryCode := range []string{"US", "CA", "MX"} {
|
||||||
|
repository.PutCountry(userdomain.Country{CountryCode: countryCode, Enabled: true})
|
||||||
|
}
|
||||||
|
repository.PutRegion(userdomain.Region{
|
||||||
|
RegionID: 404,
|
||||||
|
RegionCode: "NORTH-AMERICA",
|
||||||
|
Name: "North America",
|
||||||
|
Countries: []string{"US", "CA", "MX"},
|
||||||
|
})
|
||||||
|
repository.PutUser(userdomain.User{
|
||||||
|
UserID: 91003,
|
||||||
|
DefaultDisplayUserID: "91003",
|
||||||
|
CurrentDisplayUserID: "91003",
|
||||||
|
Country: "US",
|
||||||
|
RegionID: 404,
|
||||||
|
ProfileCompleted: true,
|
||||||
|
ProfileCompletedAtMs: 1,
|
||||||
|
OnboardingStatus: userdomain.OnboardingStatusCompleted,
|
||||||
|
Status: userdomain.StatusActive,
|
||||||
|
})
|
||||||
|
// 固定同一毫秒复现高速连续修改;国家码未进入 event_id 时,第二条同区域事件会被 INSERT IGNORE 静默丢弃。
|
||||||
|
svc := userservice.New(repository,
|
||||||
|
userservice.WithCountryRegionRepository(repository),
|
||||||
|
userservice.WithClock(func() time.Time { return time.UnixMilli(1_700_000_789_000).UTC() }),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, _, err := svc.ChangeUserCountry(ctx, 91003, "CA", "req-app-country-ca"); err != nil {
|
||||||
|
t.Fatalf("first app country change failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ChangeUserCountry(ctx, 91003, "MX", "req-app-country-mx"); err != nil {
|
||||||
|
t.Fatalf("second app country change failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := repository.RawDB().QueryContext(ctx, `
|
||||||
|
SELECT event_id, CAST(payload_json AS CHAR)
|
||||||
|
FROM user_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'UserRegionChanged' AND aggregate_id = ?
|
||||||
|
`, appcode.Default, int64(91003))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query app UserRegionChanged outbox failed: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
found := make(map[string]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var eventID string
|
||||||
|
var payloadJSON string
|
||||||
|
if err := rows.Scan(&eventID, &payloadJSON); err != nil {
|
||||||
|
t.Fatalf("scan app UserRegionChanged outbox failed: %v", err)
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
|
||||||
|
t.Fatalf("decode app UserRegionChanged payload failed: %v", err)
|
||||||
|
}
|
||||||
|
if payload["source"] != "app" || int64(payload["old_region_id"].(float64)) != 404 || int64(payload["new_region_id"].(float64)) != 404 {
|
||||||
|
t.Fatalf("app UserRegionChanged payload mismatch: %v", payload)
|
||||||
|
}
|
||||||
|
found[payload["new_country"].(string)] = eventID
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("iterate app UserRegionChanged outbox failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(found) != 2 || found["CA"] == "" || found["MX"] == "" || found["CA"] == found["MX"] {
|
||||||
|
t.Fatalf("same-millisecond app country changes must keep two distinct events: %+v", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -182,23 +182,27 @@ func TestAdminBanUserAcceptsPermanentBanAndRunsRealtimeSideEffectsOnce(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminUnbanUserUsesOwnerClock(t *testing.T) {
|
func TestAdminUnbanUserUsesOwnerClock(t *testing.T) {
|
||||||
repository := &fakeModerationRepository{adminBanRelease: AdminUserBanRelease{
|
repository := &fakeModerationRepository{adminForceUnbanResult: AdminForceUnbanResult{
|
||||||
Ban: AdminUserBan{BanID: "ban-1", TargetUserID: 10001, Status: AdminUserBanStatusReleased},
|
|
||||||
User: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusActive},
|
User: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusActive},
|
||||||
|
ReleasedManagerBlockCount: 1,
|
||||||
|
StatusRestored: true,
|
||||||
}}
|
}}
|
||||||
svc := New(repository,
|
svc := New(repository,
|
||||||
WithModerationRepository(repository),
|
WithModerationRepository(repository),
|
||||||
WithClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() }),
|
WithClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() }),
|
||||||
)
|
)
|
||||||
_, _, err := svc.AdminUnbanUser(context.Background(), ReleaseAdminUserBanCommand{
|
release, _, err := svc.AdminUnbanUser(context.Background(), AdminForceUnbanCommand{
|
||||||
BanID: "ban-1", TargetUserID: 10001, OperatorAdminID: 9001,
|
TargetUserID: 10001, OperatorAdminID: 9001, Reason: "manual",
|
||||||
Reason: "manual", RequestID: "unban-owner-clock", NowMs: 1,
|
RequestID: "unban-owner-clock", NowMs: 1,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AdminUnbanUser failed: %v", err)
|
t.Fatalf("AdminUnbanUser failed: %v", err)
|
||||||
}
|
}
|
||||||
if repository.lastAdminBanReleaseCommand.NowMs != 1_700_000_000_000 {
|
if repository.lastAdminForceUnbanCommand.NowMs != 1_700_000_000_000 {
|
||||||
t.Fatalf("unban must use user-service owner clock: %+v", repository.lastAdminBanReleaseCommand)
|
t.Fatalf("unban must use user-service owner clock: %+v", repository.lastAdminForceUnbanCommand)
|
||||||
|
}
|
||||||
|
if release.ReleasedManagerBlockCount != 1 || !release.StatusRestored {
|
||||||
|
t.Fatalf("force unban result mismatch: %+v", release)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,6 +280,7 @@ type fakeModerationRepository struct {
|
|||||||
lastCommand UserStatusCommand
|
lastCommand UserStatusCommand
|
||||||
lastAdminBanCommand AdminUserBanCommand
|
lastAdminBanCommand AdminUserBanCommand
|
||||||
lastAdminBanReleaseCommand ReleaseAdminUserBanCommand
|
lastAdminBanReleaseCommand ReleaseAdminUserBanCommand
|
||||||
|
lastAdminForceUnbanCommand AdminForceUnbanCommand
|
||||||
lastProfileCommand userdomain.ProfileUpdateCommand
|
lastProfileCommand userdomain.ProfileUpdateCommand
|
||||||
lastWithdrawAddressCommand userdomain.WithdrawAddressUpdateCommand
|
lastWithdrawAddressCommand userdomain.WithdrawAddressUpdateCommand
|
||||||
lastCountryCommand userdomain.CountryChangeCommand
|
lastCountryCommand userdomain.CountryChangeCommand
|
||||||
@ -287,6 +292,7 @@ type fakeModerationRepository struct {
|
|||||||
adminProfiles map[int64]userdomain.AdminProfile
|
adminProfiles map[int64]userdomain.AdminProfile
|
||||||
adminBanPersistence AdminUserBanPersistenceResult
|
adminBanPersistence AdminUserBanPersistenceResult
|
||||||
adminBanRelease AdminUserBanRelease
|
adminBanRelease AdminUserBanRelease
|
||||||
|
adminForceUnbanResult AdminForceUnbanResult
|
||||||
adminBanExpirations []AdminUserBanRelease
|
adminBanExpirations []AdminUserBanRelease
|
||||||
managerCrossRegionEnabled bool
|
managerCrossRegionEnabled bool
|
||||||
}
|
}
|
||||||
@ -356,6 +362,11 @@ func (r *fakeModerationRepository) ReleaseAdminUserBan(_ context.Context, comman
|
|||||||
return r.adminBanRelease, nil
|
return r.adminBanRelease, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *fakeModerationRepository) ForceReleaseUserBans(_ context.Context, command AdminForceUnbanCommand) (AdminForceUnbanResult, error) {
|
||||||
|
r.lastAdminForceUnbanCommand = command
|
||||||
|
return r.adminForceUnbanResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *fakeModerationRepository) ExpireAdminUserBans(context.Context, int64, int32) ([]AdminUserBanRelease, error) {
|
func (r *fakeModerationRepository) ExpireAdminUserBans(context.Context, int64, int32) ([]AdminUserBanRelease, error) {
|
||||||
return r.adminBanExpirations, nil
|
return r.adminBanExpirations, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,6 +175,8 @@ type ModerationRepository interface {
|
|||||||
ExpireManagerUserBlocks(ctx context.Context, nowMS int64, limit int32) ([]ManagerUserBlockRelease, error)
|
ExpireManagerUserBlocks(ctx context.Context, nowMS int64, limit int32) ([]ManagerUserBlockRelease, error)
|
||||||
CreateAdminUserBan(ctx context.Context, command AdminUserBanCommand) (AdminUserBanPersistenceResult, error)
|
CreateAdminUserBan(ctx context.Context, command AdminUserBanCommand) (AdminUserBanPersistenceResult, error)
|
||||||
ReleaseAdminUserBan(ctx context.Context, command ReleaseAdminUserBanCommand) (AdminUserBanRelease, error)
|
ReleaseAdminUserBan(ctx context.Context, command ReleaseAdminUserBanCommand) (AdminUserBanRelease, error)
|
||||||
|
// ForceReleaseUserBans 是后台管理员的最高治理入口,必须在一个事务内释放管理员和经理两类封禁事实。
|
||||||
|
ForceReleaseUserBans(ctx context.Context, command AdminForceUnbanCommand) (AdminForceUnbanResult, error)
|
||||||
ExpireAdminUserBans(ctx context.Context, nowMS int64, limit int32) ([]AdminUserBanRelease, error)
|
ExpireAdminUserBans(ctx context.Context, nowMS int64, limit int32) ([]AdminUserBanRelease, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -199,6 +199,140 @@ func TestManagerAndAdminBanOverlapRestoresOnlyAfterBothSourcesRelease(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminForceUnbanReleasesAllManagedSourcesAndRestoresUser(t *testing.T) {
|
||||||
|
repo := mysqltest.NewRepository(t)
|
||||||
|
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||||
|
createAdminBanTestUser(t, repo, 18008, "188008", userdomain.StatusActive)
|
||||||
|
storage := repo.Repository.UserRepository()
|
||||||
|
target, err := storage.GetUser(ctx, 18008)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get force unban target failed: %v", err)
|
||||||
|
}
|
||||||
|
managerBlock, err := storage.CreateManagerUserBlock(ctx, userservice.ManagerUserBlockCommand{
|
||||||
|
CommandID: "force-manager", ManagerUserID: 7001, TargetUserID: 18008,
|
||||||
|
BlockedUntilMS: 20_000, Reason: "manager", RequestID: "force-manager", NowMS: 10_000,
|
||||||
|
}, target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create manager block failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := storage.SetUserStatus(ctx, userservice.UserStatusCommand{
|
||||||
|
AppCode: "lalu", TargetUserID: 18008, Status: userdomain.StatusBanned,
|
||||||
|
OperatorType: userservice.OperatorTypeAppUser, OperatorUserID: 7001,
|
||||||
|
Reason: "manager_center_block:" + managerBlock.BlockID, RequestID: "force-manager", NowMs: 10_000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("apply manager block status failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := storage.CreateAdminUserBan(ctx, userservice.AdminUserBanCommand{
|
||||||
|
CommandID: "force-admin-first", TargetUserID: 18008, ExpiresAtMs: 0,
|
||||||
|
OperatorAdminID: 9001, Reason: "admin-first", RequestID: "force-admin-first", NowMs: 10_100,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create first admin ban failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := storage.CreateAdminUserBan(ctx, userservice.AdminUserBanCommand{
|
||||||
|
CommandID: "force-admin-second", TargetUserID: 18008, ExpiresAtMs: 30_000,
|
||||||
|
OperatorAdminID: 9002, Reason: "admin-second", RequestID: "force-admin-second", NowMs: 10_200,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create second admin ban failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
release, err := storage.ForceReleaseUserBans(ctx, userservice.AdminForceUnbanCommand{
|
||||||
|
TargetUserID: 18008, OperatorAdminID: 9900, Reason: "manual override",
|
||||||
|
RequestID: "force-unban-all", NowMs: 10_300,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("force unban failed: %v", err)
|
||||||
|
}
|
||||||
|
if release.ReleasedAdminBanCount != 2 || release.ReleasedManagerBlockCount != 1 || !release.StatusRestored || release.User.Status != userdomain.StatusActive {
|
||||||
|
t.Fatalf("force unban must release every source and restore user: %+v", release)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeAdminCount, activeManagerCount int64
|
||||||
|
if err := repo.RawDB().QueryRow(`SELECT COUNT(*) FROM admin_user_bans WHERE app_code = 'lalu' AND target_user_id = 18008 AND status = 'active'`).Scan(&activeAdminCount); err != nil {
|
||||||
|
t.Fatalf("count active admin bans failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.RawDB().QueryRow(`SELECT COUNT(*) FROM manager_user_blocks WHERE app_code = 'lalu' AND target_user_id = 18008 AND status = 'active'`).Scan(&activeManagerCount); err != nil {
|
||||||
|
t.Fatalf("count active manager blocks failed: %v", err)
|
||||||
|
}
|
||||||
|
if activeAdminCount != 0 || activeManagerCount != 0 {
|
||||||
|
t.Fatalf("force unban left active facts: admin=%d manager=%d", activeAdminCount, activeManagerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminForceUnbanRetryDoesNotReleaseNewBan(t *testing.T) {
|
||||||
|
repo := mysqltest.NewRepository(t)
|
||||||
|
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||||
|
createAdminBanTestUser(t, repo, 18009, "188009", userdomain.StatusBanned)
|
||||||
|
storage := repo.Repository.UserRepository()
|
||||||
|
command := userservice.AdminForceUnbanCommand{
|
||||||
|
TargetUserID: 18009, OperatorAdminID: 9900, Reason: "legacy override",
|
||||||
|
RequestID: "force-unban-idempotent", NowMs: 11_000,
|
||||||
|
}
|
||||||
|
first, err := storage.ForceReleaseUserBans(ctx, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("force legacy unban failed: %v", err)
|
||||||
|
}
|
||||||
|
if first.ReleasedAdminBanCount != 0 || first.ReleasedManagerBlockCount != 0 || !first.StatusRestored || first.User.Status != userdomain.StatusActive {
|
||||||
|
t.Fatalf("legacy force unban mismatch: %+v", first)
|
||||||
|
}
|
||||||
|
newBan, err := storage.CreateAdminUserBan(ctx, userservice.AdminUserBanCommand{
|
||||||
|
CommandID: "ban-after-force", TargetUserID: 18009, ExpiresAtMs: 0,
|
||||||
|
OperatorAdminID: 9001, Reason: "new independent ban", RequestID: "ban-after-force", NowMs: 11_100,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create new ban after force unban failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retry, err := storage.ForceReleaseUserBans(ctx, command)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry force unban failed: %v", err)
|
||||||
|
}
|
||||||
|
if !retry.StatusRestored || retry.User.Status != userdomain.StatusBanned {
|
||||||
|
t.Fatalf("retry must return first command result without restoring newly banned user: %+v", retry)
|
||||||
|
}
|
||||||
|
var newBanStatus string
|
||||||
|
if err := repo.RawDB().QueryRow(`SELECT status FROM admin_user_bans WHERE app_code = 'lalu' AND ban_id = ?`, newBan.Ban.BanID).Scan(&newBanStatus); err != nil {
|
||||||
|
t.Fatalf("read new ban status failed: %v", err)
|
||||||
|
}
|
||||||
|
if newBanStatus != userservice.AdminUserBanStatusActive {
|
||||||
|
t.Fatalf("retry released a ban created after the original command: status=%s", newBanStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleasedManagerBlockCannotReapplyBannedStatus(t *testing.T) {
|
||||||
|
repo := mysqltest.NewRepository(t)
|
||||||
|
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||||
|
createAdminBanTestUser(t, repo, 18010, "188010", userdomain.StatusActive)
|
||||||
|
storage := repo.Repository.UserRepository()
|
||||||
|
target, err := storage.GetUser(ctx, 18010)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get manager race target failed: %v", err)
|
||||||
|
}
|
||||||
|
block, err := storage.CreateManagerUserBlock(ctx, userservice.ManagerUserBlockCommand{
|
||||||
|
CommandID: "manager-race", ManagerUserID: 7001, TargetUserID: 18010,
|
||||||
|
BlockedUntilMS: 30_000, Reason: "race", RequestID: "manager-race", NowMS: 12_000,
|
||||||
|
}, target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create manager race block failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := storage.ForceReleaseUserBans(ctx, userservice.AdminForceUnbanCommand{
|
||||||
|
TargetUserID: 18010, OperatorAdminID: 9900, Reason: "override pending block",
|
||||||
|
RequestID: "force-before-manager-status", NowMs: 12_100,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("force release pending manager block failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := storage.SetUserStatus(ctx, userservice.UserStatusCommand{
|
||||||
|
AppCode: "lalu", TargetUserID: 18010, Status: userdomain.StatusBanned,
|
||||||
|
OperatorType: userservice.OperatorTypeAppUser, OperatorUserID: 7001,
|
||||||
|
Reason: "manager_center_block:" + block.BlockID, RequestID: "manager-race", NowMs: 12_200,
|
||||||
|
}); xerr.CodeOf(err) != xerr.Conflict {
|
||||||
|
t.Fatalf("released manager block must not reapply banned status, err=%v", err)
|
||||||
|
}
|
||||||
|
user, err := storage.GetUser(ctx, 18010)
|
||||||
|
if err != nil || user.Status != userdomain.StatusActive {
|
||||||
|
t.Fatalf("released manager race must leave user active: user=%+v err=%v", user, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExpireAdminUserBansSkipsPermanentAndRestoresTimedBan(t *testing.T) {
|
func TestExpireAdminUserBansSkipsPermanentAndRestoresTimedBan(t *testing.T) {
|
||||||
repo := mysqltest.NewRepository(t)
|
repo := mysqltest.NewRepository(t)
|
||||||
ctx := appcode.WithContext(context.Background(), "lalu")
|
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||||
|
|||||||
171
services/user-service/internal/storage/mysql/user/force_unban.go
Normal file
171
services/user-service/internal/storage/mysql/user/force_unban.go
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||||
|
userservice "hyapp/services/user-service/internal/service/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ForceReleaseUserBans 是后台管理员覆盖全部封禁来源的唯一持久化入口。
|
||||||
|
// 用户主状态、管理员封禁、经理封禁和命令幂等事实必须在同一事务提交,避免出现“状态已恢复但封禁仍 active”的中间态。
|
||||||
|
func (r *Repository) ForceReleaseUserBans(ctx context.Context, command userservice.AdminForceUnbanCommand) (userservice.AdminForceUnbanResult, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
appCode := appcode.FromContext(ctx)
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// 先锁 users 行,使同一用户的状态切换串行化;事实表的条件 UPDATE 随后锁定当前全部 active 范围。
|
||||||
|
// 经理封禁切状态前也会复验其 block 仍 active,因此与本事务并发时不会留下 released block + banned 用户的不一致组合。
|
||||||
|
currentStatus, err := lockUserStatus(ctx, tx, appCode, command.TargetUserID)
|
||||||
|
if err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
existing, result, err := claimAdminForceUnbanCommand(ctx, tx, appCode, command)
|
||||||
|
if err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
if existing {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
// 幂等重试返回当前用户状态,但绝不重新扫描 active 事实;首次命令之后的新封禁必须由新的 request_id 显式释放。
|
||||||
|
result.User, err = r.GetUser(ctx, command.TargetUserID)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseReason := fmt.Sprintf("admin_force_unban:%d:%s", command.OperatorAdminID, command.Reason)
|
||||||
|
adminUpdate, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE admin_user_bans
|
||||||
|
SET status = ?, released_by_admin_id = ?, released_reason = ?, released_at_ms = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND target_user_id = ? AND status = ?`,
|
||||||
|
userservice.AdminUserBanStatusReleased, command.OperatorAdminID, releaseReason, command.NowMs, command.NowMs,
|
||||||
|
appCode, command.TargetUserID, userservice.AdminUserBanStatusActive,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
result.ReleasedAdminBanCount, err = adminUpdate.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
managerUpdate, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE manager_user_blocks
|
||||||
|
SET status = ?, released_reason = ?, released_at_ms = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND target_user_id = ? AND status = ?`,
|
||||||
|
userservice.ManagerUserBlockStatusReleased, releaseReason, command.NowMs, command.NowMs,
|
||||||
|
appCode, command.TargetUserID, userservice.ManagerUserBlockStatusActive,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
result.ReleasedManagerBlockCount, err = managerUpdate.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newStatus := currentStatus
|
||||||
|
switch currentStatus {
|
||||||
|
case userdomain.StatusActive:
|
||||||
|
// 数据修复场景可能存在 active 用户仍挂 active 封禁事实;释放事实即可,不制造 active -> active 审计噪声。
|
||||||
|
case userdomain.StatusBanned, userdomain.StatusDisabled:
|
||||||
|
newStatus = userdomain.StatusActive
|
||||||
|
result.StatusRestored = true
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE users SET status = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND user_id = ?`,
|
||||||
|
string(newStatus), command.NowMs, appCode, command.TargetUserID,
|
||||||
|
); err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO user_status_change_logs (
|
||||||
|
app_code, target_user_id, old_status, new_status, operator_type,
|
||||||
|
operator_user_id, reason, request_id, created_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
appCode, command.TargetUserID, string(currentStatus), string(newStatus), userservice.OperatorTypeAdmin,
|
||||||
|
command.OperatorAdminID, "admin_force_unban", command.RequestID, command.NowMs,
|
||||||
|
); err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// 未知主状态不能被强制改写,否则会把未来新增的独立治理状态静默降级为 active。
|
||||||
|
return userservice.AdminForceUnbanResult{}, xerr.New(xerr.Conflict, "user status cannot be force unbanned")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE admin_user_force_unban_commands
|
||||||
|
SET released_admin_ban_count = ?, released_manager_block_count = ?,
|
||||||
|
old_status = ?, new_status = ?, status_restored = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND command_id = ?`,
|
||||||
|
result.ReleasedAdminBanCount, result.ReleasedManagerBlockCount,
|
||||||
|
string(currentStatus), string(newStatus), result.StatusRestored, command.NowMs,
|
||||||
|
appCode, command.RequestID,
|
||||||
|
); err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return userservice.AdminForceUnbanResult{}, err
|
||||||
|
}
|
||||||
|
result.User, err = r.GetUser(ctx, command.TargetUserID)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimAdminForceUnbanCommand 固化一次“释放全部来源”的命令载荷和首次结果。
|
||||||
|
// INSERT IGNORE + FOR UPDATE 使并发重试等待首个事务完成,并拒绝同一 request_id 被复用到其他用户或管理员。
|
||||||
|
func claimAdminForceUnbanCommand(ctx context.Context, tx *sql.Tx, appCode string, command userservice.AdminForceUnbanCommand) (bool, userservice.AdminForceUnbanResult, error) {
|
||||||
|
result := userservice.AdminForceUnbanResult{}
|
||||||
|
insert, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT IGNORE INTO admin_user_force_unban_commands (
|
||||||
|
app_code, command_id, target_user_id, operator_admin_id, reason,
|
||||||
|
created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
appCode, command.RequestID, command.TargetUserID, command.OperatorAdminID, command.Reason,
|
||||||
|
command.NowMs, command.NowMs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, result, err
|
||||||
|
}
|
||||||
|
inserted, err := insert.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, result, err
|
||||||
|
}
|
||||||
|
if inserted == 1 {
|
||||||
|
return false, result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetUserID, operatorAdminID int64
|
||||||
|
var reason string
|
||||||
|
var restored bool
|
||||||
|
err = tx.QueryRowContext(ctx, `
|
||||||
|
SELECT target_user_id, operator_admin_id, reason,
|
||||||
|
released_admin_ban_count, released_manager_block_count, status_restored
|
||||||
|
FROM admin_user_force_unban_commands
|
||||||
|
WHERE app_code = ? AND command_id = ?
|
||||||
|
FOR UPDATE`, appCode, command.RequestID).Scan(
|
||||||
|
&targetUserID, &operatorAdminID, &reason,
|
||||||
|
&result.ReleasedAdminBanCount, &result.ReleasedManagerBlockCount, &restored,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, result, xerr.New(xerr.Conflict, "admin force unban command disappeared")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, result, err
|
||||||
|
}
|
||||||
|
if targetUserID != command.TargetUserID || operatorAdminID != command.OperatorAdminID || reason != command.Reason {
|
||||||
|
return false, result, xerr.New(xerr.Conflict, "admin force unban command payload does not match existing command")
|
||||||
|
}
|
||||||
|
result.StatusRestored = restored
|
||||||
|
return true, result, nil
|
||||||
|
}
|
||||||
@ -13,6 +13,8 @@ import (
|
|||||||
userservice "hyapp/services/user-service/internal/service/user"
|
userservice "hyapp/services/user-service/internal/service/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const managerBlockStatusReasonPrefix = "manager_center_block:"
|
||||||
|
|
||||||
// ManagerCrossRegionEnabled 读取 active 经理的通用数据范围开关;功能权限仍由 gateway/user host service 分别校验。
|
// ManagerCrossRegionEnabled 读取 active 经理的通用数据范围开关;功能权限仍由 gateway/user host service 分别校验。
|
||||||
func (r *Repository) ManagerCrossRegionEnabled(ctx context.Context, managerUserID int64) (bool, error) {
|
func (r *Repository) ManagerCrossRegionEnabled(ctx context.Context, managerUserID int64) (bool, error) {
|
||||||
var enabled bool
|
var enabled bool
|
||||||
@ -66,6 +68,26 @@ func (r *Repository) SetUserStatus(ctx context.Context, command userservice.User
|
|||||||
return userservice.UserStatusPersistenceResult{}, xerr.New(xerr.Conflict, "user still has active ban facts")
|
return userservice.UserStatusPersistenceResult{}, xerr.New(xerr.Conflict, "user still has active ban facts")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if command.Status == userdomain.StatusBanned && strings.HasPrefix(command.Reason, managerBlockStatusReasonPrefix) {
|
||||||
|
blockID := strings.TrimSpace(strings.TrimPrefix(command.Reason, managerBlockStatusReasonPrefix))
|
||||||
|
var blockStatus string
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT status
|
||||||
|
FROM manager_user_blocks
|
||||||
|
WHERE app_code = ? AND block_id = ? AND target_user_id = ?
|
||||||
|
FOR UPDATE`, appCode, blockID, command.TargetUserID).Scan(&blockStatus)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// 经理封禁先写 block 再切用户状态;后台可能已在两步之间强制释放该 block,此时不能重新制造无 active 事实的 banned 状态。
|
||||||
|
return userservice.UserStatusPersistenceResult{}, xerr.New(xerr.Conflict, "manager block is no longer active")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return userservice.UserStatusPersistenceResult{}, err
|
||||||
|
}
|
||||||
|
if blockStatus != userservice.ManagerUserBlockStatusActive {
|
||||||
|
// block 已被经理自行解除、自动到期或后台强制释放时,后到达的状态写入必须作为冲突终止。
|
||||||
|
return userservice.UserStatusPersistenceResult{}, xerr.New(xerr.Conflict, "manager block is no longer active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
_, err = tx.ExecContext(ctx, `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
|
|||||||
@ -294,7 +294,8 @@ func insertUserRegionChangedOutbox(ctx context.Context, tx *sql.Tx, command user
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
eventID := fmt.Sprintf("UserRegionChanged:%s:%d:%d:%d:%d", source, command.UserID, oldRegionID, command.NewRegionID, command.ChangedAtMs)
|
// 普通用户没有修改冷却期;同一毫秒内连续切换同一区域的两个国家时,国家码必须参与幂等键,避免第二条事实被 INSERT IGNORE 吞掉。
|
||||||
|
eventID := fmt.Sprintf("UserRegionChanged:%s:%d:%s:%s:%d:%d:%d", source, command.UserID, userdomain.NormalizeCountryCode(oldCountry), command.NewCountry, oldRegionID, command.NewRegionID, command.ChangedAtMs)
|
||||||
_, err = tx.ExecContext(ctx, `
|
_, err = tx.ExecContext(ctx, `
|
||||||
INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
|
INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
|
||||||
VALUES (?, ?, 'UserRegionChanged', 'user', ?, 'pending', ?, ?, ?)
|
VALUES (?, ?, 'UserRegionChanged', 'user', ?, 'pending', ?, ?, ?)
|
||||||
|
|||||||
@ -483,11 +483,10 @@ func (s *Server) AdminBanUser(ctx context.Context, req *userv1.AdminBanUserReque
|
|||||||
return &userv1.AdminBanUserResponse{Ban: toProtoAdminUserBan(ban), Status: toProtoSetUserStatusResponse(status)}, nil
|
return &userv1.AdminBanUserResponse{Ban: toProtoAdminUserBan(ban), Status: toProtoSetUserStatusResponse(status)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUnbanUser 释放指定或该用户最新的管理员封禁事实;仍有其他管理员或经理封禁时用户继续保持 banned。
|
// AdminUnbanUser 由后台管理员强制释放目标用户全部管理员、经理和 legacy 封禁来源。
|
||||||
func (s *Server) AdminUnbanUser(ctx context.Context, req *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error) {
|
func (s *Server) AdminUnbanUser(ctx context.Context, req *userv1.AdminUnbanUserRequest) (*userv1.AdminUnbanUserResponse, error) {
|
||||||
ctx = contextWithApp(ctx, req.GetMeta())
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
ban, status, err := s.userSvc.AdminUnbanUser(ctx, userservice.ReleaseAdminUserBanCommand{
|
release, status, err := s.userSvc.AdminUnbanUser(ctx, userservice.AdminForceUnbanCommand{
|
||||||
BanID: req.GetBanId(),
|
|
||||||
TargetUserID: req.GetTargetUserId(),
|
TargetUserID: req.GetTargetUserId(),
|
||||||
OperatorAdminID: req.GetOperatorAdminId(),
|
OperatorAdminID: req.GetOperatorAdminId(),
|
||||||
Reason: req.GetReason(),
|
Reason: req.GetReason(),
|
||||||
@ -497,7 +496,12 @@ func (s *Server) AdminUnbanUser(ctx context.Context, req *userv1.AdminUnbanUserR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerr.ToGRPCError(err)
|
return nil, xerr.ToGRPCError(err)
|
||||||
}
|
}
|
||||||
return &userv1.AdminUnbanUserResponse{Ban: toProtoAdminUserBan(ban), Status: toProtoSetUserStatusResponse(status)}, nil
|
return &userv1.AdminUnbanUserResponse{
|
||||||
|
Status: toProtoSetUserStatusResponse(status),
|
||||||
|
ReleasedAdminBanCount: release.ReleasedAdminBanCount,
|
||||||
|
ReleasedManagerBlockCount: release.ReleasedManagerBlockCount,
|
||||||
|
StatusRestored: release.StatusRestored,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateManagerUserBlock 创建经理中心封禁记录,并同步把目标账号置为 banned。
|
// CreateManagerUserBlock 创建经理中心封禁记录,并同步把目标账号置为 banned。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user