package host import ( "context" "database/sql" "fmt" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" hostdomain "hyapp/services/user-service/internal/domain/host" hostservice "hyapp/services/user-service/internal/service/host" ) // SearchAgencies 返回用户当前区域内可加入的有效 Agency。 func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.SearchAgenciesCommand) ([]hostdomain.Agency, error) { regionID, err := r.userRegion(ctx, r.db, command.UserID, "") if err != nil { return nil, err } if regionID <= 0 { // 区域是 Agency 搜索和申请的硬边界,缺失时不能退化成全局搜索。 return nil, xerr.New(xerr.InvalidArgument, "user region is required") } query := fmt.Sprintf(` SELECT %s FROM agencies WHERE app_code = ? AND region_id = ? AND status = ? AND join_enabled = TRUE `, agencyColumns) args := []any{appcode.FromContext(ctx), regionID, hostdomain.AgencyStatusActive} if strings.TrimSpace(command.Keyword) != "" { query += " AND name LIKE ?" args = append(args, "%"+strings.TrimSpace(command.Keyword)+"%") } query += " ORDER BY created_at_ms DESC, agency_id DESC LIMIT ?" args = append(args, command.PageSize) rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() agencies := make([]hostdomain.Agency, 0, command.PageSize) for rows.Next() { agency, err := scanAgency(rows) if err != nil { return nil, err } agencies = append(agencies, agency) } return agencies, rows.Err() } // GetHostProfile 读取 Host 身份事实。 func (r *Repository) GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, error) { return queryHostProfile(ctx, r.db, "WHERE user_id = ?", userID) } // GetBDProfile 读取 BD 身份事实。 func (r *Repository) GetBDProfile(ctx context.Context, userID int64) (hostdomain.BDProfile, error) { return queryBDProfile(ctx, r.db, "WHERE user_id = ?", userID) } // GetCoinSellerProfile 读取币商身份事实。 func (r *Repository) GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error) { return queryCoinSellerProfile(ctx, r.db, "WHERE user_id = ?", userID) } // GetUserRoleSummary 用一组索引点查构造我的页角色摘要。 // 摘要只表达入口显隐需要的当前身份,不加载成员列表、团队统计或薪资数据。 func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) { app := appcode.FromContext(ctx) summary := hostdomain.UserRoleSummary{UserID: userID} var hostStatus string var hostSource string var hostAgencyID int64 var ownerAgencyID int64 var bdUserID int64 var bdRole string var bdStatus string var coinSellerStatus string var merchantAssetType string err := r.db.QueryRowContext(ctx, ` SELECT COALESCE(h.status, ''), COALESCE(h.source, ''), COALESCE(h.current_agency_id, 0), COALESCE(a.agency_id, 0), COALESCE(b.user_id, 0), COALESCE(b.role, ''), COALESCE(b.status, ''), COALESCE(c.status, ''), COALESCE(c.merchant_asset_type, '') FROM (SELECT ? AS user_id) u LEFT JOIN host_profiles h ON h.app_code = ? AND h.user_id = u.user_id LEFT JOIN agencies a ON a.app_code = ? AND a.owner_user_id = u.user_id AND a.status = ? LEFT JOIN bd_profiles b ON b.app_code = ? AND b.user_id = u.user_id LEFT JOIN coin_seller_profiles c ON c.app_code = ? AND c.user_id = u.user_id`, userID, app, app, hostdomain.AgencyStatusActive, app, app, ).Scan(&hostStatus, &hostSource, &hostAgencyID, &ownerAgencyID, &bdUserID, &bdRole, &bdStatus, &coinSellerStatus, &merchantAssetType) if err != nil { return hostdomain.UserRoleSummary{}, err } summary.HostStatus = hostStatus summary.IsHost = hostStatus == hostdomain.HostStatusActive if ownerAgencyID > 0 { summary.AgencyID = ownerAgencyID // 经理中心首版授权边界是 active Agency owner;普通 Agency 成员只展示 Agency 入口,不具备 manager 身份。 summary.IsManager = true } else { summary.AgencyID = hostAgencyID } summary.IsAgency = ownerAgencyID > 0 || (summary.IsHost && (hostSource == hostdomain.HostSourceAgencyInvitation || hostSource == hostdomain.HostSourceAdminCreateAgency)) summary.BDStatus = bdStatus if bdUserID > 0 && bdStatus == hostdomain.BDStatusActive { summary.BDID = bdUserID switch bdRole { case hostdomain.BDRoleLeader: summary.IsBD = true summary.IsBDLeader = true case hostdomain.BDRoleBD: summary.IsBD = true } } summary.CoinSellerStatus = coinSellerStatus summary.IsCoinSeller = coinSellerStatus == hostdomain.CoinSellerStatusActive && merchantAssetType == hostdomain.CoinSellerMerchantAssetType if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM role_invitations WHERE app_code = ? AND target_user_id = ? AND status = ?`, app, userID, hostdomain.InvitationStatusPending, ).Scan(&summary.PendingRoleInvitations); err != nil { return hostdomain.UserRoleSummary{}, err } return summary, nil } // HasActiveAgencyOwner 精确判断用户是否为 active Agency owner。 // 普通 Agency 成员会在 UserRoleSummary 中有 Agency 入口,但不能获得经理中心资源赠送权限。 func (r *Repository) HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error) { agency, ok, err := queryActiveAgencyByOwner(ctx, r.db, userID, false) if err != nil || !ok { return 0, ok, err } return agency.AgencyID, true, nil } // ListAgencyMembers 读取 Agency 成员关系列表。 func (r *Repository) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) { query := fmt.Sprintf(`SELECT %s FROM agency_memberships WHERE app_code = ? AND agency_id = ?`, agencyMembershipColumns) args := []any{appcode.FromContext(ctx), agencyID} if status != "" { query += " AND status = ?" args = append(args, status) } query += " ORDER BY joined_at_ms DESC, membership_id DESC" return queryAgencyMemberships(ctx, r.db, query, args...) } // ListAgencyApplications 读取 Agency 申请列表。 func (r *Repository) ListAgencyApplications(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyApplication, error) { query := fmt.Sprintf(`SELECT %s FROM agency_applications WHERE app_code = ? AND agency_id = ?`, agencyApplicationColumns) args := []any{appcode.FromContext(ctx), agencyID} if status != "" { query += " AND status = ?" args = append(args, status) } query += " ORDER BY created_at_ms DESC, application_id DESC" return queryAgencyApplications(ctx, r.db, query, args...) } func queryHostProfile(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.HostProfile, error) { clause, args = appScopedClause(ctx, clause, args...) profile, err := scanHostProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM host_profiles %s`, hostProfileColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.HostProfile{}, xerr.New(xerr.NotFound, "host profile not found") } return profile, err } func queryHostProfileMaybe(ctx context.Context, q sqlQueryer, userID int64, lock bool) (hostdomain.HostProfile, bool, error) { clause := "WHERE user_id = ? AND status = ?" if lock { clause += " FOR UPDATE" } clause, args := appScopedClause(ctx, clause, userID, hostdomain.HostStatusActive) profile, err := scanHostProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM host_profiles %s`, hostProfileColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.HostProfile{}, false, nil } if err != nil { return hostdomain.HostProfile{}, false, err } return profile, true, nil } func queryHostProfileByUserMaybe(ctx context.Context, q sqlQueryer, userID int64, lock bool) (hostdomain.HostProfile, bool, error) { clause := "WHERE user_id = ?" if lock { // 后台创建 Agency owner 时需要覆盖 active/disabled 任意主播身份行,避免重复插入主键。 clause += " FOR UPDATE" } clause, args := appScopedClause(ctx, clause, userID) profile, err := scanHostProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM host_profiles %s`, hostProfileColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.HostProfile{}, false, nil } if err != nil { return hostdomain.HostProfile{}, false, err } return profile, true, nil } func scanHostProfile(scanner rowScanner) (hostdomain.HostProfile, error) { var profile hostdomain.HostProfile err := scanner.Scan( &profile.UserID, &profile.Status, &profile.RegionID, &profile.CurrentAgencyID, &profile.CurrentMembershipID, &profile.Source, &profile.FirstBecameHostAtMs, &profile.CreatedAtMs, &profile.UpdatedAtMs, ) return profile, err } func queryAgency(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.Agency, error) { clause, args = appScopedClause(ctx, clause, args...) agency, err := scanAgency(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM agencies %s`, agencyColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.Agency{}, xerr.New(xerr.NotFound, "agency not found") } return agency, err } func queryActiveAgencyByOwner(ctx context.Context, q sqlQueryer, ownerUserID int64, lock bool) (hostdomain.Agency, bool, error) { clause := "WHERE owner_user_id = ? AND status = ?" if lock { clause += " FOR UPDATE" } clause, args := appScopedClause(ctx, clause, ownerUserID, hostdomain.AgencyStatusActive) agency, err := scanAgency(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM agencies %s`, agencyColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.Agency{}, false, nil } if err != nil { return hostdomain.Agency{}, false, err } return agency, true, nil } func queryAgencyByOwnerMaybe(ctx context.Context, q sqlQueryer, ownerUserID int64, lock bool) (hostdomain.Agency, bool, error) { clause := "WHERE owner_user_id = ?" if lock { // 后台创建 Agency 时要锁住 owner 名下所有历史 Agency 行,避免 active/closed 切换和新建并发。 clause += " FOR UPDATE" } clause, args := appScopedClause(ctx, clause, ownerUserID) agency, err := scanAgency(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM agencies %s`, agencyColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.Agency{}, false, nil } if err != nil { return hostdomain.Agency{}, false, err } return agency, true, nil } func scanAgency(scanner rowScanner) (hostdomain.Agency, error) { var agency hostdomain.Agency err := scanner.Scan( &agency.AgencyID, &agency.OwnerUserID, &agency.RegionID, &agency.ParentBDUserID, &agency.Name, &agency.Status, &agency.JoinEnabled, &agency.MaxHosts, &agency.CreatedByUserID, &agency.CreatedAtMs, &agency.UpdatedAtMs, ) return agency, err } func queryBDProfile(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.BDProfile, error) { clause, args = appScopedClause(ctx, clause, args...) profile, err := scanBDProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM bd_profiles %s`, bdProfileColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.BDProfile{}, xerr.New(xerr.NotFound, "bd profile not found") } return profile, err } func queryBDProfileMaybe(ctx context.Context, q sqlQueryer, userID int64, lock bool) (hostdomain.BDProfile, bool, error) { clause := "WHERE user_id = ? AND status = ?" if lock { clause += " FOR UPDATE" } clause, args := appScopedClause(ctx, clause, userID, hostdomain.BDStatusActive) profile, err := scanBDProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM bd_profiles %s`, bdProfileColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.BDProfile{}, false, nil } if err != nil { return hostdomain.BDProfile{}, false, err } return profile, true, nil } func queryBDProfileByUserMaybe(ctx context.Context, q sqlQueryer, userID int64, lock bool) (hostdomain.BDProfile, bool, error) { clause := "WHERE user_id = ?" if lock { // 后台创建/停用角色使用主键锁,覆盖 active 和 disabled 两种状态。 clause += " FOR UPDATE" } clause, args := appScopedClause(ctx, clause, userID) profile, err := scanBDProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM bd_profiles %s`, bdProfileColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.BDProfile{}, false, nil } if err != nil { return hostdomain.BDProfile{}, false, err } return profile, true, nil } func scanBDProfile(scanner rowScanner) (hostdomain.BDProfile, error) { var profile hostdomain.BDProfile err := scanner.Scan( &profile.UserID, &profile.Role, &profile.RegionID, &profile.ParentLeaderUserID, &profile.Status, &profile.CreatedByUserID, &profile.CreatedAtMs, &profile.UpdatedAtMs, ) return profile, err } func queryCoinSellerProfile(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.CoinSellerProfile, error) { clause, args = appScopedClause(ctx, clause, args...) profile, err := scanCoinSellerProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM coin_seller_profiles %s`, coinSellerProfileColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.CoinSellerProfile{}, xerr.New(xerr.NotFound, "coin_seller profile not found") } return profile, err } func queryCoinSellerProfileByUserMaybe(ctx context.Context, q sqlQueryer, userID int64, lock bool) (hostdomain.CoinSellerProfile, bool, error) { clause := "WHERE user_id = ?" if lock { // 币商身份可以和 Agency/BD/BD Leader 并存,但同一用户只能有一条币商身份事实。 clause += " FOR UPDATE" } clause, args := appScopedClause(ctx, clause, userID) profile, err := scanCoinSellerProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM coin_seller_profiles %s`, coinSellerProfileColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.CoinSellerProfile{}, false, nil } if err != nil { return hostdomain.CoinSellerProfile{}, false, err } return profile, true, nil } func scanCoinSellerProfile(scanner rowScanner) (hostdomain.CoinSellerProfile, error) { var profile hostdomain.CoinSellerProfile err := scanner.Scan( &profile.UserID, &profile.Status, &profile.MerchantAssetType, &profile.CreatedByUserID, &profile.CreatedAtMs, &profile.UpdatedAtMs, ) return profile, err } func queryAgencyMembership(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.AgencyMembership, error) { clause, args = appScopedClause(ctx, clause, args...) membership, err := scanAgencyMembership(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM agency_memberships %s`, agencyMembershipColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.AgencyMembership{}, xerr.New(xerr.NotFound, "agency membership not found") } return membership, err } func queryActiveMembershipByHost(ctx context.Context, q sqlQueryer, hostUserID int64, lock bool) (hostdomain.AgencyMembership, bool, error) { clause := "WHERE host_user_id = ? AND status = ?" if lock { clause += " FOR UPDATE" } clause, args := appScopedClause(ctx, clause, hostUserID, hostdomain.MembershipStatusActive) membership, err := scanAgencyMembership(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM agency_memberships %s`, agencyMembershipColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.AgencyMembership{}, false, nil } if err != nil { return hostdomain.AgencyMembership{}, false, err } return membership, true, nil } func queryAgencyMemberships(ctx context.Context, q sqlQueryer, query string, args ...any) ([]hostdomain.AgencyMembership, error) { rows, err := q.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() memberships := make([]hostdomain.AgencyMembership, 0) for rows.Next() { membership, err := scanAgencyMembership(rows) if err != nil { return nil, err } memberships = append(memberships, membership) } return memberships, rows.Err() } func scanAgencyMembership(scanner rowScanner) (hostdomain.AgencyMembership, error) { var membership hostdomain.AgencyMembership err := scanner.Scan( &membership.MembershipID, &membership.AgencyID, &membership.HostUserID, &membership.RegionID, &membership.MembershipType, &membership.Status, &membership.JoinedAtMs, &membership.EndedAtMs, &membership.EndedByUserID, &membership.EndedReason, &membership.CreatedAtMs, &membership.UpdatedAtMs, ) return membership, err } func queryAgencyApplication(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.AgencyApplication, error) { clause, args = appScopedClause(ctx, clause, args...) application, err := scanAgencyApplication(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM agency_applications %s`, agencyApplicationColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.AgencyApplication{}, xerr.New(xerr.NotFound, "agency application not found") } return application, err } func queryAgencyApplications(ctx context.Context, q sqlQueryer, query string, args ...any) ([]hostdomain.AgencyApplication, error) { rows, err := q.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() applications := make([]hostdomain.AgencyApplication, 0) for rows.Next() { application, err := scanAgencyApplication(rows) if err != nil { return nil, err } applications = append(applications, application) } return applications, rows.Err() } func scanAgencyApplication(scanner rowScanner) (hostdomain.AgencyApplication, error) { var application hostdomain.AgencyApplication err := scanner.Scan( &application.ApplicationID, &application.CommandID, &application.ApplicantUserID, &application.AgencyID, &application.RegionID, &application.Status, &application.ReviewedByUserID, &application.ReviewReason, &application.ReviewedAtMs, &application.CreatedAtMs, &application.UpdatedAtMs, ) return application, err } func queryRoleInvitation(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.RoleInvitation, error) { clause, args = appScopedClause(ctx, clause, args...) invitation, err := scanRoleInvitation(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM role_invitations %s`, roleInvitationColumns, clause), args...)) if err == sql.ErrNoRows { return hostdomain.RoleInvitation{}, xerr.New(xerr.NotFound, "role invitation not found") } return invitation, err } func scanRoleInvitation(scanner rowScanner) (hostdomain.RoleInvitation, error) { var invitation hostdomain.RoleInvitation err := scanner.Scan( &invitation.InvitationID, &invitation.CommandID, &invitation.InvitationType, &invitation.Status, &invitation.InviterUserID, &invitation.InviterBDUserID, &invitation.TargetUserID, &invitation.RegionID, &invitation.AgencyName, &invitation.ParentBDUserID, &invitation.ParentLeaderUserID, &invitation.ProcessedByUserID, &invitation.ProcessReason, &invitation.ProcessedAtMs, &invitation.CreatedAtMs, &invitation.UpdatedAtMs, ) return invitation, err } func reviewResultByApplicationID(ctx context.Context, q sqlQueryer, applicationID int64) (hostdomain.ReviewAgencyApplicationResult, error) { application, err := queryAgencyApplication(ctx, q, "WHERE application_id = ?", applicationID) if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } result := hostdomain.ReviewAgencyApplicationResult{Application: application} if application.Status == hostdomain.ApplicationStatusApproved { profile, err := queryHostProfile(ctx, q, "WHERE user_id = ?", application.ApplicantUserID) if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } membership, err := queryAgencyMembership(ctx, q, "WHERE membership_id = ?", profile.CurrentMembershipID) if err != nil { return hostdomain.ReviewAgencyApplicationResult{}, err } result.HostProfile = profile result.Membership = membership } return result, nil } func kickResultByMembershipID(ctx context.Context, q sqlQueryer, membershipID int64) (hostdomain.KickAgencyHostResult, error) { membership, err := queryAgencyMembership(ctx, q, "WHERE membership_id = ?", membershipID) if err != nil { return hostdomain.KickAgencyHostResult{}, err } profile, err := queryHostProfile(ctx, q, "WHERE user_id = ?", membership.HostUserID) if err != nil { return hostdomain.KickAgencyHostResult{}, err } return hostdomain.KickAgencyHostResult{Membership: membership, HostProfile: profile}, nil } func processResultByInvitationID(ctx context.Context, q sqlQueryer, invitationID int64) (hostdomain.ProcessRoleInvitationResult, error) { invitation, err := queryRoleInvitation(ctx, q, "WHERE invitation_id = ?", invitationID) if err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } result := hostdomain.ProcessRoleInvitationResult{Invitation: invitation} if invitation.Status != hostdomain.InvitationStatusAccepted { return result, nil } if invitation.InvitationType == hostdomain.InvitationTypeAgency { if profile, err := queryHostProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil { result.HostProfile = profile if profile.CurrentMembershipID > 0 { result.Membership, _ = queryAgencyMembership(ctx, q, "WHERE membership_id = ?", profile.CurrentMembershipID) } } if agency, ok, err := queryActiveAgencyByOwner(ctx, q, invitation.TargetUserID, false); err != nil { return hostdomain.ProcessRoleInvitationResult{}, err } else if ok { result.Agency = agency } } else if invitation.InvitationType == hostdomain.InvitationTypeBD { if bd, err := queryBDProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil { result.BDProfile = bd } } return result, nil }