package usecase import ( "context" "sort" "strings" "apps/backend/internal/module/member/domain" ) // InviteMemberBrief — 邀請網絡節點摘要(API 層再轉 types) type InviteMemberBrief struct { UID int64 Email string DisplayName string InviteCode string ParentUID *int64 Roles []string Status string DownlineCount int TotalDownlineCount int Depth int JoinedAt int64 AvatarURL string } // InviteTreeNode — 森林節點 type InviteTreeNode struct { InviteMemberBrief Children []InviteTreeNode } // MyInviteNetwork — 會員自己的碼 + 上下線 type MyInviteNetwork struct { Me InviteMemberBrief Upline *InviteMemberBrief Downlines []InviteMemberBrief TotalDownlineCount int } // GetMyNetwork — IV-01 / IV-07 func (s *AccountService) GetMyNetwork(ctx context.Context, uid int64) (*MyInviteNetwork, error) { all, err := s.Repo.ListAllMembers(ctx) if err != nil { return nil, err } briefs := buildInviteBriefs(all) me, ok := briefs[uid] if !ok { return nil, domain.ErrNotFound } var upline *InviteMemberBrief if me.ParentUID != nil { if u, ok := briefs[*me.ParentUID]; ok { cp := u upline = &cp } } var down []InviteMemberBrief for _, m := range all { if m.ParentUID != nil && *m.ParentUID == uid { if b, ok := briefs[m.UID]; ok { down = append(down, b) } } } sort.Slice(down, func(i, j int) bool { return down[i].JoinedAt > down[j].JoinedAt }) return &MyInviteNetwork{ Me: me, Upline: upline, Downlines: down, TotalDownlineCount: me.TotalDownlineCount, }, nil } // ApplyInviteCode — IV-02~06 func (s *AccountService) ApplyInviteCode(ctx context.Context, uid int64, rawCode string) (*MyInviteNetwork, error) { code := strings.ToUpper(strings.TrimSpace(rawCode)) if code == "" { return nil, domain.ErrInviteCodeRequired } me, err := s.Repo.FindByUID(ctx, uid) if err != nil { return nil, err } if me.ParentUID != nil && *me.ParentUID > 0 { return nil, domain.ErrInviteAlreadyBound } inviter, err := s.Repo.FindByInviteCode(ctx, code) if err != nil { if err == domain.ErrNotFound { return nil, domain.ErrInviteCodeNotFound } return nil, err } if inviter.UID == uid { return nil, domain.ErrInviteCodeSelf } // 防環:邀請人不可為自己的子孫 all, err := s.Repo.ListAllMembers(ctx) if err != nil { return nil, err } if isDescendantOf(uid, inviter.UID, all) { return nil, domain.ErrInviteCycle } p := inviter.UID me.ParentUID = &p me.UpdatedAt = domain.NowNano() if err := s.Repo.UpdateMember(ctx, me); err != nil { return nil, err } return s.GetMyNetwork(ctx, uid) } // GetInviteTree — IV-08 admin func (s *AccountService) GetInviteTree(ctx context.Context) ([]InviteTreeNode, error) { all, err := s.Repo.ListAllMembers(ctx) if err != nil { return nil, err } return buildInviteForest(all), nil } // MoveInviteMember — IV-09~12 // newParentUID nil = 獨立根 func (s *AccountService) MoveInviteMember(ctx context.Context, actorUID, targetUID int64, newParentUID *int64) (*InviteMemberBrief, error) { actor, err := s.Repo.FindByUID(ctx, actorUID) if err != nil { return nil, err } if !actor.IsAdmin() { return nil, domain.ErrInviteNeedAdmin } target, err := s.Repo.FindByUID(ctx, targetUID) if err != nil { return nil, domain.ErrNotFound } if newParentUID != nil && *newParentUID == targetUID { return nil, domain.ErrInviteSelfParent } if newParentUID != nil && *newParentUID > 0 { parent, err := s.Repo.FindByUID(ctx, *newParentUID) if err != nil { return nil, domain.ErrInviteParentMiss } _ = parent all, err := s.Repo.ListAllMembers(ctx) if err != nil { return nil, err } // 新父不可是 target 的子孫(含自己已檢查) if isDescendantOf(targetUID, *newParentUID, all) { return nil, domain.ErrInviteCycle } target.ParentUID = newParentUID } else { target.ParentUID = nil } target.UpdatedAt = domain.NowNano() if err := s.Repo.UpdateMember(ctx, target); err != nil { return nil, err } all, err := s.Repo.ListAllMembers(ctx) if err != nil { return nil, err } briefs := buildInviteBriefs(all) b := briefs[targetUID] return &b, nil } // ListParentCandidates — IV-13 排除子樹 func (s *AccountService) ListParentCandidates(ctx context.Context, excludeSubtreeRootUID int64) ([]InviteMemberBrief, error) { all, err := s.Repo.ListAllMembers(ctx) if err != nil { return nil, err } exclude := map[int64]struct{}{} if excludeSubtreeRootUID > 0 { exclude[excludeSubtreeRootUID] = struct{}{} for _, m := range all { if isDescendantOf(excludeSubtreeRootUID, m.UID, all) { exclude[m.UID] = struct{}{} } } } briefs := buildInviteBriefs(all) var out []InviteMemberBrief for _, m := range all { if _, skip := exclude[m.UID]; skip { continue } if b, ok := briefs[m.UID]; ok { out = append(out, b) } } sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].DisplayName) < strings.ToLower(out[j].DisplayName) }) return out, nil } // --- helpers --- func buildInviteBriefs(members []*domain.Member) map[int64]InviteMemberBrief { children := map[int64][]int64{} byUID := map[int64]*domain.Member{} for _, m := range members { byUID[m.UID] = m if m.ParentUID != nil && *m.ParentUID > 0 { p := *m.ParentUID children[p] = append(children[p], m.UID) } } totalMemo := map[int64]int{} var totalOf func(uid int64, stack map[int64]struct{}) int totalOf = func(uid int64, stack map[int64]struct{}) int { if v, ok := totalMemo[uid]; ok { return v } if _, loop := stack[uid]; loop { return 0 } stack[uid] = struct{}{} kids := children[uid] n := len(kids) for _, k := range kids { n += totalOf(k, stack) } delete(stack, uid) totalMemo[uid] = n return n } depthMemo := map[int64]int{} var depthOf func(uid int64, stack map[int64]struct{}) int depthOf = func(uid int64, stack map[int64]struct{}) int { if v, ok := depthMemo[uid]; ok { return v } if _, loop := stack[uid]; loop { return 0 } m := byUID[uid] if m == nil || m.ParentUID == nil || *m.ParentUID <= 0 || byUID[*m.ParentUID] == nil { depthMemo[uid] = 0 return 0 } stack[uid] = struct{}{} d := 1 + depthOf(*m.ParentUID, stack) delete(stack, uid) depthMemo[uid] = d return d } out := make(map[int64]InviteMemberBrief, len(members)) for _, m := range members { b := InviteMemberBrief{ UID: m.UID, Email: m.Email, DisplayName: m.DisplayName, InviteCode: strings.TrimSpace(m.InviteCode), Roles: append([]string{}, m.Roles...), Status: m.Status, JoinedAt: m.CreatedAt, AvatarURL: m.AvatarURL, DownlineCount: len(children[m.UID]), TotalDownlineCount: totalOf(m.UID, map[int64]struct{}{}), Depth: depthOf(m.UID, map[int64]struct{}{}), } if m.ParentUID != nil && *m.ParentUID > 0 && byUID[*m.ParentUID] != nil { p := *m.ParentUID b.ParentUID = &p } if b.InviteCode == "" { b.InviteCode = "—" } out[m.UID] = b } return out } func buildInviteForest(members []*domain.Member) []InviteTreeNode { briefs := buildInviteBriefs(members) byParent := map[int64][]InviteMemberBrief{} // key 0 = roots for _, b := range briefs { var pk int64 if b.ParentUID != nil { pk = *b.ParentUID if _, ok := briefs[pk]; !ok { pk = 0 // 孤兒當根 } } byParent[pk] = append(byParent[pk], b) } var build func(uid int64) InviteTreeNode build = func(uid int64) InviteTreeNode { b := briefs[uid] n := InviteTreeNode{InviteMemberBrief: b, Children: nil} kids := byParent[uid] sort.Slice(kids, func(i, j int) bool { return strings.ToLower(kids[i].DisplayName) < strings.ToLower(kids[j].DisplayName) }) for _, k := range kids { n.Children = append(n.Children, build(k.UID)) } return n } roots := byParent[0] sort.Slice(roots, func(i, j int) bool { return strings.ToLower(roots[i].DisplayName) < strings.ToLower(roots[j].DisplayName) }) var forest []InviteTreeNode for _, r := range roots { forest = append(forest, build(r.UID)) } return forest } // isDescendantOf — whether candidate is under rootUID's subtree (not root itself) func isDescendantOf(rootUID, candidateUID int64, members []*domain.Member) bool { if rootUID == candidateUID { return false } parentOf := map[int64]int64{} for _, m := range members { if m.ParentUID != nil && *m.ParentUID > 0 { parentOf[m.UID] = *m.ParentUID } } seen := map[int64]struct{}{} cur := candidateUID for cur != 0 { if cur == rootUID { return true } if _, loop := seen[cur]; loop { return false } seen[cur] = struct{}{} p, ok := parentOf[cur] if !ok { return false } cur = p } return false }