后台解除封禁以及经理修改用户国家

This commit is contained in:
zhx 2026-07-14 10:29:24 +08:00
parent 36b01aebb7
commit b5f85ec5c5
19 changed files with 1912 additions and 1334 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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='后台管理员强制解封幂等事实表';

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

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

View File

@ -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', ?, ?, ?)

View File

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