- Extract import logic from AdminApiController into DoctorImportService (thin DoctorImportController keeps the same route/contract) - Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command app:doctors:backfill-surrogate-role) enabling safe deletion after claim - DB-level UNIQUE (source, medical_system_code) + concurrent-import retry - Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks via existing ApiIrService, Persian name normalization (PersianText), pessimistic-lock race protection, DoctorClaimRequest audit table (national code hashed, mobile masked), doctor_claim rate limiter, public claim-info endpoint, welcome SMS - Admin support tools: manual transfer endpoint + paginated doctor-claims audit list + owner_status filter/fields in admin doctors list - Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped), import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER - Headless crawler login: X-Service-Token header bypasses captcha only (rate limit + password checks intact; empty env = no bypass) - docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md - tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.7 KiB
Doctor Import (IRIMC) API
Endpoint:
POST /api/v1/admin/doctors/importPermission:ROLE_ADMINیاROLE_IMPORTER(نقش حداقلی کاربر سیستمی کرالر) Controller:App\Doctor\Controller\DoctorImportController::import— منطق دامنه درApp\Doctor\Service\DoctorImportService
وارد کردن یک پزشک از سازمان نظام پزشکی (membersearch.irimc.org) بدون شماره موبایل.
برخلاف POST /api/v1/admin/doctors (که موبایل معتبر ایرانی میخواهد)، این اندپوینت برای
هر پزشک یک کاربر جانشینِ غیرفعال با شناسهٔ مصنوعی و نقشِ اختصاصی ROLE_UNCLAIMED_DOCTOR
میسازد و پروفایل را در وضعیت unclaimed ذخیره میکند تا بعداً به پزشک واقعی منتقل شود.
این نقش، هم کاربر جانشین را قابلشناسایی میکند و هم مبنای حذفِ امنِ او پس از انتقال است.
مصرفکنندهٔ اصلی: خزندهٔ پایتون (clinicpro-crawler/pipeline.py) که با کاربر «مالک
سیستمی» (0000000000) لاگین میکند و هر ~۶۰ ثانیه یک پزشک را میفرستد.
مدل مالکیت (ستونهای جدید doctors)
| ستون | مقدار هنگام ایمپورت | توضیح |
|---|---|---|
owner_status |
unclaimed |
claimed | unclaimed | pending_transfer |
source |
irimc |
manual (پیشفرض رکوردهای قدیمی) | irimc |
source_ref |
profile_url |
شناسهٔ رکورد مبدأ برای ممیزی |
managed_by |
id کاربر ادمینِ فراخوان | کاربری که پروفایل را مدیریت میکند |
claimed_at |
null |
زمان انتقال مالکیت (هنگام claim پر میشود) |
رکوردهای موجود در migration مقدار manual + claimed میگیرند تا رفتارشان تغییر نکند.
idempotency
کلید یکتای (source, medical_system_code) — از این نسخه در سطح دیتابیس هم unique است
(uniq_doctors_source_code، migration Version20260711150000)؛ درخواست همزمانِ همان پزشک
با retry داخلی به مسیر update میرود و هرگز رکورد تکراری نمیسازد.
- اگر پزشکی با همان
source+medical_system_codeوجود نداشته باشد → ساخته میشود (201). - اگر وجود داشته باشد و
owner_status != claimed→ بهروزرسانی میشود (200). - اگر وجود داشته باشد و
owner_status == claimed→ رد میشود (200,skipped: "claimed") تا دادهٔ مالک واقعی بازنویسی نشود.
Request
{
"name": "دکتر فرخنده حسینی",
"medical_system_code": "145657",
"source": "irimc",
"source_ref": "https://membersearch.irimc.org/member/profile?id=…",
"gender": "woman",
"degree": "general",
"info": "دکترای حرفهای پزشکی",
"specialties": [1],
"states": [23],
"cities": [123]
}
| فیلد | الزامی | توضیح |
|---|---|---|
name |
✅ | نام کامل پزشک |
medical_system_code |
✅ | کد نظام پزشکی (کلید idempotency) |
source |
— | پیشفرض irimc |
source_ref |
— | profile_url یا شناسهٔ مبدأ |
gender |
— | man | woman |
degree |
— | general | expert | specialist | subspecialistplus |
info |
— | متن تخصص/توضیح |
specialties / states / cities |
— | آرایهٔ شناسههای مرجع (id) |
gender/degreeباید با ثابتهایDoctor::GENDERS/Doctor::DEGREESسازگار باشند.specialties/states/citiesاگر داده شوند مجموعهٔ فعلی را جایگزین میکنند.
Response
201 Created (ساخته شد)
{ "success": true, "data": { "uuid": "…", "created": true } }
200 OK (بهروزرسانی شد)
{ "success": true, "data": { "uuid": "…", "created": false } }
200 OK (رد بهدلیل تصاحبشده)
{ "success": true, "data": { "uuid": "…", "created": false, "skipped": "claimed" } }
422 (اعتبارسنجی)
{ "success": false, "data": null, "errors": [ { "code": "…", "message": "کد نظام پزشکی الزامی است", "field": "medical_system_code" } ] }
عکس پروفایل
پس از ایمپورت، عکس با دو مرحله اضافه میشود (همان مسیر پزشکِ عادی):
POST /file/upload/clinic_pro/doctor/field_image— بدنه: باینری خام؛ هدرContent-Disposition: attachment; filename="145657.jpg". پاسخ:{ fid, uuid, url, filename, filemime, filesize }.PATCH /api/v1/doctor/{uuid}با بدنهٔ{ "image_data": <پاسخ مرحلهٔ ۱> }تا به آرایهٔimagesپروفایل افزوده شود.
انتقال مالکیت به پزشک واقعی
Endpoint:
POST /api/v1/admin/doctors/{uuid}/transfer— permissionROLE_ADMIN
مالکیت یک پروفایلِ unclaimed را به پزشک واقعی منتقل میکند.
// Request
{ "mobile": "09120000000" }
مراحل اتمیک:
- کاربر واقعی بر پایهٔ
mobileپیدا یا ساخته میشود (باید موبایل معتبر ایران باشد). - اگر کاربر واقعی از قبل صاحب پزشک دیگری باشد →
409. user_idپروفایل به کاربر واقعی تغییر میکند،ROLE_DOCTORبه او داده میشود،owner_status = claimedوclaimed_at = nowوmanaged_by = nullمیشود.- کاربر جانشین حذف میشود — فقط اگر واقعاً
ROLE_UNCLAIMED_DOCTORداشته باشد و دیگر هیچ پزشکی به او وصل نباشد (حذف امن).
// Response 200
{ "success": true, "data": {
"uuid": "…", "owner_status": "claimed",
"user_mobile": "09120000000",
"claim": { "uuid": "…" }
} }
این اندپوینت در
App\Doctor\Controller\DoctorClaimController::transferاست و همانDoctorClaimService::transferByAdminرا صدا میزند؛ هر انتقال یک رکورد ممیزی درdoctor_claim_requestsباverification_method = "admin_manual"میسازد. جریان self-claim پزشک (با احراز هویت API.ir) درdocs/api/doctor-claim.mdمستند است.
| کد | حالت |
|---|---|
404 |
پزشک یافت نشد |
409 |
قبلاً claimed است، یا کاربر مقصد پزشک دیگری دارد |
422 |
موبایل نامعتبر |
کاربر سیستمی و چرخهٔ عمر
# ساخت/فعالسازی کاربر مالک سیستمی (کرالر با این لاگین میکند)
php bin/console app:system-owner 0000000000 --password=<secret> --activate
# غیرفعالسازی پس از پایان کار (کرالر هم با --deactivate-on-finish این را از طریق API انجام میدهد)
php bin/console app:system-owner 0000000000 --deactivate
کاربر سیستمی least privilege است: فقط ROLE_USER,ROLE_IMPORTER میگیرد (اجرای مجدد
دستور، ROLE_ADMIN قدیمی را هم حذف میکند). ROLE_IMPORTER فقط به همین اندپوینت ایمپورت
دسترسی دارد و به هیچ اندپوینت /api/v1/admin/* دیگری راه ندارد (تست: DoctorImportTest::testImporterRoleCanImportButNothingElse).
لاگین سرویسی (captcha)
مسیر /api/v1/user/login کپچای ALTCHA دارد. برای لاگین headless کرالر، هدر سرّی
تعریف شده است:
X-Service-Token: <مقدار env CRAWLER_SERVICE_TOKEN>
- فقط کپچا دور زده میشود؛ rate limit و اعتبارسنجی رمز دستنخورده میمانند.
- اگر env خالی/تعریفنشده باشد هیچ bypass وجود ندارد (secure by default).
- چرخش credential: تغییر
CRAWLER_SERVICE_TOKEN+ تغییر رمز باapp:system-owner … --password=…؛ ابطال فوری:--deactivate.
backfill نقش جانشینهای قدیمی
جانشینهای ساختهشده قبل از افزودن نقش marker:
php bin/console app:doctors:backfill-surrogate-role --dry-run # فقط گزارش
php bin/console app:doctors:backfill-surrogate-role # اعمال