inspect(false); return DB::transaction(function () { $this->lock(); return $this->inspect(true); }); } private function inspect(bool $write): array { $users = DB::table('users')->get(['id','user_code','agent_user_code','from','created_at'])->keyBy('id'); $existing = DB::table('share_members')->get()->keyBy('user_id'); $assigned = 0; foreach ($users as $id=>$user) { if ((int)$user->from === 2) continue; $old=(string)($user->user_code ?? ''); $stable=(string)($existing[$id]->invitation_code ?? ''); if ($old === '' || $old === '0') { $assigned++; if (!$write) continue; $code=($stable !== '' && $stable !== '0') ? $stable : User::generateUserCode(); if (DB::table('users')->where('user_code',$code)->where('id','<>',$id)->exists()) throw new \RuntimeException('固化邀请码已被其他会员占用,请人工核对:'.$id); DB::table('users')->where('id',$id)->update(['user_code'=>$code]); $user->user_code=$code; } if ($write && $existing->has($id) && ($stable === '' || $stable === '0')) { DB::table('share_members')->where('user_id',$id)->update(['invitation_code'=>(string)$user->user_code]); $existing[$id]->invitation_code=(string)$user->user_code; } } $codes = []; foreach ($users as $u) if ((string)$u->user_code !== '' && (string)$u->user_code !== '0') $codes[strtoupper(trim((string)$u->user_code))][] = (int)$u->id; $parents = []; $statuses = []; $errors = []; foreach ($users as $u) { $id = (int)$u->id; if ($existing->has($id)) { $parents[$id] = $existing[$id]->parent_user_id === null ? null : (int)$existing[$id]->parent_user_id; $statuses[$id] = $existing[$id]->binding_status; continue; } $code = strtoupper(trim((string)($u->agent_user_code ?? ''))); $matches = $codes[$code] ?? []; $parents[$id] = null; $statuses[$id] = 'root'; if ((int)$u->from === 2 || $code === '') continue; if ($code === '0' || count($matches) === 0) $statuses[$id] = 'missing'; elseif (count($matches) > 1) $statuses[$id] = 'ambiguous'; elseif ((int)$users[$matches[0]]->from === 2) $statuses[$id] = 'missing'; else { $parents[$id] = $matches[0]; $statuses[$id] = 'matched'; } } // Only actual cycle edges are removed. Confirmed children outside the cycle keep their identity. $done = []; foreach (array_keys($parents) as $id) { $path = []; $at = $id; while ($at !== null && isset($parents[$at]) && !isset($done[$at])) { if (isset($path[$at])) { $cycle = array_slice(array_keys($path), $path[$at]); foreach ($cycle as $member) { if ($existing->has($member)) throw new \RuntimeException('已固化的推荐关系存在循环,禁止自动改绑:' . $member); $parents[$member] = null; $statuses[$member] = 'cycle'; } break; } $path[$at] = count($path); $at = $parents[$at]; } foreach ($path as $member => $_) $done[$member] = true; } foreach ($statuses as $id => $status) if (in_array($status,['missing','ambiguous','cycle'],true)) $errors[] = ['user_id'=>$id,'status'=>$status]; $result = ['users'=>$users->count(),'created'=>0,'existing'=>$existing->count(),'invitation_codes_assigned'=>$assigned,'errors'=>$errors,'written'=>$write]; if (!$write) { $result['created'] = $users->count() - $existing->count(); return $result; } $now = Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s'); $initial = DB::table('share_system_state')->where('key','initialized_at')->value('value') === null; foreach ($users as $u) { $id = (int)$u->id; if ($existing->has($id)) continue; DB::table('share_members')->insert(['user_id'=>$id,'parent_user_id'=>$parents[$id], 'invitation_code'=>(string)($u->user_code ?? ''),'binding_status'=>$statuses[$id], 'bound_at'=>$initial ? '1970-01-01 00:00:00' : ($u->created_at ?: $now), 'created_at'=>$now,'updated_at'=>$now]); $result['created']++; } $members = DB::table('share_members')->get()->keyBy('user_id'); foreach ($members as $member) { $id = (int)$member->user_id; $at = $id; $depth = 0; $seen = []; $bound = '1970-01-01 00:00:00'; $rows = []; while ($at !== null && $members->has($at)) { if (isset($seen[$at])) throw new \RuntimeException('推荐关系循环'); $seen[$at] = true; $rows[] = ['ancestor_user_id'=>$at,'descendant_user_id'=>$id,'depth'=>$depth,'bound_at'=>$bound]; $edge = $members[$at]; if ($edge->parent_user_id === null) break; $bound = max($bound,(string)$edge->bound_at); $at = (int)$edge->parent_user_id; $depth++; } foreach (array_chunk($rows,500) as $chunk) DB::table('share_tree')->insertOrIgnore($chunk); DB::table('share_accounts')->insertOrIgnore(['user_id'=>$id,'created_at'=>$now,'updated_at'=>$now]); } DB::table('share_system_state')->updateOrInsert(['key'=>'initialized_at'],['value'=>$now,'created_at'=>$now,'updated_at'=>$now]); return $result; } public function bind(int $userId, ?int $parentId, string $code, string $status = 'matched'): void { DB::transaction(function () use ($userId,$parentId,$code,$status) { $this->lock(); $now = Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s'); $member = DB::table('share_members')->where('user_id',$userId)->lockForUpdate()->first(); if ($member && $member->parent_user_id !== null) { if ((int)$member->parent_user_id !== $parentId) throw new \RuntimeException('上级关系已固定,不能改绑'); return; } if ($parentId !== null) { $parent = DB::table('share_members')->where('user_id',$parentId)->first(); if (!$parent) throw new \RuntimeException('推广会员关系尚未初始化'); if ($parentId === $userId || DB::table('share_tree')->where('ancestor_user_id',$userId)->where('descendant_user_id',$parentId)->exists()) throw new \RuntimeException('不能绑定自己或自己的下级'); $user = DB::table('users')->where('id',$parentId)->first(); if (!$user || (int)$user->from === 2) throw new \RuntimeException('试玩会员不能作为推广人'); } if (!$member) { DB::table('share_members')->insert(['user_id'=>$userId,'parent_user_id'=>null,'invitation_code'=>$code,'binding_status'=>'root','bound_at'=>$now,'created_at'=>$now,'updated_at'=>$now]); DB::table('share_tree')->insertOrIgnore(['ancestor_user_id'=>$userId,'descendant_user_id'=>$userId,'depth'=>0,'bound_at'=>'1970-01-01 00:00:00']); DB::table('share_accounts')->insertOrIgnore(['user_id'=>$userId,'created_at'=>$now,'updated_at'=>$now]); } if ($parentId === null) return; $ancestors = DB::table('share_tree')->where('descendant_user_id',$parentId)->get(); $descendants = DB::table('share_tree')->where('ancestor_user_id',$userId)->get(); foreach ($ancestors as $ancestor) { $rows = []; foreach ($descendants as $descendant) $rows[] = ['ancestor_user_id'=>$ancestor->ancestor_user_id, 'descendant_user_id'=>$descendant->descendant_user_id,'depth'=>$ancestor->depth+1+$descendant->depth, 'bound_at'=>max($now,(string)$ancestor->bound_at,(string)$descendant->bound_at)]; foreach (array_chunk($rows,500) as $chunk) DB::table('share_tree')->insertOrIgnore($chunk); } DB::table('share_members')->where('user_id',$userId)->update(['parent_user_id'=>$parentId,'binding_status'=>$status,'bound_at'=>$now,'updated_at'=>$now]); }); } private function lock(): void { if (!DB::table('share_system_state')->where('key','relations_lock')->lockForUpdate()->first()) throw new \RuntimeException('分享关系表尚未初始化,请执行迁移'); } }