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

343 lines
8.7 KiB
Go
Raw Normal View History

2026-07-13 03:18:08 +00:00
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
}