343 lines
8.7 KiB
Go
343 lines
8.7 KiB
Go
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
|
||
}
|