674 lines
28 KiB
Go
674 lines
28 KiB
Go
package host
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||
hostservice "hyapp/services/user-service/internal/service/host"
|
||
)
|
||
|
||
// ExternalInviteBD 创建“外管操作者 -> BD”的待确认邀请。
|
||
// Portal 身份不属于 user-service,但操作者和目标用户的 active/region 事实必须在本事务内锁定重验。
|
||
func (r *Repository) ExternalInviteBD(ctx context.Context, command hostservice.ExternalInviteBDCommand) (hostdomain.RoleInvitation, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if replayed, ok, err := replayExternalInvitation(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteBD); err != nil || ok {
|
||
return replayed, err
|
||
}
|
||
if err := requireExternalOperatorAndTargetRegion(ctx, tx, command.OperatorUserID, command.TargetUserID, command.ExpectedRegionID); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if _, ok, err := queryBDProfileMaybe(ctx, tx, command.TargetUserID, true); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
} else if ok {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has active bd profile")
|
||
}
|
||
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeExternalBD, command.TargetUserID, true); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
} else if ok {
|
||
if pending.ExternalOperatorUserID != command.OperatorUserID || pending.ParentOwnerUserID != command.OperatorUserID {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has pending bd invitation")
|
||
}
|
||
if err := bindExternalCommandReplay(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteBD, pending.InvitationID, command.NowMs); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
return pending, nil
|
||
}
|
||
|
||
invitation := hostdomain.RoleInvitation{
|
||
InvitationID: command.InvitationID,
|
||
CommandID: command.CommandID,
|
||
InvitationType: hostdomain.InvitationTypeExternalBD,
|
||
Status: hostdomain.InvitationStatusPending,
|
||
InviterUserID: command.OperatorUserID,
|
||
TargetUserID: command.TargetUserID,
|
||
ExternalOperatorUserID: command.OperatorUserID,
|
||
RegionID: command.ExpectedRegionID,
|
||
ParentOwnerUserID: command.OperatorUserID,
|
||
CreatedAtMs: command.NowMs,
|
||
UpdatedAtMs: command.NowMs,
|
||
}
|
||
if err := persistExternalInvitation(ctx, tx, invitation, hostdomain.CommandTypeExternalInviteBD, command.NowMs); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
return invitation, nil
|
||
}
|
||
|
||
// ExternalInviteAgency 为 scope 内明确选择的 BD 创建 Agency 邀请。
|
||
// BD 的 parent_owner_user_id 是授权索引;不能用 created_by_user_id 或 parent_leader_user_id 猜外管归属。
|
||
func (r *Repository) ExternalInviteAgency(ctx context.Context, command hostservice.ExternalInviteAgencyCommand) (hostdomain.RoleInvitation, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if replayed, ok, err := replayExternalInvitation(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteAgency); err != nil || ok {
|
||
return replayed, err
|
||
}
|
||
if err := requireExternalOperatorAndTargetRegion(ctx, tx, command.OperatorUserID, command.TargetUserID, command.ExpectedRegionID); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
bd, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentBDUserID)
|
||
if err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if bd.Status != hostdomain.BDStatusActive || bd.Role != hostdomain.BDRoleBD || bd.RegionID != command.ExpectedRegionID ||
|
||
bd.ParentLeaderUserID != 0 || bd.ParentOwnerUserID <= 0 ||
|
||
!containsExternalScopeOwner(command.ScopeOwnerUserIDs, bd.ParentOwnerUserID) {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "parent bd is outside external team scope")
|
||
}
|
||
if _, err := requireExternalScopeOwnerRegion(ctx, tx, bd.ParentOwnerUserID, command.ExpectedRegionID); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.TargetUserID, true); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
} else if ok {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already owns active agency")
|
||
}
|
||
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeExternalAgency, command.TargetUserID, true); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
} else if ok {
|
||
if pending.ExternalOperatorUserID != command.OperatorUserID || pending.ParentBDUserID != bd.UserID || pending.ParentOwnerUserID != bd.ParentOwnerUserID {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has pending agency invitation")
|
||
}
|
||
if err := bindExternalCommandReplay(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteAgency, pending.InvitationID, command.NowMs); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
return pending, nil
|
||
}
|
||
|
||
invitation := hostdomain.RoleInvitation{
|
||
InvitationID: command.InvitationID,
|
||
CommandID: command.CommandID,
|
||
InvitationType: hostdomain.InvitationTypeExternalAgency,
|
||
Status: hostdomain.InvitationStatusPending,
|
||
InviterUserID: command.OperatorUserID,
|
||
InviterBDUserID: bd.UserID,
|
||
TargetUserID: command.TargetUserID,
|
||
ExternalOperatorUserID: command.OperatorUserID,
|
||
RegionID: command.ExpectedRegionID,
|
||
AgencyName: command.AgencyName,
|
||
ParentBDUserID: bd.UserID,
|
||
ParentLeaderUserID: bd.ParentLeaderUserID,
|
||
ParentOwnerUserID: bd.ParentOwnerUserID,
|
||
CreatedAtMs: command.NowMs,
|
||
UpdatedAtMs: command.NowMs,
|
||
}
|
||
if err := persistExternalInvitation(ctx, tx, invitation, hostdomain.CommandTypeExternalInviteAgency, command.NowMs); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
return invitation, nil
|
||
}
|
||
|
||
// ExternalInviteHost 为 scope 内明确选择的 Agency 创建 Host 邀请。
|
||
func (r *Repository) ExternalInviteHost(ctx context.Context, command hostservice.ExternalInviteHostCommand) (hostdomain.RoleInvitation, error) {
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
if replayed, ok, err := replayExternalInvitation(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteHost); err != nil || ok {
|
||
return replayed, err
|
||
}
|
||
if err := requireExternalOperatorAndTargetRegion(ctx, tx, command.OperatorUserID, command.TargetUserID, command.ExpectedRegionID); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", command.AgencyID)
|
||
if err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if agency.Status != hostdomain.AgencyStatusActive || !agency.JoinEnabled || agency.RegionID != command.ExpectedRegionID {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "agency is not active in external region")
|
||
}
|
||
bd, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", agency.ParentBDUserID)
|
||
if err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if bd.Status != hostdomain.BDStatusActive || bd.ParentLeaderUserID != 0 || bd.ParentOwnerUserID <= 0 ||
|
||
!containsExternalScopeOwner(command.ScopeOwnerUserIDs, bd.ParentOwnerUserID) {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "agency is outside external team scope")
|
||
}
|
||
if _, err := requireExternalScopeOwnerRegion(ctx, tx, bd.ParentOwnerUserID, command.ExpectedRegionID); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.TargetUserID, true); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
} else if ok {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has active agency membership")
|
||
}
|
||
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.TargetUserID, true); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
} else if ok {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already owns active agency")
|
||
}
|
||
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeExternalHost, command.TargetUserID, true); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
} else if ok {
|
||
if pending.ExternalOperatorUserID != command.OperatorUserID || pending.ParentAgencyID != agency.AgencyID || pending.ParentOwnerUserID != bd.ParentOwnerUserID {
|
||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has pending host invitation")
|
||
}
|
||
if err := bindExternalCommandReplay(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteHost, pending.InvitationID, command.NowMs); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
return pending, nil
|
||
}
|
||
|
||
invitation := hostdomain.RoleInvitation{
|
||
InvitationID: command.InvitationID,
|
||
CommandID: command.CommandID,
|
||
InvitationType: hostdomain.InvitationTypeExternalHost,
|
||
Status: hostdomain.InvitationStatusPending,
|
||
InviterUserID: command.OperatorUserID,
|
||
InviterBDUserID: bd.UserID,
|
||
TargetUserID: command.TargetUserID,
|
||
ExternalOperatorUserID: command.OperatorUserID,
|
||
RegionID: command.ExpectedRegionID,
|
||
AgencyName: agency.Name,
|
||
ParentBDUserID: bd.UserID,
|
||
ParentLeaderUserID: bd.ParentLeaderUserID,
|
||
ParentOwnerUserID: bd.ParentOwnerUserID,
|
||
ParentAgencyID: agency.AgencyID,
|
||
CreatedAtMs: command.NowMs,
|
||
UpdatedAtMs: command.NowMs,
|
||
}
|
||
if err := persistExternalInvitation(ctx, tx, invitation, hostdomain.CommandTypeExternalInviteHost, command.NowMs); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return hostdomain.RoleInvitation{}, err
|
||
}
|
||
return invitation, nil
|
||
}
|
||
|
||
func replayExternalInvitation(ctx context.Context, tx *sql.Tx, commandID string, commandType string) (hostdomain.RoleInvitation, bool, error) {
|
||
existing, ok, err := commandResult(ctx, tx, commandID)
|
||
if err != nil || !ok {
|
||
return hostdomain.RoleInvitation{}, false, err
|
||
}
|
||
if err := requireCommandResult(existing, commandType, hostdomain.ResultTypeInvitation); err != nil {
|
||
return hostdomain.RoleInvitation{}, true, err
|
||
}
|
||
invitation, err := queryRoleInvitation(ctx, tx, "WHERE invitation_id = ?", existing.ResultID)
|
||
return invitation, true, err
|
||
}
|
||
|
||
func bindExternalCommandReplay(ctx context.Context, tx *sql.Tx, commandID string, commandType string, invitationID int64, nowMs int64) error {
|
||
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
|
||
CommandID: commandID,
|
||
CommandType: commandType,
|
||
ResultType: hostdomain.ResultTypeInvitation,
|
||
ResultID: invitationID,
|
||
CreatedAtMs: nowMs,
|
||
}); err != nil {
|
||
return mapHostDuplicateError(err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func persistExternalInvitation(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, commandType string, nowMs int64) error {
|
||
if err := insertExternalRoleInvitation(ctx, tx, invitation); err != nil {
|
||
return mapHostDuplicateError(err)
|
||
}
|
||
if err := bindExternalCommandReplay(ctx, tx, invitation.CommandID, commandType, invitation.InvitationID, nowMs); err != nil {
|
||
return err
|
||
}
|
||
return insertRoleInvitationCreatedOutbox(ctx, tx, invitation, nowMs)
|
||
}
|
||
|
||
func requireExternalOperatorAndTargetRegion(ctx context.Context, tx *sql.Tx, operatorUserID int64, targetUserID int64, expectedRegionID int64) error {
|
||
operatorRegionID, err := currentUserRegion(ctx, tx, operatorUserID, "FOR UPDATE")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if operatorRegionID <= 0 || operatorRegionID != expectedRegionID {
|
||
return xerr.New(xerr.RegionMismatch, "external operator region has changed")
|
||
}
|
||
targetRegionID, err := currentUserRegion(ctx, tx, targetUserID, "FOR UPDATE")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if targetRegionID != expectedRegionID {
|
||
return xerr.New(xerr.RegionMismatch, "target region does not match external operator region")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func requireExternalScopeOwnerRegion(ctx context.Context, tx *sql.Tx, ownerUserID int64, expectedRegionID int64) (int64, error) {
|
||
regionID, err := currentUserRegion(ctx, tx, ownerUserID, "FOR UPDATE")
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if regionID != expectedRegionID {
|
||
return 0, xerr.New(xerr.RegionMismatch, "external team owner region has changed")
|
||
}
|
||
return regionID, nil
|
||
}
|
||
|
||
func containsExternalScopeOwner(ownerUserIDs []int64, ownerUserID int64) bool {
|
||
for _, candidate := range ownerUserIDs {
|
||
if candidate == ownerUserID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func validateExternalBDInvitationAcceptance(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, targetRegionID int64) error {
|
||
if invitation.InvitationType != hostdomain.InvitationTypeExternalBD ||
|
||
invitation.ExternalOperatorUserID <= 0 ||
|
||
invitation.InviterBDUserID != 0 ||
|
||
invitation.ParentLeaderUserID != 0 {
|
||
return xerr.New(xerr.InvalidArgument, "external bd invitation metadata is invalid")
|
||
}
|
||
operatorRegionID, err := currentUserRegion(ctx, tx, invitation.ExternalOperatorUserID, "FOR UPDATE")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if invitation.ParentOwnerUserID != invitation.ExternalOperatorUserID ||
|
||
invitation.InviterUserID != invitation.ExternalOperatorUserID ||
|
||
invitation.RegionID <= 0 ||
|
||
operatorRegionID != invitation.RegionID ||
|
||
targetRegionID != invitation.RegionID {
|
||
return xerr.New(xerr.RegionMismatch, "external bd invitation scope has changed")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateExternalAgencyInvitationAcceptance(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, targetRegionID int64) error {
|
||
if invitation.InvitationType != hostdomain.InvitationTypeExternalAgency ||
|
||
invitation.ExternalOperatorUserID <= 0 ||
|
||
invitation.InviterUserID != invitation.ExternalOperatorUserID ||
|
||
invitation.InviterBDUserID != invitation.ParentBDUserID {
|
||
return xerr.New(xerr.InvalidArgument, "external agency invitation metadata is invalid")
|
||
}
|
||
operatorRegionID, err := currentUserRegion(ctx, tx, invitation.ExternalOperatorUserID, "FOR UPDATE")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if operatorRegionID != invitation.RegionID || targetRegionID != invitation.RegionID || invitation.ParentOwnerUserID <= 0 {
|
||
return xerr.New(xerr.RegionMismatch, "external agency invitation region has changed")
|
||
}
|
||
bd, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", invitation.ParentBDUserID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if bd.Status != hostdomain.BDStatusActive ||
|
||
bd.RegionID != invitation.RegionID ||
|
||
bd.ParentLeaderUserID != 0 ||
|
||
bd.ParentOwnerUserID != invitation.ParentOwnerUserID {
|
||
return xerr.New(xerr.Conflict, "external agency parent bd ownership has changed")
|
||
}
|
||
_, err = requireExternalScopeOwnerRegion(ctx, tx, bd.ParentOwnerUserID, invitation.RegionID)
|
||
return err
|
||
}
|
||
|
||
func validateExternalHostInvitationAcceptance(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, targetRegionID int64) (hostdomain.Agency, error) {
|
||
if invitation.InvitationType != hostdomain.InvitationTypeExternalHost ||
|
||
invitation.ExternalOperatorUserID <= 0 ||
|
||
invitation.InviterUserID != invitation.ExternalOperatorUserID ||
|
||
invitation.InviterBDUserID != invitation.ParentBDUserID {
|
||
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "external host invitation metadata is invalid")
|
||
}
|
||
operatorRegionID, err := currentUserRegion(ctx, tx, invitation.ExternalOperatorUserID, "FOR UPDATE")
|
||
if err != nil {
|
||
return hostdomain.Agency{}, err
|
||
}
|
||
if operatorRegionID != invitation.RegionID || targetRegionID != invitation.RegionID || invitation.ParentAgencyID <= 0 || invitation.ParentOwnerUserID <= 0 {
|
||
return hostdomain.Agency{}, xerr.New(xerr.RegionMismatch, "external host invitation region has changed")
|
||
}
|
||
agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", invitation.ParentAgencyID)
|
||
if err != nil {
|
||
return hostdomain.Agency{}, err
|
||
}
|
||
if agency.Status != hostdomain.AgencyStatusActive || !agency.JoinEnabled ||
|
||
agency.RegionID != invitation.RegionID || agency.ParentBDUserID != invitation.ParentBDUserID {
|
||
return hostdomain.Agency{}, xerr.New(xerr.Conflict, "external host parent agency has changed")
|
||
}
|
||
bd, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", invitation.ParentBDUserID)
|
||
if err != nil {
|
||
return hostdomain.Agency{}, err
|
||
}
|
||
if bd.Status != hostdomain.BDStatusActive || bd.ParentLeaderUserID != 0 ||
|
||
bd.ParentOwnerUserID != invitation.ParentOwnerUserID || bd.RegionID != invitation.RegionID {
|
||
return hostdomain.Agency{}, xerr.New(xerr.Conflict, "external host parent bd ownership has changed")
|
||
}
|
||
if _, err := requireExternalScopeOwnerRegion(ctx, tx, bd.ParentOwnerUserID, invitation.RegionID); err != nil {
|
||
return hostdomain.Agency{}, err
|
||
}
|
||
return agency, nil
|
||
}
|
||
|
||
// ListExternalTeamUsers 通过 parent_owner 索引先收敛 BD,再向下连接 Agency/Host。
|
||
// region/status 条件在 users 权威表上执行;scope 绝不允许为空。
|
||
func (r *Repository) ListExternalTeamUsers(ctx context.Context, command hostservice.ListExternalTeamUsersCommand) (hostdomain.ExternalTeamUsers, error) {
|
||
if len(command.ScopeOwnerUserIDs) == 0 || command.RegionID <= 0 {
|
||
return hostdomain.ExternalTeamUsers{}, xerr.New(xerr.PermissionDenied, "external team scope is empty")
|
||
}
|
||
cte, cteArgs := externalTeamCTE(ctx, command.ScopeOwnerUserIDs, command.RegionID)
|
||
filter, filterArgs := externalTeamFilter(command)
|
||
outerArgs := []any{appcode.FromContext(ctx), command.RegionID, command.Status}
|
||
outerArgs = append(outerArgs, filterArgs...)
|
||
countArgs := append(append([]any{}, cteArgs...), outerArgs...)
|
||
var total int64
|
||
if err := r.db.QueryRowContext(ctx, cte+`
|
||
SELECT COUNT(DISTINCT tr.user_id)
|
||
FROM team_relations tr
|
||
INNER JOIN users u ON u.app_code = ? AND u.user_id = tr.user_id
|
||
WHERE u.region_id = ? AND u.status = ?`+filter,
|
||
countArgs...,
|
||
).Scan(&total); err != nil {
|
||
return hostdomain.ExternalTeamUsers{}, err
|
||
}
|
||
|
||
offset := (command.Page - 1) * command.PageSize
|
||
listArgs := append(append([]any{}, cteArgs...), outerArgs...)
|
||
listArgs = append(listArgs, command.PageSize, offset)
|
||
rows, err := r.db.QueryContext(ctx, cte+`
|
||
SELECT
|
||
u.user_id,
|
||
COALESCE(u.default_display_user_id, ''),
|
||
COALESCE(u.current_display_user_id, ''),
|
||
COALESCE(u.username, ''),
|
||
COALESCE(u.avatar, ''),
|
||
u.status,
|
||
COALESCE(u.region_id, 0),
|
||
GROUP_CONCAT(DISTINCT tr.role ORDER BY FIELD(tr.role, 'bd', 'agency', 'host') SEPARATOR ','),
|
||
MAX(tr.parent_owner_user_id),
|
||
MAX(tr.parent_bd_user_id),
|
||
MAX(tr.agency_id)
|
||
FROM team_relations tr
|
||
INNER JOIN users u ON u.app_code = ? AND u.user_id = tr.user_id
|
||
WHERE u.region_id = ? AND u.status = ?`+filter+`
|
||
GROUP BY u.user_id, u.default_display_user_id, u.current_display_user_id, u.username, u.avatar, u.status, u.region_id
|
||
ORDER BY MAX(u.updated_at_ms) DESC, u.user_id DESC
|
||
LIMIT ? OFFSET ?`,
|
||
listArgs...,
|
||
)
|
||
if err != nil {
|
||
return hostdomain.ExternalTeamUsers{}, err
|
||
}
|
||
defer rows.Close()
|
||
users := make([]hostdomain.ExternalTeamUser, 0, command.PageSize)
|
||
for rows.Next() {
|
||
var user hostdomain.ExternalTeamUser
|
||
var roles string
|
||
if err := rows.Scan(
|
||
&user.UserID,
|
||
&user.DefaultDisplayUserID,
|
||
&user.CurrentDisplayUserID,
|
||
&user.Username,
|
||
&user.Avatar,
|
||
&user.Status,
|
||
&user.RegionID,
|
||
&roles,
|
||
&user.ParentOwnerUserID,
|
||
&user.ParentBDUserID,
|
||
&user.AgencyID,
|
||
); err != nil {
|
||
return hostdomain.ExternalTeamUsers{}, err
|
||
}
|
||
if roles != "" {
|
||
user.Roles = strings.Split(roles, ",")
|
||
}
|
||
users = append(users, user)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return hostdomain.ExternalTeamUsers{}, err
|
||
}
|
||
return hostdomain.ExternalTeamUsers{Users: users, Total: total, Page: command.Page, PageSize: command.PageSize}, nil
|
||
}
|
||
|
||
func externalTeamCTE(ctx context.Context, ownerUserIDs []int64, regionID int64) (string, []any) {
|
||
args := make([]any, 0, len(ownerUserIDs)+9)
|
||
args = append(args, regionID, regionID, appcode.FromContext(ctx))
|
||
for _, userID := range ownerUserIDs {
|
||
args = append(args, userID)
|
||
}
|
||
args = append(args,
|
||
hostdomain.BDStatusActive,
|
||
appcode.FromContext(ctx),
|
||
hostdomain.AgencyStatusActive,
|
||
appcode.FromContext(ctx),
|
||
hostdomain.MembershipStatusActive,
|
||
)
|
||
query := `
|
||
WITH scoped_bd AS (
|
||
SELECT b.user_id AS bd_user_id, b.parent_owner_user_id
|
||
FROM bd_profiles b
|
||
INNER JOIN users scope_owner
|
||
ON scope_owner.app_code = b.app_code AND scope_owner.user_id = b.parent_owner_user_id
|
||
AND scope_owner.status = 'active' AND scope_owner.region_id = ?
|
||
INNER JOIN users bd_user
|
||
ON bd_user.app_code = b.app_code AND bd_user.user_id = b.user_id
|
||
AND bd_user.region_id = ?
|
||
WHERE b.app_code = ?
|
||
AND b.parent_owner_user_id IN (` + placeholderList(len(ownerUserIDs)) + `)
|
||
AND b.parent_leader_user_id IS NULL
|
||
AND b.status = ?
|
||
),
|
||
scoped_agencies AS (
|
||
SELECT a.agency_id, a.owner_user_id, a.parent_bd_user_id, sb.parent_owner_user_id
|
||
FROM scoped_bd sb
|
||
INNER JOIN agencies a
|
||
ON a.app_code = ? AND a.parent_bd_user_id = sb.bd_user_id AND a.status = ?
|
||
),
|
||
team_relations AS (
|
||
SELECT bd_user_id AS user_id, 'bd' AS role, parent_owner_user_id, bd_user_id AS parent_bd_user_id, 0 AS agency_id
|
||
FROM scoped_bd
|
||
UNION ALL
|
||
SELECT owner_user_id, 'agency', parent_owner_user_id, parent_bd_user_id, agency_id
|
||
FROM scoped_agencies
|
||
UNION ALL
|
||
SELECT m.host_user_id, 'host', sa.parent_owner_user_id, sa.parent_bd_user_id, sa.agency_id
|
||
FROM scoped_agencies sa
|
||
INNER JOIN agency_memberships m
|
||
ON m.app_code = ? AND m.agency_id = sa.agency_id
|
||
AND m.status = ? AND m.membership_type = 'member'
|
||
)`
|
||
return query, args
|
||
}
|
||
|
||
func externalTeamFilter(command hostservice.ListExternalTeamUsersCommand) (string, []any) {
|
||
filter := ""
|
||
args := make([]any, 0, 5)
|
||
if command.Role != "" {
|
||
filter += " AND tr.role = ?"
|
||
args = append(args, command.Role)
|
||
}
|
||
if command.Keyword != "" {
|
||
filter += " AND (u.user_id = ? OR u.current_display_user_id = ? OR u.default_display_user_id = ? OR u.username LIKE ?)"
|
||
args = append(args, int64FromKeyword(command.Keyword), command.Keyword, command.Keyword, "%"+command.Keyword+"%")
|
||
}
|
||
return filter, args
|
||
}
|
||
|
||
// SearchExternalInvitationCandidate 只执行最多四个有唯一索引支撑的精确查询;
|
||
// 不对 username 做 LIKE,避免候选接口演变成区域用户目录。
|
||
func (r *Repository) SearchExternalInvitationCandidate(ctx context.Context, command hostservice.SearchExternalInvitationCandidateCommand) (hostdomain.ExternalTeamUser, error) {
|
||
operatorRegionID, err := currentUserRegion(ctx, r.db, command.OperatorUserID, "")
|
||
if err != nil {
|
||
return hostdomain.ExternalTeamUser{}, err
|
||
}
|
||
if operatorRegionID <= 0 || operatorRegionID != command.ExpectedRegionID {
|
||
return hostdomain.ExternalTeamUser{}, xerr.New(xerr.RegionMismatch, "external operator region has changed")
|
||
}
|
||
user, err := r.findExactExternalCandidate(ctx, command.ExpectedRegionID, command.Keyword, command.NowMs)
|
||
if err != nil {
|
||
return hostdomain.ExternalTeamUser{}, err
|
||
}
|
||
if err := r.requireCandidateRoleAvailable(ctx, command.InvitationType, user.UserID); err != nil {
|
||
return hostdomain.ExternalTeamUser{}, err
|
||
}
|
||
return user, nil
|
||
}
|
||
|
||
func (r *Repository) findExactExternalCandidate(ctx context.Context, regionID int64, keyword string, nowMs int64) (hostdomain.ExternalTeamUser, error) {
|
||
type lookup struct {
|
||
clause string
|
||
args []any
|
||
}
|
||
lookups := make([]lookup, 0, 3)
|
||
if userID, err := strconv.ParseInt(keyword, 10, 64); err == nil && userID > 0 {
|
||
lookups = append(lookups, lookup{clause: "user_id = ?", args: []any{userID}})
|
||
}
|
||
lookups = append(lookups,
|
||
lookup{
|
||
// users.current_display_user_id 是读取快照;过期 pretty 快照不能继续命中候选人。
|
||
clause: `current_display_user_id = ?
|
||
AND (current_display_user_id_kind <> 'pretty'
|
||
OR current_display_user_id_expires_at_ms IS NULL
|
||
OR current_display_user_id_expires_at_ms = 0
|
||
OR current_display_user_id_expires_at_ms > ?)`,
|
||
args: []any{keyword, nowMs},
|
||
},
|
||
lookup{clause: "default_display_user_id = ?", args: []any{keyword}},
|
||
)
|
||
for _, candidate := range lookups {
|
||
var user hostdomain.ExternalTeamUser
|
||
args := []any{appcode.FromContext(ctx), regionID}
|
||
args = append(args, candidate.args...)
|
||
err := r.db.QueryRowContext(ctx, `
|
||
SELECT user_id, COALESCE(default_display_user_id, ''), COALESCE(current_display_user_id, ''),
|
||
COALESCE(username, ''), COALESCE(avatar, ''), status, COALESCE(region_id, 0)
|
||
FROM users
|
||
WHERE app_code = ? AND region_id = ? AND status = 'active' AND `+candidate.clause+`
|
||
LIMIT 1`,
|
||
args...,
|
||
).Scan(
|
||
&user.UserID,
|
||
&user.DefaultDisplayUserID,
|
||
&user.CurrentDisplayUserID,
|
||
&user.Username,
|
||
&user.Avatar,
|
||
&user.Status,
|
||
&user.RegionID,
|
||
)
|
||
if err == nil {
|
||
return user, nil
|
||
}
|
||
if err != sql.ErrNoRows {
|
||
return hostdomain.ExternalTeamUser{}, err
|
||
}
|
||
}
|
||
|
||
// active lease 是靓号归属事实,不能只依赖 users.current_display_user_id 快照;
|
||
// active_display_user_id 生成列使该精确查询命中租约唯一索引,不扫描区域用户。
|
||
var user hostdomain.ExternalTeamUser
|
||
err := r.db.QueryRowContext(ctx, `
|
||
SELECT u.user_id, COALESCE(u.default_display_user_id, ''), COALESCE(u.current_display_user_id, ''),
|
||
COALESCE(u.username, ''), COALESCE(u.avatar, ''), u.status, COALESCE(u.region_id, 0)
|
||
FROM pretty_display_user_id_leases lease FORCE INDEX (uk_pretty_display_user_id_active)
|
||
INNER JOIN users u
|
||
ON u.user_id = lease.user_id AND u.app_code = lease.app_code
|
||
WHERE lease.app_code = ? AND lease.active_display_user_id = ?
|
||
AND (lease.expires_at_ms = 0 OR lease.expires_at_ms > ?)
|
||
AND u.region_id = ? AND u.status = 'active'
|
||
LIMIT 1`,
|
||
appcode.FromContext(ctx), keyword, nowMs, regionID,
|
||
).Scan(
|
||
&user.UserID,
|
||
&user.DefaultDisplayUserID,
|
||
&user.CurrentDisplayUserID,
|
||
&user.Username,
|
||
&user.Avatar,
|
||
&user.Status,
|
||
&user.RegionID,
|
||
)
|
||
if err == nil {
|
||
return user, nil
|
||
}
|
||
if err != sql.ErrNoRows {
|
||
return hostdomain.ExternalTeamUser{}, err
|
||
}
|
||
return hostdomain.ExternalTeamUser{}, xerr.New(xerr.NotFound, "external invitation candidate not found")
|
||
}
|
||
|
||
func (r *Repository) requireCandidateRoleAvailable(ctx context.Context, invitationType string, userID int64) error {
|
||
var query string
|
||
switch invitationType {
|
||
case hostdomain.InvitationTypeBD:
|
||
query = `SELECT (
|
||
EXISTS(SELECT 1 FROM bd_profiles WHERE app_code = ? AND user_id = ? AND status = 'active')
|
||
OR EXISTS(SELECT 1 FROM role_invitations
|
||
WHERE app_code = ? AND target_user_id = ? AND status = 'pending'
|
||
AND invitation_type IN ('bd', 'external_bd'))
|
||
)`
|
||
case hostdomain.InvitationTypeAgency:
|
||
query = `SELECT (
|
||
EXISTS(SELECT 1 FROM agencies WHERE app_code = ? AND owner_user_id = ? AND status = 'active')
|
||
OR EXISTS(SELECT 1 FROM role_invitations
|
||
WHERE app_code = ? AND target_user_id = ? AND status = 'pending'
|
||
AND invitation_type IN ('agency', 'external_agency'))
|
||
)`
|
||
case hostdomain.InvitationTypeHost:
|
||
query = `SELECT (
|
||
EXISTS(SELECT 1 FROM agencies WHERE app_code = ? AND owner_user_id = ? AND status = 'active')
|
||
OR EXISTS(SELECT 1 FROM agency_memberships WHERE app_code = ? AND host_user_id = ? AND status = 'active')
|
||
OR EXISTS(SELECT 1 FROM role_invitations
|
||
WHERE app_code = ? AND target_user_id = ? AND status = 'pending'
|
||
AND invitation_type IN ('host', 'external_host'))
|
||
)`
|
||
default:
|
||
return xerr.New(xerr.InvalidArgument, "invitation_type is invalid")
|
||
}
|
||
var exists bool
|
||
args := []any{appcode.FromContext(ctx), userID, appcode.FromContext(ctx), userID}
|
||
if invitationType == hostdomain.InvitationTypeHost {
|
||
args = append(args, appcode.FromContext(ctx), userID)
|
||
}
|
||
if err := r.db.QueryRowContext(ctx, query, args...).Scan(&exists); err != nil {
|
||
return err
|
||
}
|
||
if exists {
|
||
return xerr.New(xerr.Conflict, fmt.Sprintf("target already has active %s identity", invitationType))
|
||
}
|
||
return nil
|
||
}
|