613 lines
22 KiB
Go
613 lines
22 KiB
Go
package externaladmin
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"hyapp-admin-server/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
const (
|
|
maxHierarchyAccounts = 5000
|
|
maxParentCandidates = 500
|
|
// user-service bounds the trusted owner IN-list at 1000. Enforce the same
|
|
// contract before issuing any team request instead of letting an oversized
|
|
// hierarchy fail differently across list, invitation and mutation routes.
|
|
maxExternalScopeOwners = 1000
|
|
)
|
|
|
|
type portalScope struct {
|
|
RegionID int64
|
|
OwnerUserIDs []int64
|
|
ExternalSuperAdminAccounts []model.ExternalAdminAccount
|
|
}
|
|
|
|
func normalizeIdentity(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "", model.ExternalAdminIdentityCountryManager:
|
|
// Empty is accepted only for rolling-compatible create callers and historical
|
|
// rows; the persisted/current wire value is always the explicit default.
|
|
return model.ExternalAdminIdentityCountryManager
|
|
case model.ExternalAdminIdentityLocal:
|
|
return model.ExternalAdminIdentityLocal
|
|
case model.ExternalAdminIdentityExternalSuperAdmin:
|
|
return model.ExternalAdminIdentityExternalSuperAdmin
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func requiredParentIdentity(identity string) (string, bool) {
|
|
switch normalizeIdentity(identity) {
|
|
case model.ExternalAdminIdentityExternalSuperAdmin:
|
|
return model.ExternalAdminIdentityCountryManager, true
|
|
case model.ExternalAdminIdentityCountryManager:
|
|
return model.ExternalAdminIdentityLocal, false
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// activeUserRegion resolves the current canonical region instead of copying it into
|
|
// the portal account. Joining active regions makes deleted/disabled mappings fail
|
|
// closed while retaining an indexed users(app_code,user_id) point lookup.
|
|
func (s *Service) activeUserRegion(ctx context.Context, appCode string, userID int64) (int64, error) {
|
|
if s.userDB == nil || userID <= 0 {
|
|
return 0, ErrInvalidInput
|
|
}
|
|
var regionID int64
|
|
err := s.userDB.QueryRowContext(ctx, `
|
|
SELECT u.region_id
|
|
FROM users u
|
|
JOIN regions rg
|
|
ON rg.app_code = u.app_code
|
|
AND rg.region_id = u.region_id
|
|
AND rg.status = 'active'
|
|
WHERE u.app_code = ?
|
|
AND u.user_id = ?
|
|
AND u.status = 'active'
|
|
AND COALESCE(u.region_id, 0) > 0
|
|
LIMIT 1`, normalizeAppCode(appCode), userID).Scan(®ionID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, nil
|
|
}
|
|
return regionID, err
|
|
}
|
|
|
|
func (s *Service) activeUserRegions(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
|
|
result := make(map[int64]int64, len(userIDs))
|
|
uniqueIDs := uniquePositiveInt64s(userIDs)
|
|
if len(uniqueIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
if s.userDB == nil {
|
|
return nil, errors.New("user mysql is not configured")
|
|
}
|
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",")
|
|
args := make([]any, 0, len(uniqueIDs)+1)
|
|
args = append(args, normalizeAppCode(appCode))
|
|
for _, userID := range uniqueIDs {
|
|
args = append(args, userID)
|
|
}
|
|
rows, err := s.userDB.QueryContext(ctx, `
|
|
SELECT u.user_id, u.region_id
|
|
FROM users u
|
|
JOIN regions rg
|
|
ON rg.app_code = u.app_code
|
|
AND rg.region_id = u.region_id
|
|
AND rg.status = 'active'
|
|
WHERE u.app_code = ?
|
|
AND u.status = 'active'
|
|
AND COALESCE(u.region_id, 0) > 0
|
|
AND u.user_id IN (`+placeholders+`)`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var userID int64
|
|
var regionID int64
|
|
if err := rows.Scan(&userID, ®ionID); err != nil {
|
|
return nil, err
|
|
}
|
|
result[userID] = regionID
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// resolvePortalScope rebuilds the hierarchy on every authenticated request. Status,
|
|
// parent changes and region changes therefore take effect without trusting stale
|
|
// browser claims or persisting duplicated owner-service state in the admin database.
|
|
func (s *Service) resolvePortalScope(ctx context.Context, account model.ExternalAdminAccount) (portalScope, error) {
|
|
scope := portalScope{OwnerUserIDs: []int64{}, ExternalSuperAdminAccounts: []model.ExternalAdminAccount{}}
|
|
account.IdentityType = normalizeIdentity(account.IdentityType)
|
|
if account.IdentityType == "" {
|
|
return scope, ErrInvalidHierarchy
|
|
}
|
|
regionID, err := s.activeUserRegion(ctx, account.AppCode, account.LinkedAppUserID)
|
|
if err != nil {
|
|
return scope, err
|
|
}
|
|
scope.RegionID = regionID
|
|
if regionID == 0 {
|
|
return scope, nil
|
|
}
|
|
|
|
valid, err := s.accountParentChainIsActive(ctx, account, regionID)
|
|
if err != nil {
|
|
return scope, err
|
|
}
|
|
if !valid {
|
|
return scope, nil
|
|
}
|
|
scope.OwnerUserIDs = append(scope.OwnerUserIDs, account.LinkedAppUserID)
|
|
|
|
descendants, superAdmins, err := s.loadPortalDescendants(ctx, account)
|
|
if err != nil {
|
|
return scope, err
|
|
}
|
|
if len(descendants) == 0 {
|
|
return scope, nil
|
|
}
|
|
userIDs := make([]int64, 0, len(descendants))
|
|
for _, descendant := range descendants {
|
|
userIDs = append(userIDs, descendant.LinkedAppUserID)
|
|
}
|
|
regions, err := s.activeUserRegions(ctx, account.AppCode, userIDs)
|
|
if err != nil {
|
|
return scope, err
|
|
}
|
|
validAccounts := make(map[uint64]struct{}, len(descendants))
|
|
switch account.IdentityType {
|
|
case model.ExternalAdminIdentityCountryManager:
|
|
for _, superAdmin := range superAdmins {
|
|
if superAdmin.ParentAccountID == nil || *superAdmin.ParentAccountID != account.ID || regions[superAdmin.LinkedAppUserID] != regionID {
|
|
continue
|
|
}
|
|
validAccounts[superAdmin.ID] = struct{}{}
|
|
}
|
|
case model.ExternalAdminIdentityLocal:
|
|
validManagers := make(map[uint64]struct{}, len(descendants))
|
|
for _, descendant := range descendants {
|
|
if normalizeIdentity(descendant.IdentityType) != model.ExternalAdminIdentityCountryManager ||
|
|
descendant.ParentAccountID == nil || *descendant.ParentAccountID != account.ID ||
|
|
regions[descendant.LinkedAppUserID] != regionID {
|
|
continue
|
|
}
|
|
validManagers[descendant.ID] = struct{}{}
|
|
validAccounts[descendant.ID] = struct{}{}
|
|
}
|
|
// A Local must not inherit a SuperAdmin through a Manager that moved to
|
|
// another region. Filter by the already validated parent-account set before
|
|
// adding either business scope roots or the SuperAdmin menu projection.
|
|
for _, superAdmin := range superAdmins {
|
|
if superAdmin.ParentAccountID == nil || regions[superAdmin.LinkedAppUserID] != regionID {
|
|
continue
|
|
}
|
|
if _, parentValid := validManagers[*superAdmin.ParentAccountID]; !parentValid {
|
|
continue
|
|
}
|
|
validAccounts[superAdmin.ID] = struct{}{}
|
|
}
|
|
}
|
|
for _, descendant := range descendants {
|
|
if _, valid := validAccounts[descendant.ID]; valid {
|
|
scope.OwnerUserIDs = append(scope.OwnerUserIDs, descendant.LinkedAppUserID)
|
|
}
|
|
}
|
|
for _, superAdmin := range superAdmins {
|
|
if _, valid := validAccounts[superAdmin.ID]; valid {
|
|
scope.ExternalSuperAdminAccounts = append(scope.ExternalSuperAdminAccounts, superAdmin)
|
|
}
|
|
}
|
|
scope.OwnerUserIDs = uniquePositiveInt64s(scope.OwnerUserIDs)
|
|
if len(scope.OwnerUserIDs) > maxExternalScopeOwners {
|
|
return portalScope{OwnerUserIDs: []int64{}, ExternalSuperAdminAccounts: []model.ExternalAdminAccount{}}, ErrInvalidHierarchy
|
|
}
|
|
return scope, nil
|
|
}
|
|
|
|
func (s *Service) accountParentChainIsActive(ctx context.Context, account model.ExternalAdminAccount, regionID int64) (bool, error) {
|
|
return s.accountParentChainIsActiveWithDB(ctx, s.db, account, regionID, false)
|
|
}
|
|
|
|
// accountParentChainIsActiveWithDB validates the complete bounded portal chain
|
|
// using the caller's admin transaction when hierarchy rows are being created or
|
|
// rebound. The identity order has a fixed maximum depth
|
|
// SuperAdmin -> country manager -> Local, so this never becomes an unbounded
|
|
// recursive query even if a manually corrupted row points back to a child.
|
|
func (s *Service) accountParentChainIsActiveWithDB(
|
|
ctx context.Context,
|
|
db *gorm.DB,
|
|
account model.ExternalAdminAccount,
|
|
regionID int64,
|
|
lockAncestors bool,
|
|
) (bool, error) {
|
|
identity := normalizeIdentity(account.IdentityType)
|
|
if identity == model.ExternalAdminIdentityLocal {
|
|
return account.ParentAccountID == nil, nil
|
|
}
|
|
if account.ParentAccountID == nil {
|
|
return identity == model.ExternalAdminIdentityCountryManager, nil
|
|
}
|
|
expectedIdentity, _ := requiredParentIdentity(identity)
|
|
if expectedIdentity == "" {
|
|
return false, nil
|
|
}
|
|
var parent model.ExternalAdminAccount
|
|
query := db.WithContext(ctx)
|
|
if lockAncestors {
|
|
// Parent mutation and account creation already run in a transaction. Share
|
|
// locking every ancestor prevents a concurrent disable/rebind from making a
|
|
// child valid only for the instant between validation and commit.
|
|
query = query.Clauses(clause.Locking{Strength: "SHARE"})
|
|
}
|
|
err := query.Where("id = ? AND app_code = ? AND identity_type = ? AND status = ?",
|
|
*account.ParentAccountID, account.AppCode, expectedIdentity, model.ExternalAdminStatusActive).
|
|
Take(&parent).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
parentRegionID, err := s.activeUserRegion(ctx, account.AppCode, parent.LinkedAppUserID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if parentRegionID != regionID {
|
|
return false, nil
|
|
}
|
|
// A SuperAdmin must not stay authorized through a Manager whose optional
|
|
// Local binding has become invalid. Identity transitions strictly decrease
|
|
// from SuperAdmin -> Manager -> Local, so this checks the complete bounded
|
|
// chain without introducing an unbounded recursive authorization query.
|
|
return s.accountParentChainIsActiveWithDB(ctx, db, parent, regionID, lockAncestors)
|
|
}
|
|
|
|
func (s *Service) loadPortalDescendants(ctx context.Context, account model.ExternalAdminAccount) ([]model.ExternalAdminAccount, []model.ExternalAdminAccount, error) {
|
|
switch normalizeIdentity(account.IdentityType) {
|
|
case model.ExternalAdminIdentityExternalSuperAdmin:
|
|
return []model.ExternalAdminAccount{}, []model.ExternalAdminAccount{}, nil
|
|
case model.ExternalAdminIdentityCountryManager:
|
|
superAdmins, err := s.queryChildren(ctx, account.AppCode, []uint64{account.ID}, model.ExternalAdminIdentityExternalSuperAdmin)
|
|
return superAdmins, superAdmins, err
|
|
case model.ExternalAdminIdentityLocal:
|
|
managers, err := s.queryChildren(ctx, account.AppCode, []uint64{account.ID}, model.ExternalAdminIdentityCountryManager)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
managerIDs := make([]uint64, 0, len(managers))
|
|
for _, manager := range managers {
|
|
managerIDs = append(managerIDs, manager.ID)
|
|
}
|
|
superAdmins, err := s.queryChildren(ctx, account.AppCode, managerIDs, model.ExternalAdminIdentityExternalSuperAdmin)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
descendants := make([]model.ExternalAdminAccount, 0, len(managers)+len(superAdmins))
|
|
descendants = append(descendants, managers...)
|
|
descendants = append(descendants, superAdmins...)
|
|
return descendants, superAdmins, nil
|
|
default:
|
|
return nil, nil, ErrInvalidHierarchy
|
|
}
|
|
}
|
|
|
|
func (s *Service) queryChildren(ctx context.Context, appCode string, parentIDs []uint64, identity string) ([]model.ExternalAdminAccount, error) {
|
|
if len(parentIDs) == 0 {
|
|
return []model.ExternalAdminAccount{}, nil
|
|
}
|
|
items := []model.ExternalAdminAccount{}
|
|
err := s.db.WithContext(ctx).
|
|
Where("app_code = ? AND parent_account_id IN ? AND identity_type = ? AND status = ?",
|
|
normalizeAppCode(appCode), parentIDs, identity, model.ExternalAdminStatusActive).
|
|
Order("id ASC").
|
|
Limit(maxHierarchyAccounts + 1).
|
|
Find(&items).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Silent truncation would broaden/lose team authorization unpredictably. Fail the
|
|
// request instead, so operators must split an oversized hierarchy deliberately.
|
|
if len(items) > maxHierarchyAccounts {
|
|
return nil, ErrInvalidHierarchy
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Service) validateParentForChild(ctx context.Context, tx *gorm.DB, appCode string, identity string, parentID *uint64, childRegionID int64) (*model.ExternalAdminAccount, error) {
|
|
identity = normalizeIdentity(identity)
|
|
expectedIdentity, parentRequired := requiredParentIdentity(identity)
|
|
if identity == "" || childRegionID <= 0 {
|
|
return nil, ErrInvalidHierarchy
|
|
}
|
|
if parentID == nil {
|
|
if parentRequired {
|
|
return nil, ErrInvalidHierarchy
|
|
}
|
|
return nil, nil
|
|
}
|
|
if *parentID == 0 || expectedIdentity == "" {
|
|
return nil, ErrInvalidHierarchy
|
|
}
|
|
var parent model.ExternalAdminAccount
|
|
err := tx.Clauses(clause.Locking{Strength: "SHARE"}).
|
|
Where("id = ? AND app_code = ? AND identity_type = ? AND status = ?",
|
|
*parentID, normalizeAppCode(appCode), expectedIdentity, model.ExternalAdminStatusActive).
|
|
Take(&parent).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrInvalidHierarchy
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parentRegionID, err := s.activeUserRegion(ctx, appCode, parent.LinkedAppUserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if parentRegionID == 0 || parentRegionID != childRegionID {
|
|
return nil, ErrInvalidHierarchy
|
|
}
|
|
// A country manager with a stale/disabled/cross-region Local parent is not a
|
|
// valid SuperAdmin parent. Likewise, a manually corrupted Local row with its
|
|
// own parent cannot authorize a manager. Validate the full chain inside the
|
|
// same transaction instead of creating a child that immediately fails closed.
|
|
valid, err := s.accountParentChainIsActiveWithDB(ctx, tx, parent, childRegionID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !valid {
|
|
return nil, ErrInvalidHierarchy
|
|
}
|
|
return &parent, nil
|
|
}
|
|
|
|
func (s *Service) ListParentCandidates(ctx context.Context, input ParentCandidateInput) (ParentCandidateResult, error) {
|
|
if s.db == nil {
|
|
return ParentCandidateResult{}, errors.New("admin mysql is not configured")
|
|
}
|
|
input.AppCode = normalizeAppCode(input.AppCode)
|
|
identity := normalizeIdentity(input.IdentityType)
|
|
if input.AppCode == "" || (strings.TrimSpace(input.IdentityType) != "" && identity == "") {
|
|
return ParentCandidateResult{}, ErrInvalidInput
|
|
}
|
|
var child model.ExternalAdminAccount
|
|
var regionID int64
|
|
if input.ChildAccountID > 0 {
|
|
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", input.ChildAccountID, input.AppCode).Take(&child).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ParentCandidateResult{}, ErrAccountNotFound
|
|
}
|
|
return ParentCandidateResult{}, err
|
|
}
|
|
identity = normalizeIdentity(child.IdentityType)
|
|
if identity == "" {
|
|
return ParentCandidateResult{}, ErrInvalidHierarchy
|
|
}
|
|
var err error
|
|
regionID, err = s.activeUserRegion(ctx, input.AppCode, child.LinkedAppUserID)
|
|
if err != nil {
|
|
return ParentCandidateResult{}, err
|
|
}
|
|
} else {
|
|
target, err := s.ResolveTarget(ctx, input.AppCode, input.TargetUserID)
|
|
if err != nil {
|
|
return ParentCandidateResult{}, err
|
|
}
|
|
regionID, err = s.activeUserRegion(ctx, input.AppCode, target.UserID)
|
|
if err != nil {
|
|
return ParentCandidateResult{}, err
|
|
}
|
|
}
|
|
expectedIdentity, _ := requiredParentIdentity(identity)
|
|
if expectedIdentity == "" || regionID <= 0 {
|
|
return ParentCandidateResult{Items: []AccountReference{}}, nil
|
|
}
|
|
|
|
query := s.db.WithContext(ctx).
|
|
Where("app_code = ? AND identity_type = ? AND status = ?", input.AppCode, expectedIdentity, model.ExternalAdminStatusActive)
|
|
if keyword := strings.TrimSpace(input.Keyword); keyword != "" {
|
|
query = query.Where("username LIKE ?", "%"+keyword+"%")
|
|
}
|
|
candidates := []model.ExternalAdminAccount{}
|
|
if err := query.Order("username ASC, id ASC").Limit(maxParentCandidates + 1).Find(&candidates).Error; err != nil {
|
|
return ParentCandidateResult{}, err
|
|
}
|
|
truncated := len(candidates) > maxParentCandidates
|
|
if truncated {
|
|
candidates = candidates[:maxParentCandidates]
|
|
}
|
|
userIDs := make([]int64, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
userIDs = append(userIDs, candidate.LinkedAppUserID)
|
|
}
|
|
regions, err := s.activeUserRegions(ctx, input.AppCode, userIDs)
|
|
if err != nil {
|
|
return ParentCandidateResult{}, err
|
|
}
|
|
users, err := s.loadLinkedUsers(ctx, input.AppCode, userIDs)
|
|
if err != nil {
|
|
return ParentCandidateResult{}, err
|
|
}
|
|
items := make([]AccountReference, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
if regions[candidate.LinkedAppUserID] != regionID {
|
|
continue
|
|
}
|
|
// Candidate visibility is authorization-sensitive: a country manager whose
|
|
// optional Local chain has drifted must not be selectable for a new
|
|
// SuperAdmin merely because the manager row itself is still active.
|
|
valid, err := s.accountParentChainIsActive(ctx, candidate, regionID)
|
|
if err != nil {
|
|
return ParentCandidateResult{}, err
|
|
}
|
|
if !valid {
|
|
continue
|
|
}
|
|
items = append(items, accountReference(candidate, users[candidate.LinkedAppUserID]))
|
|
}
|
|
return ParentCandidateResult{Items: items, Truncated: truncated}, nil
|
|
}
|
|
|
|
func (s *Service) UpdateAccountParent(ctx context.Context, appCode string, id uint64, input UpdateParentInput) (ParentUpdateResult, error) {
|
|
appCode = normalizeAppCode(appCode)
|
|
if s.db == nil {
|
|
return ParentUpdateResult{}, errors.New("admin mysql is not configured")
|
|
}
|
|
if appCode == "" || id == 0 || input.ExpectedRevision == 0 {
|
|
return ParentUpdateResult{}, ErrInvalidInput
|
|
}
|
|
result := ParentUpdateResult{}
|
|
var account model.ExternalAdminAccount
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).Take(&account).Error; err != nil {
|
|
return err
|
|
}
|
|
if account.PermissionRevision != input.ExpectedRevision {
|
|
return ErrParentConflict
|
|
}
|
|
regionID, err := s.activeUserRegion(ctx, appCode, account.LinkedAppUserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.validateParentForChild(ctx, tx, appCode, account.IdentityType, input.ParentAccountID, regionID); err != nil {
|
|
return err
|
|
}
|
|
if equalUint64Pointers(account.ParentAccountID, input.ParentAccountID) {
|
|
return nil
|
|
}
|
|
if err := tx.Model(&account).Updates(map[string]any{
|
|
"parent_account_id": input.ParentAccountID,
|
|
"permission_revision": account.PermissionRevision + 1,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := revokeAllSessions(tx, account.ID, "hierarchy_changed", time.Now().UTC().UnixMilli()); err != nil {
|
|
return err
|
|
}
|
|
result.Changed = true
|
|
result.SessionsRevoked = true
|
|
return nil
|
|
})
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ParentUpdateResult{}, ErrAccountNotFound
|
|
}
|
|
if err != nil {
|
|
return ParentUpdateResult{}, err
|
|
}
|
|
account = model.ExternalAdminAccount{}
|
|
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", id, appCode).Take(&account).Error; err != nil {
|
|
return ParentUpdateResult{}, err
|
|
}
|
|
dto, err := s.accountDTOWithRelations(ctx, account)
|
|
if err != nil {
|
|
return ParentUpdateResult{}, err
|
|
}
|
|
result.Account = dto
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) ListExternalSuperAdmins(ctx context.Context, principal SessionPrincipal) (ListResult, error) {
|
|
if normalizeIdentity(principal.IdentityType) == model.ExternalAdminIdentityExternalSuperAdmin {
|
|
return ListResult{}, ErrInvalidHierarchy
|
|
}
|
|
var account model.ExternalAdminAccount
|
|
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).Take(&account).Error; err != nil {
|
|
return ListResult{}, err
|
|
}
|
|
scope, err := s.resolvePortalScope(ctx, account)
|
|
if err != nil {
|
|
return ListResult{}, err
|
|
}
|
|
users, err := s.loadLinkedUsers(ctx, principal.AppCode, linkedUserIDs(scope.ExternalSuperAdminAccounts))
|
|
if err != nil {
|
|
return ListResult{}, err
|
|
}
|
|
items := make([]AccountDTO, 0, len(scope.ExternalSuperAdminAccounts))
|
|
for _, item := range scope.ExternalSuperAdminAccounts {
|
|
items = append(items, accountDTO(item, users[item.LinkedAppUserID]))
|
|
}
|
|
return ListResult{Items: items, Page: 1, PageSize: len(items), Total: int64(len(items))}, nil
|
|
}
|
|
|
|
func (s *Service) accountDTOWithRelations(ctx context.Context, account model.ExternalAdminAccount) (AccountDTO, error) {
|
|
accountUsers, err := s.loadLinkedUsers(ctx, account.AppCode, []int64{account.LinkedAppUserID})
|
|
if err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
var parent *model.ExternalAdminAccount
|
|
parentUsers := map[int64]LinkedUser{}
|
|
if account.ParentAccountID != nil {
|
|
var loaded model.ExternalAdminAccount
|
|
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", *account.ParentAccountID, account.AppCode).Take(&loaded).Error; err == nil {
|
|
parent = &loaded
|
|
parentUsers, err = s.loadLinkedUsers(ctx, account.AppCode, []int64{loaded.LinkedAppUserID})
|
|
if err != nil {
|
|
return AccountDTO{}, err
|
|
}
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return AccountDTO{}, err
|
|
}
|
|
}
|
|
return accountDTOWithParent(account, accountUsers[account.LinkedAppUserID], parent, parentUsers), nil
|
|
}
|
|
|
|
func accountReference(account model.ExternalAdminAccount, user LinkedUser) AccountReference {
|
|
if user.UserID == 0 {
|
|
user.UserID = account.LinkedAppUserID
|
|
}
|
|
return AccountReference{
|
|
ID: account.ID, Username: account.Username, IdentityType: normalizeIdentity(account.IdentityType),
|
|
Status: account.Status, LinkedUser: user,
|
|
}
|
|
}
|
|
|
|
func accountDTOWithParent(account model.ExternalAdminAccount, linkedUser LinkedUser, parent *model.ExternalAdminAccount, users map[int64]LinkedUser) AccountDTO {
|
|
dto := accountDTO(account, linkedUser)
|
|
if parent != nil {
|
|
reference := accountReference(*parent, users[parent.LinkedAppUserID])
|
|
dto.Parent = &reference
|
|
}
|
|
return dto
|
|
}
|
|
|
|
func linkedUserIDs(accounts []model.ExternalAdminAccount) []int64 {
|
|
ids := make([]int64, 0, len(accounts))
|
|
for _, account := range accounts {
|
|
ids = append(ids, account.LinkedAppUserID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func uniquePositiveInt64s(values []int64) []int64 {
|
|
seen := make(map[int64]struct{}, len(values))
|
|
result := make([]int64, 0, len(values))
|
|
for _, value := range values {
|
|
if value <= 0 {
|
|
continue
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
|
return result
|
|
}
|
|
|
|
func equalUint64Pointers(left *uint64, right *uint64) bool {
|
|
if left == nil || right == nil {
|
|
return left == nil && right == nil
|
|
}
|
|
return *left == *right
|
|
}
|