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
|
|||
|
|
}
|