From 7d8ef54ca79c0b622ac759318fb1c6c373ae9f71 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 1 Jul 2026 16:27:19 +0800 Subject: [PATCH] fix: disable host identity after agency removal --- .../user-service/internal/domain/host/host.go | 2 +- .../internal/service/host/service.go | 4 +- .../internal/service/host/service_test.go | 113 ++++++++++++++++++ .../storage/mysql/host/applications.go | 38 +++++- .../internal/storage/mysql/host/writes.go | 11 ++ .../internal/transport/grpc/host.go | 2 +- 6 files changed, 160 insertions(+), 10 deletions(-) diff --git a/services/user-service/internal/domain/host/host.go b/services/user-service/internal/domain/host/host.go index fbdd78a6..2608e3b1 100644 --- a/services/user-service/internal/domain/host/host.go +++ b/services/user-service/internal/domain/host/host.go @@ -4,7 +4,7 @@ package host const ( // HostStatusActive 表示用户已经拥有长期主播身份。 HostStatusActive = "active" - // HostStatusDisabled 预留给后台停用主播身份,App Phase 1 不主动产生。 + // HostStatusDisabled 表示主播身份已停用;Agency owner 移除普通 Host 后也进入该状态。 HostStatusDisabled = "disabled" // HostSourceApplication 表示主播身份来自 Agency 申请审核通过。 diff --git a/services/user-service/internal/service/host/service.go b/services/user-service/internal/service/host/service.go index 8a078173..5a2de50a 100644 --- a/services/user-service/internal/service/host/service.go +++ b/services/user-service/internal/service/host/service.go @@ -174,7 +174,7 @@ func (s *Service) ReviewAgencyApplication(ctx context.Context, command ReviewAge }) } -// KickAgencyHost 结束普通成员关系,保留 Host 身份。 +// KickAgencyHost 结束普通成员关系,并在目标没有 active Agency owner 身份时停用 Host 身份。 func (s *Service) KickAgencyHost(ctx context.Context, command KickAgencyHostInput) (hostdomain.KickAgencyHostResult, error) { if err := s.requireWriteDependencies(); err != nil { return hostdomain.KickAgencyHostResult{}, err @@ -191,7 +191,7 @@ func (s *Service) KickAgencyHost(ctx context.Context, command KickAgencyHostInpu AgencyID: command.AgencyID, HostUserID: command.HostUserID, Reason: strings.TrimSpace(command.Reason), - EventID: s.idGenerator.NewInt64(), + EventID: s.nextEventIDRange(2), NowMs: nowMs, }) } diff --git a/services/user-service/internal/service/host/service_test.go b/services/user-service/internal/service/host/service_test.go index d7323073..17cf9e79 100644 --- a/services/user-service/internal/service/host/service_test.go +++ b/services/user-service/internal/service/host/service_test.go @@ -439,6 +439,119 @@ func TestAcceptHostInvitationDoesNotGrantAgencyIdentity(t *testing.T) { } } +func TestKickAgencyHostDisablesOrdinaryHostIdentity(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedActiveUser(t, repository, 621, 10) + seedActiveUser(t, repository, 622, 10) + repository.PutAgency(hostdomain.Agency{ + AgencyID: 623, + OwnerUserID: 621, + RegionID: 10, + Status: hostdomain.AgencyStatusActive, + JoinEnabled: true, + }) + repository.PutHostProfile(hostdomain.HostProfile{ + UserID: 622, + Status: hostdomain.HostStatusActive, + RegionID: 10, + CurrentAgencyID: 623, + CurrentMembershipID: 624, + Source: hostdomain.HostSourceHostInvitation, + }) + seedActiveMembership(t, repository, 624, 623, 622, hostdomain.MembershipTypeMember) + svc := newHostService(repository, 625, 640) + + result, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{ + CommandID: "kick-host-622", + OperatorUserID: 621, + AgencyID: 623, + HostUserID: 622, + Reason: "agency_remove", + }) + if err != nil { + t.Fatalf("KickAgencyHost failed: %v", err) + } + if result.Membership.Status != hostdomain.MembershipStatusEnded || result.Membership.EndedAtMs != 640 || result.Membership.EndedByUserID != 621 || result.Membership.EndedReason != "agency_remove" { + t.Fatalf("membership should be ended by agency owner: %+v", result.Membership) + } + if result.HostProfile.Status != hostdomain.HostStatusDisabled || result.HostProfile.CurrentAgencyID != 0 || result.HostProfile.CurrentMembershipID != 0 { + t.Fatalf("removed ordinary host should lose host identity: %+v", result.HostProfile) + } + summary, err := svc.GetUserRoleSummary(ctx, 622) + if err != nil { + t.Fatalf("GetUserRoleSummary after kick failed: %v", err) + } + if summary.IsHost || summary.HostStatus != hostdomain.HostStatusDisabled || summary.IsAgency { + t.Fatalf("removed host should no longer expose host entry: %+v", summary) + } + replayed, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{ + CommandID: "kick-host-622", + OperatorUserID: 621, + AgencyID: 623, + HostUserID: 622, + Reason: "agency_remove", + }) + if err != nil { + t.Fatalf("KickAgencyHost idempotent replay failed: %v", err) + } + if replayed.HostProfile.Status != hostdomain.HostStatusDisabled || replayed.HostProfile.CurrentMembershipID != 0 || replayed.Membership.Status != hostdomain.MembershipStatusEnded { + t.Fatalf("idempotent replay should return disabled host fact: %+v", replayed) + } +} + +func TestKickAgencyHostKeepsHostIdentityForActiveAgencyOwner(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedActiveUser(t, repository, 631, 10) + seedActiveUser(t, repository, 632, 10) + repository.PutAgency(hostdomain.Agency{ + AgencyID: 633, + OwnerUserID: 631, + RegionID: 10, + Status: hostdomain.AgencyStatusActive, + JoinEnabled: true, + }) + repository.PutAgency(hostdomain.Agency{ + AgencyID: 635, + OwnerUserID: 632, + RegionID: 10, + Status: hostdomain.AgencyStatusActive, + JoinEnabled: true, + }) + repository.PutHostProfile(hostdomain.HostProfile{ + UserID: 632, + Status: hostdomain.HostStatusActive, + RegionID: 10, + CurrentAgencyID: 633, + CurrentMembershipID: 634, + Source: hostdomain.HostSourceHostInvitation, + }) + seedActiveMembership(t, repository, 634, 633, 632, hostdomain.MembershipTypeMember) + svc := newHostService(repository, 636, 650) + + result, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{ + CommandID: "kick-host-owner-632", + OperatorUserID: 631, + AgencyID: 633, + HostUserID: 632, + Reason: "agency_remove", + }) + if err != nil { + t.Fatalf("KickAgencyHost for active agency owner failed: %v", err) + } + if result.HostProfile.Status != hostdomain.HostStatusActive || result.HostProfile.CurrentAgencyID != 0 || result.HostProfile.CurrentMembershipID != 0 { + t.Fatalf("active agency owner should keep host identity but drop removed membership pointer: %+v", result.HostProfile) + } + summary, err := svc.GetUserRoleSummary(ctx, 632) + if err != nil { + t.Fatalf("GetUserRoleSummary for active agency owner failed: %v", err) + } + if !summary.IsHost || !summary.IsAgency || summary.AgencyID != 635 { + t.Fatalf("active agency owner should still expose host and agency entries: %+v", summary) + } +} + func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) diff --git a/services/user-service/internal/storage/mysql/host/applications.go b/services/user-service/internal/storage/mysql/host/applications.go index 89aede0b..44303066 100644 --- a/services/user-service/internal/storage/mysql/host/applications.go +++ b/services/user-service/internal/storage/mysql/host/applications.go @@ -218,7 +218,7 @@ func (r *Repository) ReviewAgencyApplication(ctx context.Context, command hostse return result, nil } -// KickAgencyHost 只结束 Agency 成员关系,不删除 Host 身份。 +// KickAgencyHost 结束普通成员关系;普通成员会同步停用 Host 身份,Agency owner 例外。 func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.KickAgencyHostCommand) (hostdomain.KickAgencyHostResult, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -249,8 +249,14 @@ func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.Kic if agency.OwnerUserID == command.HostUserID { return hostdomain.KickAgencyHostResult{}, xerr.New(xerr.Conflict, "agency owner cannot be kicked") } - // host_profile 保留主播身份,只清理当前归属指针;因此这里先锁住身份行, - // 后续成员关系结束和 current_membership 清空能在同事务内完成。 + // 如果目标用户同时是其他 active Agency owner,协议要求 owner 自动拥有 Host 身份; + // 这种身份不能因为另一家 Agency 移除普通成员关系而被误停用。 + _, ownsActiveAgency, err := queryActiveAgencyByOwner(ctx, tx, command.HostUserID, true) + if err != nil { + return hostdomain.KickAgencyHostResult{}, err + } + // host_profile 是 Host 身份事实;踢出时必须和 membership 同锁同事务更新, + // 否则我的页、Host Center 和送礼结算会在短时间内看到互相矛盾的身份。 hostProfile, err := queryHostProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.HostUserID) if err != nil { return hostdomain.KickAgencyHostResult{}, err @@ -273,12 +279,27 @@ func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.Kic if err := endAgencyMembership(ctx, tx, membership); err != nil { return hostdomain.KickAgencyHostResult{}, err } - if hostProfile.CurrentMembershipID == membership.MembershipID { - // 只在身份行当前指向这条成员关系时清空,避免误伤未来可能出现的重新入会关系。 + hostProfileStatusChanged := false + if ownsActiveAgency { + // Agency owner 本身仍是 Host;这里只摘掉被结束的普通成员归属, + // 不把 owner 的 Host 入口关掉,避免破坏“owner 自动拥有 Host 身份”的协议边界。 + if hostProfile.CurrentMembershipID == membership.MembershipID { + hostProfile.CurrentAgencyID = 0 + hostProfile.CurrentMembershipID = 0 + hostProfile.UpdatedAtMs = command.NowMs + if err := updateHostCurrentMembership(ctx, tx, hostProfile); err != nil { + return hostdomain.KickAgencyHostResult{}, err + } + } + } else { + // 普通成员被 Agency 移除后不再拥有 Host 身份;保留历史行但状态置 disabled, + // 后续重新申请或被邀请时会复用该行并重新激活。 + hostProfileStatusChanged = hostProfile.Status != hostdomain.HostStatusDisabled + hostProfile.Status = hostdomain.HostStatusDisabled hostProfile.CurrentAgencyID = 0 hostProfile.CurrentMembershipID = 0 hostProfile.UpdatedAtMs = command.NowMs - if err := updateHostCurrentMembership(ctx, tx, hostProfile); err != nil { + if err := disableHostProfileAfterAgencyKick(ctx, tx, hostProfile); err != nil { return hostdomain.KickAgencyHostResult{}, err } } @@ -294,6 +315,11 @@ func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.Kic if err := insertHostOutbox(ctx, tx, command.EventID, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil { return hostdomain.KickAgencyHostResult{}, err } + if hostProfileStatusChanged { + if err := insertHostOutbox(ctx, tx, command.EventID+1, "HostStatusChanged", "host_profile", hostProfile.UserID, command.NowMs); err != nil { + return hostdomain.KickAgencyHostResult{}, err + } + } if err := tx.Commit(); err != nil { return hostdomain.KickAgencyHostResult{}, err } diff --git a/services/user-service/internal/storage/mysql/host/writes.go b/services/user-service/internal/storage/mysql/host/writes.go index d3a73d7f..7449f78d 100644 --- a/services/user-service/internal/storage/mysql/host/writes.go +++ b/services/user-service/internal/storage/mysql/host/writes.go @@ -157,6 +157,17 @@ func updateHostCurrentMembership(ctx context.Context, tx *sql.Tx, profile hostdo return err } +func disableHostProfileAfterAgencyKick(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error { + // Agency 移除普通 Host 的产品语义是收回主播身份;历史行保留给审计和后续重新开通复用。 + // 当前归属必须同步清空,否则 App 入口和送礼结算会继续把 ended membership 当作有效关系。 + _, err := tx.ExecContext(ctx, ` + UPDATE host_profiles + SET status = ?, current_agency_id = NULL, current_membership_id = NULL, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? + `, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) + return err +} + func updateHostProfileForMembership(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error { // 恢复 disabled Host 或修复历史指针时,需要同时写 status 和当前归属; // source/first_became_host_at_ms 仍然保留首次成为 Host 的历史语义。 diff --git a/services/user-service/internal/transport/grpc/host.go b/services/user-service/internal/transport/grpc/host.go index d87e266b..7fe060c1 100644 --- a/services/user-service/internal/transport/grpc/host.go +++ b/services/user-service/internal/transport/grpc/host.go @@ -74,7 +74,7 @@ func (s *Server) ReviewAgencyApplication(ctx context.Context, req *userv1.Review }, nil } -// KickAgencyHost 结束普通 Host 的 Agency 归属。 +// KickAgencyHost 结束普通 Host 的 Agency 归属,并按领域规则返回移除后的 Host 身份状态。 func (s *Server) KickAgencyHost(ctx context.Context, req *userv1.KickAgencyHostRequest) (*userv1.KickAgencyHostResponse, error) { if s.hostSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))