- Added the remaining components for the doctor import feature in the backend, including role management, ownership transfer endpoint, and captcha bypass for crawler service login. - Created detailed scenarios for the doctor claim process, ensuring proper identity verification and mobile validation. - Established a crawler interface for token management and state tracking using SQLite, enabling a resume capability for the crawling process.
11 KiB
تکمیل فیچر ایمپورت پزشکان نظام پزشکی (قطعات باقیمانده)
پروژه
clinicpro (backend) + یک تغییر کوچک در clinicpro-crawler/clinicpro_client.py
برنچ: تغییرات backend روی برنچ جدید در repo خود clinicpro:
git -C clinicpro checkout -b feature/irimc-doctor-import(تغییر کرالر در repo والد است — همانجا commit شود.)
زمینه
سند سناریو: docs/scenarios/ایمپورت-پزشکان-نظام-پزشکی.md. بخش عمدهٔ فیچر قبلاً پیاده شده و در کد موجود است — دوباره نساز:
| قطعه | وضعیت |
|---|---|
ستونهای مالکیت doctors (owner_status, source, source_ref, managed_by, claimed_at) + متد transferOwnershipTo() |
✅ src/Doctor/Entity/Doctor.php:81-98,390 |
| Migration | ✅ migrations/Version20260711120000.php (اعمالشده) |
POST /api/v1/admin/doctors/import — idempotent، کاربر جانشین imp_<hash>، skip روی claimed |
✅ src/Admin/Controller/AdminApiController.php:483 |
دستور app:system-owner |
✅ src/Auth/Command/SystemOwnerCommand.php |
| مستند | ✅ docs/api/doctor-import.md |
کرالر (clinicpro_client.py, pipeline.py) |
✅ clinicpro-crawler/ |
چهار قطعه از سند هنوز پیاده نشده — این پرامپت فقط همانهاست:
- نقش
ROLE_UNCLAIMED_DOCTORبرای کاربر جانشین (§۳ سند) — الان جانشین فقطROLE_USERمیگیرد. - اندپوینت انتقال مالکیت
POST /api/v1/admin/doctors/{uuid}/transfer(§۴) — متد entity هست، کنترلر نیست. - حذف امن کاربر جانشین بعد از انتقال (§۳) — وابسته به ۱ و ۲.
- رد شدن کپچا برای لاگین سرویسیِ کرالر (§۷) — الان لاگین headless با
ERR_CAPTCHA_001میشکند.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Admin/Controller/AdminApiController.php |
importDoctor خط ۴۸۳ (اصلاح نقش) + اندپوینت transfer جدید |
src/Doctor/Entity/Doctor.php |
transferOwnershipTo(User) خط ۳۹۰ — آماده، فقط صدا بزن |
src/Auth/Entity/User.php |
addRole() خط ۱۰۵، hasRole() خط ۱۱۴، setStatus() |
src/Auth/Security/PasswordAuthenticator.php |
خط ۴۹: $this->captcha->assertValid($request) — نقطهٔ bypass |
src/Shared/Captcha/CaptchaGuard.php |
گارد کپچا (برای فهم امضا) |
docs/api/doctor-import.md |
باید transfer + هدر سرویس مستند شود |
clinicpro-crawler/clinicpro_client.py |
افزودن هدر سرویس به لاگین |
وضعیت فعلی
ساخت کاربر جانشین در importDoctor (خط ~۵۱۴) — بدون نقش اختصاصی:
$synthetic = 'imp_' . substr(md5($source . ':' . $code), 0, 14);
$user = $userRepo->findOneBy(['mobileNumber' => $synthetic]);
if ($user === null) {
$user = new User($synthetic);
$user->setRealName($name);
$user->setStatus(0); // جانشین: هرگز لاگین نمیکند
$this->em->persist($user);
}
متد آمادهٔ entity:
// Doctor.php:388-396 — user_id را پر میکند، مدیریت سیستمی را برمیدارد و وضعیت را claimed میکند.
public function transferOwnershipTo(User $user): self
{
...
$this->ownerStatus = 'claimed';
$this->claimedAt = time();
کپچا (بدون استثنا):
// PasswordAuthenticator.php:49 — ابتدای authenticate()
$this->captcha->assertValid($request);
وظایف
۱. نقش ROLE_UNCLAIMED_DOCTOR برای کاربر جانشین
در importDoctor، هنگام ساخت کاربر جانشین:
$user = new User($synthetic);
$user->setRealName($name);
$user->setStatus(0);
$user->addRole('ROLE_UNCLAIMED_DOCTOR');
$this->em->persist($user);
- نقش را بهصورت رشته اضافه کن (الگوی موجود
addRole('ROLE_DOCTOR')در پروژه). - ایمپورتهای قبلی (جانشینهای موجود بدون این نقش): چون idempotent است، در همان
importDoctorوقتی$doctor !== null && unclaimedاست هم نقش را به کاربر فعلیاش تضمین کن (if (!$user->hasRole(...)) addRole(...)) — کاربر جانشین از$doctor->getUser()در دسترس است.
۲. اندپوینت انتقال مالکیت
در AdminApiController (کنار importDoctor، همان الگوی OA + $this->success/error):
#[Route('/api/v1/admin/doctors/{uuid}/transfer', methods: ['POST'])]
public function transferDoctor(string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim((string) ($data['mobile'] ?? ''));
if (!preg_match('/^09\d{9}$/', $mobile)) {
return $this->error(ErrorCodes::VALIDATION, 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
if ($doctor === null) {
return $this->error(ErrorCodes::NOT_FOUND, 'پزشک یافت نشد', 404);
}
if ($doctor->getOwnerStatus() === 'claimed') {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این پروفایل قبلاً تصاحب شده است', 409);
}
$userRepo = $this->em->getRepository(User::class);
$target = $userRepo->findOneBy(['mobileNumber' => $mobile]);
if ($target === null) {
$target = new User($mobile);
$target->setRealName($doctor->getName());
$target->setStatus(1);
$this->em->persist($target);
}
// قید یکتای user_id: کاربر هدف نباید از قبل پزشک دیگری داشته باشد
$already = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $target]);
if ($already !== null && $already->getId() !== $doctor->getId()) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً پروفایل پزشک دیگری دارد', 409);
}
$surrogate = $doctor->getUser();
$target->addRole('ROLE_DOCTOR');
$doctor->transferOwnershipTo($target);
// حذف امن جانشین: فقط اگر واقعاً جانشین است و هیچ پزشک دیگری به او وصل نیست
if ($surrogate !== null
&& $surrogate->getId() !== $target->getId()
&& $surrogate->hasRole('ROLE_UNCLAIMED_DOCTOR')
&& $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) {
$this->em->remove($surrogate);
}
$this->em->flush();
return $this->success([
'uuid' => $doctor->getUuid(),
'owner_status' => $doctor->getOwnerStatus(),
'user_mobile' => $mobile,
]);
}
نکتهها:
- ترتیب: اول
transferOwnershipTo(کهuser_idرا عوض میکند)، بعد شمارش پزشکانِ جانشین — دقت کن Doctrine تاflushتغییر را به DB نمیبرد، پسcount(['user' => $surrogate])ممکن است هنوز همین پزشک را بشمارد. یا اول flush کن بعد حذف در flush دوم، یا شرط راcount === 0 || (count === 1 && همین doctor)بگذار. سناریوی سادهٔ امن: دو مرحله —flush()بعد از transfer، سپس شمارش وremove($surrogate)وflush()دوم. - امضای دقیق فیلد رابطهٔ
Doctor::userرا قبل ازfindOneBy(['user' => ...])از entity تأیید کن. - ثابتهای
ErrorCodesموجود (VALIDATION,NOT_FOUND,ERR_CONFLICT_001) — چیز جدید نساز. - بلوک OA (سواگر) مثل
importDoctorبنویس: body{ mobile }، پاسخهای 200/404/409/422.
۳. رد شدن کپچا برای لاگین سرویسی کرالر
راه انتخابی سند (§۷، گزینهٔ «هدر سرّی مورد اعتماد»)، امنتر از خاموشکردن ALTCHA:
در PasswordAuthenticator::authenticate()، قبل از assertValid:
$serviceToken = $_ENV['CRAWLER_SERVICE_TOKEN'] ?? '';
$sentToken = (string) $request->headers->get('X-Service-Token', '');
$isServiceLogin = $serviceToken !== '' && hash_equals($serviceToken, $sentToken);
if (!$isServiceLogin) {
$this->captcha->assertValid($request);
}
- فقط کپچا دور زده میشود؛ rate-limit و اعتبارسنجی رمز سر جای خود میمانند.
- اگر env خالی باشد هیچ bypass وجود ندارد (پیشفرض امن).
- env جدید را به
.env(خالی) و.env.exampleاضافه کن + ذکر در مستند. - ترجیحاً env را از طریق constructor bind کن (الگوی
services.yamlمثل$appUrl: '%env(APP_BASE_URL)%') نه$_ENVمستقیم — با الگوی موجود فایل هماهنگ شو.
۴. کرالر: ارسال هدر سرویس
در clinicpro-crawler/clinicpro_client.py، متد لاگین: اگر env CLINICPRO_SERVICE_TOKEN ست بود، هدر X-Service-Token را به درخواست لاگین اضافه کن (فقط لاگین کافی است). به .env.example کرالر هم اضافه کن.
۵. مستند + تست
docs/api/doctor-import.md: بخش transfer (method/path/permission/body/پاسخها/خطاها با مثال JSON) + توضیحX-Service-Tokenبرای لاگین سرویسی + نقشROLE_UNCLAIMED_DOCTOR.- تست e2e مطابق §۹ سند:
سپس یک transfer دستی با curl و بررسی:
ddev exec php bin/console app:system-owner 0000000000 --password=test123 --activate cd ../clinicpro-crawler && CLINICPRO_PASSWORD=test123 .venv/bin/python pipeline.py \ --mode file --file output/یاسوج/doctors.json --no-photos --limit 2 --interval 2owner_status=claimed، کاربر جانشین حذفشده، کاربر واقعیROLE_DOCTORدارد. - تستهای موجود
tests/Admin/tests/Doctorرا اجرا کن؛ اگر تست importDoctor وجود دارد، case انتقال را کنارش اضافه کن.
نکات مهم
transferOwnershipToاز قبلclaimed_at/owner_status/managed_byرا هندل میکند — منطق را در کنترلر تکرار نکن.- پروفایل
pending_transferدر این فاز فقط یک مقدار enum است؛ جریان درخواست تصاحب از سمت Nobat724 فاز بعدی است (§۹) — نساز. - حذف جانشین باید دقیقاً سه شرط سند را داشته باشد: نقش
ROLE_UNCLAIMED_DOCTOR+ هیچ پزشک متصل + غیر از کاربر هدف. کاربر واقعی را هرگز حذف نکن. activeDoctorAppointmentبعد از transfer دست نزن — روشنکردن نوبتدهی با مالک جدید است.- بعد از تغییر API، بهروزرسانی
docs/api/doctor-import.mdدر همین session الزامی است (قانون پروژه).