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

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(&regionID)
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, &regionID); 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
}