thread-master/apps/backend/internal/module/member/usecase/invite.go

343 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-0206
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-0912
// 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
}