setConfig(['default' => 'sqlite', 'auto_timestamp' => false, 'datetime_format' => false, 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'test_']]]); $app->instance('think\DbManager', $manager); $config = new think\Config(); $config->set(['data_scope' => ['enabled' => true, 'no_dept_fallback_self' => true, 'frontline_roles' => [1, 2], 'team_roles' => [3, 7, 8], 'exempt_roles' => []]], 'project'); $app->instance('config', $config); foreach ([ 'CREATE TABLE test_admin_role (admin_id INT, role_id INT)', 'CREATE TABLE test_system_role (id INT PRIMARY KEY, name TEXT, data_scope INT, delete_time INT NULL)', 'CREATE TABLE test_admin_dept (admin_id INT, dept_id INT)', 'CREATE TABLE test_dept (id INT PRIMARY KEY, pid INT, name TEXT, delete_time INT NULL)', 'CREATE TABLE test_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT, status INT, delete_time INT NULL)', 'CREATE TABLE test_doctor_appointment (id INT PRIMARY KEY, patient_id INT, doctor_id INT, status INT)', ] as $ddl) { Db::execute($ddl); } Db::name('system_role')->insertAll([ ['id' => 1, 'name' => '医生', 'data_scope' => 1, 'delete_time' => null], ['id' => 2, 'name' => '医助', 'data_scope' => 4, 'delete_time' => null], ['id' => 3, 'name' => '管理员', 'data_scope' => 1, 'delete_time' => null], ['id' => 7, 'name' => '已删除经理', 'data_scope' => 1, 'delete_time' => 1], ['id' => 17, 'name' => '自定义主管', 'data_scope' => 3, 'delete_time' => null], ['id' => 18, 'name' => '自定义区域主管', 'data_scope' => 2, 'delete_time' => null], ['id' => 19, 'name' => '普通功能', 'data_scope' => 1, 'delete_time' => null], ]); Db::name('dept')->insertAll([ ['id' => 10, 'pid' => 0, 'name' => 'Synthetic team'], ['id' => 20, 'pid' => 10, 'name' => 'Synthetic child'], ['id' => 30, 'pid' => 0, 'name' => 'Synthetic other'], ]); Db::name('admin_dept')->insertAll([ ['admin_id' => 11, 'dept_id' => 10], ['admin_id' => 12, 'dept_id' => 10], ['admin_id' => 13, 'dept_id' => 20], ['admin_id' => 14, 'dept_id' => 30], ]); foreach ([101 => 11, 102 => 12, 103 => 13, 104 => 14, 105 => 14, 106 => 14, 107 => 14, 108 => 14] as $id => $assistant) { Db::name('tcm_diagnosis')->insert(['id' => $id, 'assistant_id' => $assistant, 'status' => 1]); } Db::name('doctor_appointment')->insertAll([ ['id' => 1, 'patient_id' => 105, 'doctor_id' => 11, 'status' => 3], ['id' => 2, 'patient_id' => 106, 'doctor_id' => 12, 'status' => 1], ['id' => 3, 'patient_id' => 107, 'doctor_id' => 13, 'status' => 4], ['id' => 4, 'patient_id' => 108, 'doctor_id' => 11, 'status' => 0], ]); $checks = 0; $expect = static function (bool $condition, string $label) use (&$checks): void { if (!$condition) { throw new RuntimeException($label); } $checks++; }; $setRoles = static function (array $roles): void { Db::name('admin_role')->where('admin_id', 11)->delete(); foreach ($roles as $role) { Db::name('admin_role')->insert(['admin_id' => 11, 'role_id' => $role]); } }; $actor = static fn(array $roles): array => ['id' => 11, 'admin_id' => 11, 'root' => 0, 'role_id' => $roles]; $patients = static function (array $info, ?array $currentRoles = null): array { $query = Db::name('tcm_diagnosis')->alias('d')->whereNull('d.delete_time')->where('d.status', 1); MyPatientLogic::applyScope($query, 11, $info, $currentRoles); return array_map('intval', $query->order('d.id')->column('d.id')); }; $setRoles([2]); $expect($patients($actor([3])) === [101], 'legacy call uses live assistant role instead of cached administrator'); $expect(MyPatientLogic::scopeMeta(11, $actor([3]))['mode'] === 'self', 'scope metadata uses same live role set'); $setRoles([1, 2]); $expect($patients($actor([1, 2])) === [101, 105], 'doctor and assistant see union of own relationships'); $expect(Scope::selfOnlyRoleIds(11, $actor([1, 2])) === [1, 2], 'legacy doctor ALL does not widen the assistant role'); $setRoles([2, 17]); $expect($patients($actor([2, 17])) === [101, 102, 105, 106], 'custom department supervisor plus assistant sees team union'); $expect(Scope::selfOnlyRoleIds(11, $actor([2, 17])) === [], 'custom team role removes frontline-only narrowing'); $setRoles([2, 18]); $expect($patients($actor([2, 18])) === [101, 102, 103, 105, 106, 107], 'custom parent-department scope includes descendants'); $setRoles([2, 7]); $expect(!Scope::hasTeamRole(11, $actor([2, 7])), 'deleted configured manager role is not a team grant'); $expect($patients($actor([2, 7])) === [101], 'deleted role relationship cannot expand patient scope'); $setRoles([19]); $expect(!Scope::hasTeamRole(11, $actor([19])) && $patients($actor([19])) === [101, 105], 'ordinary ALL functional role retains narrow relationship fallback'); $setRoles([3]); $expect(count($patients($actor([3]))) === 8, 'explicit administrator retains all-patient scope'); // The supplied role set is an internal current-read result, not a request parameter. $setRoles([3]); $expect(!Scope::hasTeamRole(11, $actor([3]), []), 'explicit empty effective roles never fall back to cache or database'); $expect($patients($actor([3]), []) === [101, 105], 'empty current roles do not reopen cached full scope'); $expect($patients($actor([3]), [2]) === [101], 'current assistant roles override stale manager in both branch and range'); $expect($patients($actor([3]), [2, 17]) === [101, 102, 105, 106], 'current custom team range cannot reuse stale administrator ALL scope'); $expect($patients($actor([2]), [2, 17]) === [101, 102, 105, 106], 'current custom team grant is not narrowed by old assistant cache'); $expect($patients($actor([2]), ['2', 17, 17, 0, -1]) === [101, 102, 105, 106], 'internal effective role ids are normalized'); $roleLocks = []; Db::event('before_select', static function ($query) use (&$roleLocks): void { if ($query->getTable() === 'test_system_role') { $roleLocks[] = $query->getOptions('lock'); } }); Scope::hasTeamRole(11, $actor([3]), [2, 17]); $expect(in_array(true, $roleLocks, true), 'effective-role team classification requests current metadata read'); // Exercise the real assistant-service integration, including its department snapshot checks. $setRoles([2, 17]); $teamActor = $actor([2, 17]); $expect(Access::visibleStaffIds(11, $teamActor) === [11, 12], 'custom team role enters existing staff/department snapshot validation'); $query = Db::name('tcm_diagnosis')->alias('d'); Access::applyScope($query, 11, $teamActor); $expect(array_map('intval', $query->order('d.id')->column('d.id')) === [101, 102, 105, 106], 'assistant-service patient scope recognizes the custom supervisor'); // Only inspect SQL construction for MySQL; every executed query still uses in-memory SQLite. final class ScopeMysqlQueryProbe extends think\db\Query { public array $rawPredicates = []; public function getConfig(string $name = '') { return $name === 'type' ? 'mysql' : parent::getConfig($name); } public function whereRaw(string $where, array $bind = [], string $logic = 'AND') { $this->rawPredicates[] = $where; return $this; } } foreach ([[1], [19], [2, 17]] as $roles) { $probe = new ScopeMysqlQueryProbe(Db::connect()); MyPatientLogic::applyScope($probe, 11, $actor([3]), $roles); $sql = implode(' ', $probe->rawPredicates); $expect(str_contains($sql, 'scope_apt') && str_contains($sql, ' FOR UPDATE)'), 'current appointment subquery retains lock for ' . implode(',', $roles)); } $setRoles([1]); $probe = new ScopeMysqlQueryProbe(Db::connect()); MyPatientLogic::applyScope($probe, 11, $actor([1])); $expect(!str_contains(implode(' ', $probe->rawPredicates), 'FOR UPDATE'), 'legacy optional-argument behavior remains unlocked'); $expect(!$app->initialized(), 'application configuration and .env were never initialized'); echo "MyPatientScopeMergeTest: {$checks} checks passed (real ORM/authorization, synthetic SQLite, no network).\n";