后台解除封禁以及经理修改用户国家
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;
|
||||
}
|
||||
|
||||
// AdminUnbanUserRequest 释放一条管理员封禁;ban_id 留空时按目标用户释放最新一条,其他管理员或经理封禁仍会阻止账号恢复。
|
||||
// AdminUnbanUserRequest 由后台管理员强制释放目标用户全部管理员、经理和 legacy 封禁来源。
|
||||
message AdminUnbanUserRequest {
|
||||
RequestMeta meta = 1;
|
||||
// ban_id 已废弃;保留字段号用于滚动发布兼容,强制解封始终按 target_user_id 处理全部有效事实。
|
||||
string ban_id = 2;
|
||||
int64 target_user_id = 3;
|
||||
int64 operator_admin_id = 4;
|
||||
@ -943,8 +944,12 @@ message AdminUnbanUserRequest {
|
||||
}
|
||||
|
||||
message AdminUnbanUserResponse {
|
||||
// ban 仅为旧客户端保留;强制解封可能只释放经理或 legacy 来源,因此新调用方不应依赖该字段。
|
||||
AdminUserBan ban = 1;
|
||||
SetUserStatusResponse status = 2;
|
||||
int64 released_admin_ban_count = 3;
|
||||
int64 released_manager_block_count = 4;
|
||||
bool status_restored = 5;
|
||||
}
|
||||
|
||||
message ManagerUserBlock {
|
||||
|
||||
@ -1078,16 +1078,6 @@ func (s *Service) AdminUnbanUser(ctx context.Context, userID int64, operatorAdmi
|
||||
if requestID == "" {
|
||||
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)
|
||||
if reason == "" {
|
||||
reason = "admin_app_user_unban"
|
||||
@ -1100,8 +1090,7 @@ func (s *Service) AdminUnbanUser(ctx context.Context, userID int64, operatorAdmi
|
||||
SentAtMs: nowMs,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
},
|
||||
// 摘要可能选中一条更晚到期的 manager 封禁;留空由 user-service 选择最新 admin 事实。
|
||||
BanId: "",
|
||||
// user-service 以 target_user_id 为边界原子释放全部 admin/manager 事实;不再由聚合摘要猜测具体来源。
|
||||
TargetUserId: userID,
|
||||
OperatorAdminId: operatorAdminID,
|
||||
Reason: reason,
|
||||
|
||||
@ -903,7 +903,12 @@ func TestTransferManagerUserCountryUsesCrossRegionManagerScope(t *testing.T) {
|
||||
900001: {UserId: 900001, Status: userv1.UserStatus_USER_STATUS_ACTIVE, Country: "MX", RegionId: 1001},
|
||||
}}
|
||||
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"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/users/country", bytes.NewReader(body))
|
||||
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") {
|
||||
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 {
|
||||
|
||||
@ -13,6 +13,8 @@ type Handler struct {
|
||||
userHostAdminClient client.UserHostAdminClient
|
||||
userProfileClient client.UserProfileClient
|
||||
growthLevelClient client.GrowthLevelClient
|
||||
// broadcastClient 只负责国家跨区域后的旧群清理;国家和房间迁移事实仍由 user-service outbox 驱动。
|
||||
broadcastClient client.BroadcastClient
|
||||
// dashboardCache 缓存经理团队工资统计,避免每次进入经理中心都做组织树 + 周期账户双聚合。
|
||||
dashboardCache managerDashboardCache
|
||||
}
|
||||
@ -23,6 +25,7 @@ type Config struct {
|
||||
UserHostAdminClient client.UserHostAdminClient
|
||||
UserProfileClient client.UserProfileClient
|
||||
GrowthLevelClient client.GrowthLevelClient
|
||||
BroadcastClient client.BroadcastClient
|
||||
}
|
||||
|
||||
func New(config Config) *Handler {
|
||||
@ -32,6 +35,7 @@ func New(config Config) *Handler {
|
||||
userHostAdminClient: config.UserHostAdminClient,
|
||||
userProfileClient: config.UserProfileClient,
|
||||
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) {
|
||||
switch request.Method {
|
||||
case http.MethodGet:
|
||||
@ -853,6 +874,8 @@ func (h *Handler) transferManagerUserCountry(writer http.ResponseWriter, request
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
// 经理入口和 App/后台入口必须保持国家变更副作用一致;房间迁移由 user_outbox 保证,旧区域 IM 群清理在提交后尽力执行。
|
||||
h.removeManagerOldRegionBroadcastMemberBestEffort(request, targetUserID, resp.GetOldRegionId(), resp.GetNewRegionId())
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"user": managerUserFromProto(resp.GetUser(), nil),
|
||||
"old_region_id": resp.GetOldRegionId(),
|
||||
|
||||
@ -121,6 +121,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
UserHostAdminClient: h.userHostAdminClient,
|
||||
UserProfileClient: h.userProfileClient,
|
||||
GrowthLevelClient: h.growthLevelClient,
|
||||
BroadcastClient: h.broadcastClient,
|
||||
})
|
||||
appAPI := appapi.New(appapi.Config{
|
||||
AppConfigReader: h.appConfigReader,
|
||||
|
||||
@ -97,4 +97,41 @@ func TestHandleUserRegionChangedUpdatesOwnerRoomRegionIdempotently(t *testing.T)
|
||||
if len(oldRegionEntries) != 0 {
|
||||
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)
|
||||
) 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 (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
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
|
||||
}
|
||||
|
||||
// ReleaseAdminUserBanCommand 只释放指定 ban_id,避免一个管理员操作覆盖其他管理员的独立封禁事实。
|
||||
// ReleaseAdminUserBanCommand 只用于单条管理员封禁的自动到期和底层兼容释放。
|
||||
// 后台人工解封必须走 AdminForceUnbanCommand,保证经理封禁和重叠管理员封禁在同一事务内一起释放。
|
||||
type ReleaseAdminUserBanCommand struct {
|
||||
BanID string
|
||||
TargetUserID int64
|
||||
@ -39,6 +40,25 @@ type ReleaseAdminUserBanCommand struct {
|
||||
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 用于审计,不作为自动恢复的唯一判断依据。
|
||||
type AdminUserBan struct {
|
||||
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
|
||||
}
|
||||
|
||||
// AdminUnbanUser 手动释放一条管理员封禁。
|
||||
// repository 在同一事务内重新检查其他管理员和经理封禁;没有满足恢复条件时仅释放事实,不改 users.status。
|
||||
func (s *Service) AdminUnbanUser(ctx context.Context, command ReleaseAdminUserBanCommand) (AdminUserBan, UserStatusResult, error) {
|
||||
// AdminUnbanUser 是后台人工强制解封入口。
|
||||
// repository 原子释放全部管理员/经理封禁,并覆盖没有独立事实表记录的 legacy banned/disabled 状态。
|
||||
func (s *Service) AdminUnbanUser(ctx context.Context, command AdminForceUnbanCommand) (AdminForceUnbanResult, UserStatusResult, error) {
|
||||
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)
|
||||
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 时钟,避免跨服务时钟偏差污染事实顺序。
|
||||
command.NowMs = s.now().UTC().UnixMilli()
|
||||
command.RequestID = strings.TrimSpace(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 == "" {
|
||||
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 {
|
||||
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。
|
||||
|
||||
@ -135,3 +135,72 @@ func TestAdminChangeUserCountryWritesOutboxWhenRegionUnchanged(t *testing.T) {
|
||||
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) {
|
||||
repository := &fakeModerationRepository{adminBanRelease: AdminUserBanRelease{
|
||||
Ban: AdminUserBan{BanID: "ban-1", TargetUserID: 10001, Status: AdminUserBanStatusReleased},
|
||||
User: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusActive},
|
||||
repository := &fakeModerationRepository{adminForceUnbanResult: AdminForceUnbanResult{
|
||||
User: userdomain.User{AppCode: "lalu", UserID: 10001, Status: userdomain.StatusActive},
|
||||
ReleasedManagerBlockCount: 1,
|
||||
StatusRestored: true,
|
||||
}}
|
||||
svc := New(repository,
|
||||
WithModerationRepository(repository),
|
||||
WithClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() }),
|
||||
)
|
||||
_, _, err := svc.AdminUnbanUser(context.Background(), ReleaseAdminUserBanCommand{
|
||||
BanID: "ban-1", TargetUserID: 10001, OperatorAdminID: 9001,
|
||||
Reason: "manual", RequestID: "unban-owner-clock", NowMs: 1,
|
||||
release, _, err := svc.AdminUnbanUser(context.Background(), AdminForceUnbanCommand{
|
||||
TargetUserID: 10001, OperatorAdminID: 9001, Reason: "manual",
|
||||
RequestID: "unban-owner-clock", NowMs: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AdminUnbanUser failed: %v", err)
|
||||
}
|
||||
if repository.lastAdminBanReleaseCommand.NowMs != 1_700_000_000_000 {
|
||||
t.Fatalf("unban must use user-service owner clock: %+v", repository.lastAdminBanReleaseCommand)
|
||||
if repository.lastAdminForceUnbanCommand.NowMs != 1_700_000_000_000 {
|
||||
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
|
||||
lastAdminBanCommand AdminUserBanCommand
|
||||
lastAdminBanReleaseCommand ReleaseAdminUserBanCommand
|
||||
lastAdminForceUnbanCommand AdminForceUnbanCommand
|
||||
lastProfileCommand userdomain.ProfileUpdateCommand
|
||||
lastWithdrawAddressCommand userdomain.WithdrawAddressUpdateCommand
|
||||
lastCountryCommand userdomain.CountryChangeCommand
|
||||
@ -287,6 +292,7 @@ type fakeModerationRepository struct {
|
||||
adminProfiles map[int64]userdomain.AdminProfile
|
||||
adminBanPersistence AdminUserBanPersistenceResult
|
||||
adminBanRelease AdminUserBanRelease
|
||||
adminForceUnbanResult AdminForceUnbanResult
|
||||
adminBanExpirations []AdminUserBanRelease
|
||||
managerCrossRegionEnabled bool
|
||||
}
|
||||
@ -356,6 +362,11 @@ func (r *fakeModerationRepository) ReleaseAdminUserBan(_ context.Context, comman
|
||||
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) {
|
||||
return r.adminBanExpirations, nil
|
||||
}
|
||||
|
||||
@ -175,6 +175,8 @@ type ModerationRepository interface {
|
||||
ExpireManagerUserBlocks(ctx context.Context, nowMS int64, limit int32) ([]ManagerUserBlockRelease, error)
|
||||
CreateAdminUserBan(ctx context.Context, command AdminUserBanCommand) (AdminUserBanPersistenceResult, 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)
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
repo := mysqltest.NewRepository(t)
|
||||
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"
|
||||
)
|
||||
|
||||
const managerBlockStatusReasonPrefix = "manager_center_block:"
|
||||
|
||||
// ManagerCrossRegionEnabled 读取 active 经理的通用数据范围开关;功能权限仍由 gateway/user host service 分别校验。
|
||||
func (r *Repository) ManagerCrossRegionEnabled(ctx context.Context, managerUserID int64) (bool, error) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
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, `
|
||||
UPDATE users
|
||||
|
||||
@ -294,7 +294,8 @@ func insertUserRegionChangedOutbox(ctx context.Context, tx *sql.Tx, command user
|
||||
if err != nil {
|
||||
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, `
|
||||
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', ?, ?, ?)
|
||||
|
||||
@ -483,11 +483,10 @@ func (s *Server) AdminBanUser(ctx context.Context, req *userv1.AdminBanUserReque
|
||||
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) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
ban, status, err := s.userSvc.AdminUnbanUser(ctx, userservice.ReleaseAdminUserBanCommand{
|
||||
BanID: req.GetBanId(),
|
||||
release, status, err := s.userSvc.AdminUnbanUser(ctx, userservice.AdminForceUnbanCommand{
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
OperatorAdminID: req.GetOperatorAdminId(),
|
||||
Reason: req.GetReason(),
|
||||
@ -497,7 +496,12 @@ func (s *Server) AdminUnbanUser(ctx context.Context, req *userv1.AdminUnbanUserR
|
||||
if err != nil {
|
||||
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。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user