2026-07-23 16:47:40 +08:00

674 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}