fix: disable host identity after agency removal
This commit is contained in:
parent
d7570d78c0
commit
7d8ef54ca7
@ -4,7 +4,7 @@ package host
|
||||
const (
|
||||
// HostStatusActive 表示用户已经拥有长期主播身份。
|
||||
HostStatusActive = "active"
|
||||
// HostStatusDisabled 预留给后台停用主播身份,App Phase 1 不主动产生。
|
||||
// HostStatusDisabled 表示主播身份已停用;Agency owner 移除普通 Host 后也进入该状态。
|
||||
HostStatusDisabled = "disabled"
|
||||
|
||||
// HostSourceApplication 表示主播身份来自 Agency 申请审核通过。
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 的历史语义。
|
||||
|
||||
@ -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"))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user