audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [ 'reason' => 'not_active_enterprise_user', ]); return null; } $roster = app(FinanceRoster::class)->findEligible((string) ($profile['dingtalk_user_id'] ?? '')); $restricted = $roster !== null && ($roster['restricted'] ?? false) === true; $supplierRoster = $roster ? null : app(SupplierPortalAccess::class)->resolve( (string) ($profile['dingtalk_user_id'] ?? ''), (string) ($profile['union_id'] ?? ''), ); if (! $roster && ! $supplierRoster) { $this->audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [ 'reason' => 'outside_authorized_roster', ]); $request->attributes->set('dingtalk_denial', 'outside_roster'); return null; } $identity = DingTalkIdentity::query() ->with('user') ->where('union_id', $profile['union_id']) ->first(); if ($identity) { if (! $this->isEligible($identity->user)) { $this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [ 'reason' => 'local_account_ineligible', ]); return null; } if (SupplierPortalAccess::isSupplierUser($identity->user)) { $company = $this->enabledCompany($identity->user); if (! $supplierRoster || ! $company) { $this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [ 'reason' => 'outside_supplier_roster', ]); return null; } app(SupplierPortalAccess::class)->bind($identity->user, (int) $company->id, $supplierRoster); } elseif (! $roster) { $this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [ 'reason' => 'outside_finance_roster', ]); return null; } $identity->forceFill([ 'open_id' => $profile['open_id'] ?: $identity->open_id, 'dingtalk_user_id' => $profile['dingtalk_user_id'], 'nickname' => $profile['nickname'] ?: $identity->nickname, ])->save(); // 按当前部门刷新访问范围:财务中心=全部,其它在职员工=风险管控两个页面。 $this->applyAccessScope($identity->user, $restricted); return $identity; } $existingRequest = DingTalkBindingRequest::query()->where('union_id', $profile['union_id'])->first(); if ($existingRequest && in_array($existingRequest->status, [DingTalkBindingRequest::REJECTED, DingTalkBindingRequest::REVOKED], true)) { $existingRequest->forceFill(['last_attempted_at' => now()])->save(); $this->audit('login_denied', 'denied', $existingRequest->matched_user_id, null, $profile['union_id'], $request, [ 'reason' => 'binding_previously_denied', ]); return null; } return DB::transaction(function () use ($profile, $roster, $restricted, $supplierRoster, $request): ?DingTalkIdentity { // Serialize first-time provisioning on the target company. Both the // account and unique identity are committed together, including retries. $companies = Company::enabled() ->when(config('dingtalk.company_id'), fn ($q, $id) => $q->whereKey($id)) ->orderBy('id')->limit(2)->lockForUpdate()->get(); if ($companies->count() !== 1) { $request->attributes->set('dingtalk_denial', 'unavailable'); return null; } $existing = DingTalkIdentity::query()->with('user')->where('union_id', $profile['union_id'])->first(); if ($existing) { return $this->isEligible($existing->user) ? $existing : null; } $binding = DingTalkBindingRequest::query()->where('union_id', $profile['union_id'])->lockForUpdate()->first(); if ($binding && in_array($binding->status, [DingTalkBindingRequest::REJECTED, DingTalkBindingRequest::REVOKED], true)) { return null; } // Never claim a local account by a shared name, email or phone number. if (DingTalkIdentity::query()->where('dingtalk_user_id', $profile['dingtalk_user_id'])->exists()) { return null; } $supplierPortal = $supplierRoster !== null; $roleName = ! $supplierPortal && FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id']) ? SystemAccess::SUPER_ADMIN : SystemAccess::USER; $role = Role::where('name', $roleName)->firstOrFail(); $email = $supplierPortal ? null : $this->normalizeEmail($profile['email'] ?? null); if (! $email || User::withTrashed()->where('email', $email)->exists()) { $email = 'ding-'.hash('sha256', $profile['union_id']).'@dingtalk.invalid'; } // A deleted/disabled account must not be silently resurrected. if (User::withTrashed()->where('email', $email)->exists()) { return null; } $account = User::create([ 'name' => Str::limit($profile['nickname'] ?: ($roster['display_name'] ?? '供应商人员'), 191, ''), 'email' => $email, 'password' => Str::random(64), 'locale' => 'zh-CN', 'enabled' => true, 'landing_page' => $supplierPortal ? 'business.supplier-portal.home' : 'dashboard', 'created_by' => 0, 'created_from' => $supplierPortal ? SupplierPortalAccess::CREATED_FROM : ($restricted ? 'dingtalk_restricted' : (FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id']) ? 'dingtalk_roster_limited' : 'dingtalk_roster')), ]); $account->roles()->attach($role->id); // Explicit designation controls the role; ordinary roster users get shell access. $account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id); $account->companies()->attach($companies->first()->id); if ($supplierPortal) { app(SupplierPortalAccess::class)->bind($account, (int) $companies->first()->id, $supplierRoster); } elseif ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) { Contact::create([ 'company_id' => $companies->first()->id, 'type' => Contact::EMPLOYEE_TYPE, 'user_id' => $account->id, 'name' => $account->name, 'email' => $email, 'phone' => $mobile, 'currency_code' => $companies->first()->currency ?: 'CNY', 'enabled' => true, 'created_by' => 0, 'created_from' => 'dingtalk_roster', ]); } $identity = DingTalkIdentity::create([ 'user_id' => $account->id, 'union_id' => $profile['union_id'], 'open_id' => $profile['open_id'], 'dingtalk_user_id' => $profile['dingtalk_user_id'], 'nickname' => $profile['nickname'], 'bound_at' => now(), ]); $this->upsertRequest( $profile, DingTalkBindingRequest::APPROVED, $supplierPortal ? 'supplier_roster_provisioned' : 'finance_roster_provisioned', $account->id, ); $this->audit('account_auto_created', 'success', $account->id, null, $profile['union_id'], $request); return $identity->load('user'); }, 3); } /** * 自动开通的钉钉账号按当前部门刷新访问范围;手工账号不受影响。 */ private function applyAccessScope(User $user, bool $restricted): void { $createdFrom = (string) $user->created_from; if (! in_array($createdFrom, ['dingtalk_roster', 'dingtalk_restricted'], true)) { return; } $target = $restricted ? 'dingtalk_restricted' : 'dingtalk_roster'; if ($createdFrom !== $target) { $user->forceFill(['created_from' => $target])->save(); } } public function recordLogin(DingTalkIdentity $identity, Request $request): void { $this->audit('login_success', 'success', $identity->user_id, null, $identity->union_id, $request); } public function audit( string $event, string $outcome, ?int $userId, ?int $actorUserId, ?string $unionId, Request $request, array $metadata = [] ): void { DingTalkAuthAudit::query()->create([ 'event' => $event, 'outcome' => $outcome, 'user_id' => $userId, 'actor_user_id' => $actorUserId, 'union_id_hash' => $unionId ? hash('sha256', $unionId) : null, 'ip_address' => $request->ip(), 'metadata' => $metadata ?: null, 'created_at' => now(), ]); } private function isEligible(?User $user): bool { return $user && $user->enabled && $this->enabledCompany($user) !== null; } private function enabledCompany(User $user): ?Company { return $user->withoutEvents(fn () => $user->companies()->enabled()->first()); } private function upsertRequest(array $profile, string $status, string $reason, ?int $matchedUserId): void { $email = $this->normalizeEmail($profile['email'] ?? null); $mobile = $this->normalizeMobile($profile['mobile'] ?? null); $bindingRequest = DingTalkBindingRequest::query()->firstOrNew(['union_id' => $profile['union_id']]); $terminalStatus = in_array($bindingRequest->status, [DingTalkBindingRequest::REJECTED, DingTalkBindingRequest::REVOKED], true); $bindingRequest->fill([ 'open_id' => $profile['open_id'] ?? null, 'dingtalk_user_id' => $profile['dingtalk_user_id'] ?? null, 'nickname' => $profile['nickname'] ?? null, 'email_hash' => $email ? hash('sha256', $email) : null, 'mobile_hash' => $mobile ? hash('sha256', $mobile) : null, 'email_hint' => $this->maskEmail($email), 'mobile_hint' => $this->maskMobile($mobile), 'status' => $terminalStatus && $status === DingTalkBindingRequest::PENDING ? $bindingRequest->status : $status, 'match_reason' => $reason, 'matched_user_id' => $matchedUserId, 'last_attempted_at' => now(), ])->save(); } private function normalizeEmail(mixed $value): ?string { $email = strtolower(trim((string) $value)); return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; } private function normalizeMobile(mixed $value): ?string { $mobile = preg_replace('/\D+/', '', (string) $value); if (str_starts_with($mobile, '86') && strlen($mobile) === 13) { $mobile = substr($mobile, 2); } return strlen($mobile) >= 7 ? $mobile : null; } private function maskEmail(?string $email): ?string { if (! $email) { return null; } [$local, $domain] = explode('@', $email, 2); return substr($local, 0, 1).'***@'.$domain; } private function maskMobile(?string $mobile): ?string { return $mobile ? str_repeat('*', max(0, strlen($mobile) - 4)).substr($mobile, -4) : null; } }