fix: disable host identity after agency removal

This commit is contained in:
zhx 2026-07-01 16:27:19 +08:00
parent d7570d78c0
commit 7d8ef54ca7
6 changed files with 160 additions and 10 deletions

View File

@ -4,7 +4,7 @@ package host
const (
// HostStatusActive 表示用户已经拥有长期主播身份。
HostStatusActive = "active"
// HostStatusDisabled 预留给后台停用主播身份App Phase 1 不主动产生
// HostStatusDisabled 表示主播身份已停用Agency owner 移除普通 Host 后也进入该状态
HostStatusDisabled = "disabled"
// HostSourceApplication 表示主播身份来自 Agency 申请审核通过。

View File

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

View File

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

View File

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

View File

@ -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 的历史语义。

View File

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