feat: implement doctor import completion feature and crawler enhancements
- 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.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# تکمیل فیچر ایمپورت پزشکان نظام پزشکی (قطعات باقیمانده)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (backend) + یک تغییر کوچک در `clinicpro-crawler/clinicpro_client.py`
|
||||
|
||||
> **برنچ:** تغییرات backend روی برنچ جدید در repo خود clinicpro: `git -C clinicpro checkout -b feature/irimc-doctor-import`
|
||||
> (تغییر کرالر در repo والد است — همانجا commit شود.)
|
||||
|
||||
## زمینه
|
||||
|
||||
سند سناریو: [docs/scenarios/ایمپورت-پزشکان-نظام-پزشکی.md](../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/` |
|
||||
|
||||
**چهار قطعه از سند هنوز پیاده نشده** — این پرامپت فقط همانهاست:
|
||||
|
||||
1. نقش `ROLE_UNCLAIMED_DOCTOR` برای کاربر جانشین (§۳ سند) — الان جانشین فقط `ROLE_USER` میگیرد.
|
||||
2. اندپوینت انتقال مالکیت `POST /api/v1/admin/doctors/{uuid}/transfer` (§۴) — متد entity هست، کنترلر **نیست**.
|
||||
3. حذف امن کاربر جانشین بعد از انتقال (§۳) — وابسته به ۱ و ۲.
|
||||
4. رد شدن کپچا برای لاگین سرویسیِ کرالر (§۷) — الان لاگین 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` (خط ~۵۱۴) — **بدون نقش اختصاصی**:
|
||||
|
||||
```php
|
||||
$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:
|
||||
|
||||
```php
|
||||
// Doctor.php:388-396 — user_id را پر میکند، مدیریت سیستمی را برمیدارد و وضعیت را claimed میکند.
|
||||
public function transferOwnershipTo(User $user): self
|
||||
{
|
||||
...
|
||||
$this->ownerStatus = 'claimed';
|
||||
$this->claimedAt = time();
|
||||
```
|
||||
|
||||
کپچا (بدون استثنا):
|
||||
|
||||
```php
|
||||
// PasswordAuthenticator.php:49 — ابتدای authenticate()
|
||||
$this->captcha->assertValid($request);
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. نقش `ROLE_UNCLAIMED_DOCTOR` برای کاربر جانشین
|
||||
|
||||
در `importDoctor`، هنگام ساخت کاربر جانشین:
|
||||
|
||||
```php
|
||||
$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`):
|
||||
|
||||
```php
|
||||
#[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`:
|
||||
|
||||
```php
|
||||
$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 مطابق §۹ سند:
|
||||
```bash
|
||||
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 2
|
||||
```
|
||||
سپس یک transfer دستی با curl و بررسی: `owner_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 الزامی است (قانون پروژه).
|
||||
Reference in New Issue
Block a user