钉钉登录权限:财务中心看全部,其他在职员工只看风险管控两个页面 #489

Merged
renshen merged 1 commits from feature/renshen-dept-access-scope into main 2026-10-24 13:25:06 +08:00
5 changed files with 88 additions and 8 deletions

View File

@ -28,6 +28,7 @@ class DingTalkClosedAccess
}
$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'] ?? ''),
@ -77,6 +78,9 @@ class DingTalkClosedAccess
'nickname' => $profile['nickname'] ?: $identity->nickname,
])->save();
// 按当前部门刷新访问范围:财务中心=全部,其它在职员工=风险管控两个页面。
$this->applyAccessScope($identity->user, $restricted);
return $identity;
}
@ -90,7 +94,7 @@ class DingTalkClosedAccess
return null;
}
return DB::transaction(function () use ($profile, $roster, $supplierRoster, $request): ?DingTalkIdentity {
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()
@ -135,8 +139,10 @@ class DingTalkClosedAccess
'created_by' => 0,
'created_from' => $supplierPortal
? SupplierPortalAccess::CREATED_FROM
: (FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
? 'dingtalk_roster_limited' : 'dingtalk_roster'),
: ($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.
@ -168,6 +174,24 @@ class DingTalkClosedAccess
}, 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);

View File

@ -87,16 +87,17 @@ class FinanceRoster
return null;
}
$allowed = array_map('strval', config('dingtalk.finance_department_ids', []));
if (! self::isDesignatedSuperAdmin($userId)
&& ! self::isLimitedLoginUser($userId)
&& ! array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')))) {
return null;
}
// 财务中心(或指定超级管理员/受限登录名单)拥有完整权限;
// 其它在职员工返回 restricted,只能访问风险管控下的两个页面。
$inFinance = self::isDesignatedSuperAdmin($userId)
|| self::isLimitedLoginUser($userId)
|| (bool) array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')));
return [
'roster_id' => (string) $flower['Id'],
'display_name' => trim((string) ($flower['flowerName'] ?: $flower['name'])),
'department_ids' => $departmentIds,
'restricted' => ! $inFinance,
];
}

View File

@ -50,6 +50,11 @@ class SidebarPermissions
return [SupplierPortalAccess::PAGE_KEY];
}
// 非财务中心员工:只能看到财务经营风险管控下的两个页面。
if (self::hasRestrictedDingTalkAccess($user)) {
return self::normalizeAllowedKeys((array) config('dingtalk.restricted_page_keys', []));
}
if (self::hasLimitedDingTalkAccess($user)) {
return config('dingtalk.limited_login_page_keys', []);
}
@ -88,6 +93,11 @@ class SidebarPermissions
&& FinanceRoster::isLimitedLoginUser((string) $user->dingtalkIdentity?->dingtalk_user_id));
}
public static function hasRestrictedDingTalkAccess(User $user): bool
{
return $user->created_from === 'dingtalk_restricted';
}
public static function navigationFor(User $user): array
{
$navigation = BusinessNavigation::all();

View File

@ -20,6 +20,11 @@ return [
'courier-payables/courier-information',
'courier-payables/quote-management',
],
// 非财务中心员工:可以登录,但只能访问财务经营风险管控下的这两个页面。
'restricted_page_keys' => [
'financial-risk-control/store-violation-alert',
'financial-risk-control/process-statistics',
],
// When omitted, automatic provisioning requires exactly one enabled company.
'company_id' => env('DINGTALK_COMPANY_ID'),

View File

@ -138,6 +138,46 @@ class DingTalkLoginTest extends FeatureTestCase
->assertSee('data-cockpit', false);
}
public function test_non_finance_employee_only_gets_the_two_risk_control_pages(): void
{
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andReturn([
'roster_id' => '99001',
'display_name' => '运营中心员工',
'department_ids' => ['777001'],
'restricted' => true,
]);
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-restricted', 'open-restricted'));
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'fixture', 'state' => $state]))
->assertRedirect(route('dashboard', ['company_id' => $this->company->id]));
$created = auth()->user();
$this->assertSame('dingtalk_restricted', $created->created_from);
$this->assertSame([
'financial-risk-control/store-violation-alert',
'financial-risk-control/process-statistics',
], \App\Utilities\SidebarPermissions::allowedKeys($created));
$this->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-risk-control',
'page' => 'store-violation-alert',
]))->assertOk();
$this->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-risk-control',
'page' => 'process-statistics',
]))->assertOk();
$this->withExceptionHandling()->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'order-data',
'page' => 'raw-data',
]))->assertStatus(403);
}
public function test_limited_login_gets_only_three_courier_pages_and_blocks_direct_urls(): void
{
config(['dingtalk.limited_login_user_ids' => ['ding-user-1']]);