feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key
- 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>
This commit is contained in:
@@ -343,6 +343,7 @@ List all doctors with pagination.
|
||||
| `status` | string | ❌ | `"active"` or `"inactive"` |
|
||||
| `gender` | string | ❌ | `"male"` or `"female"` |
|
||||
| `specialty_id` | integer | ❌ | Filter by specialty |
|
||||
| `owner_status` | string | ❌ | `claimed` \| `unclaimed` \| `pending_transfer` — پروفایلهای ایمپورت IRIMC (خروجی هم `owner_status` و `source` دارد) |
|
||||
| `sort` | string | ❌ | Sort field |
|
||||
|
||||
### Response `200`
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Doctor Profile Claim API (تصاحب پروفایل پزشک ایمپورتشده)
|
||||
|
||||
> **Controller:** `App\Doctor\Controller\DoctorClaimController` — منطق در `App\Doctor\Service\DoctorClaimService`
|
||||
> **مصرفکننده:** سایت عمومی Nobat724 (همهٔ دامنهها) + پنل ادمین
|
||||
|
||||
پزشکِ ایمپورتشده از نظام پزشکی (`owner_status = unclaimed`) توسط پزشک واقعی تصاحب میشود.
|
||||
احراز هویت سمت سرور با **API.ir** انجام میشود (شاهکار: تطبیق موبایل↔کدملی؛ PersonInfo: تطبیق
|
||||
کدملی+تاریخ تولد و نام). هیچ درخواستی از فرانت به API.ir نمیرود و توکن API.ir هرگز به کلاینت
|
||||
نمیرسد. claim پس از تطبیق موفق **خودکار** نهایی میشود (بدون approve ادمین — تصمیم مستند در
|
||||
`.claude/prompt/irimc-import-complete.md` §۲.۲).
|
||||
|
||||
## چرخهٔ وضعیت
|
||||
|
||||
```
|
||||
unclaimed ──claim/transfer شروع──▶ pending_transfer ──موفق──▶ claimed
|
||||
▲ │شکست تطبیق/خطای استعلام
|
||||
└───────────────────────────────────┘ (برگشت، قابل تلاش مجدد)
|
||||
```
|
||||
|
||||
هر تلاش یک رکورد ممیزی در `doctor_claim_requests` میسازد — کد ملی فقط **hash sha256** و
|
||||
موبایل فقط **mask شده** ذخیره میشود؛ هیچ دادهٔ هویتی خام در DB یا لاگ نمیماند.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/doctor/{uuid}/claim-info`
|
||||
|
||||
**Permission:** عمومی (بدون JWT) — فقط برای رندر دکمهٔ «آیا شما این پزشک هستید؟»
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "success": true, "data": { "claimable": true, "owner_status": "unclaimed" } }
|
||||
```
|
||||
|
||||
| کد | حالت |
|
||||
|---|---|
|
||||
| `404` | پزشک یافت نشد |
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/doctor/{uuid}/claim`
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY` — کاربر با OTP لاگین شده (موبایلش تأییدشده است)
|
||||
**Rate limit:** limiter `doctor_claim` — ۵ تلاش در ساعت بهازای هر (کاربر، پزشک)
|
||||
|
||||
### Request
|
||||
```json
|
||||
{
|
||||
"national_code": "0010007700",
|
||||
"birth_date": "1371/1/1",
|
||||
"first_name": "فرخنده",
|
||||
"last_name": "حسینی"
|
||||
}
|
||||
```
|
||||
|
||||
| فیلد | الزامی | قاعده |
|
||||
|---|:---:|---|
|
||||
| `national_code` | ✅ | ۱۰ رقم (ارقام فارسی پذیرفته و نرمال میشوند) |
|
||||
| `birth_date` | ✅ | شمسی `Y/m/d` |
|
||||
| `first_name` / `last_name` | ✅ | با هویت ثبت احوال و نام پروفایل تطبیق داده میشود (نرمالسازی ي/ی، ك/ک، نیمفاصله — `PersianText`) |
|
||||
|
||||
### مراحل سرور (اتمیک/ضد race)
|
||||
|
||||
1. قفل `PESSIMISTIC_WRITE` روی ردیف پزشک → اگر `unclaimed` نبود `409`؛ اگر کاربر از قبل پزشکی دارد `409`؛ اگر کد ملی متعلق به کاربر دیگری است `409` — سپس `pending_transfer` + رکورد ممیزی (تراکنش کوتاه، بدون فراخوان خارجی داخل قفل).
|
||||
2. شاهکار (`ApiIrService::shahkarMatch`) — تطبیق موبایل کاربر با کد ملی. اگر API.ir پیکربندی نشده باشد، این گام skip و مبنا موبایلِ OTP-تأییدشده است.
|
||||
3. `PersonInfo` — تطبیق کدملی+تاریخ تولد؛ `alive=false` → رد.
|
||||
4. تطبیق نام: ورودی کاربر ↔ هویت تأییدشده ↔ نام پروفایل (بدون پیشوند «دکتر»).
|
||||
5. نهاییسازی اتمیک: `user_id` → کاربر واقعی، `ROLE_DOCTOR`، `national_code_verified=true`، `owner_status=claimed`، حذف امنِ کاربر جانشین (فقط با `ROLE_UNCLAIMED_DOCTOR` و بدون پزشک دیگر).
|
||||
6. پیامک خوشآمد (تمپلیت `welcome`، async).
|
||||
|
||||
> شکست در گامهای ۲-۴ پروفایل را به `unclaimed` برمیگرداند تا پزشک واقعی بتواند دوباره تلاش کند.
|
||||
> نوبتدهی (`active_doctor_appointment`) خاموش میماند تا مالک جدید برنامهٔ کاری تعریف کند.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "claimed",
|
||||
"claim": { "uuid": "…" },
|
||||
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Errors
|
||||
| کد | HTTP | حالت |
|
||||
|---|---|---|
|
||||
| `ERR_AUTH_001` | 401 | بدون لاگین |
|
||||
| `ERR_NOT_FOUND_001` | 404 | پزشک یافت نشد |
|
||||
| `ERR_CONFLICT_001` | 409 | پروفایل قابل تصاحب نیست / کاربر پزشک دیگری دارد / درخواست همزمان دیگری در جریان است |
|
||||
| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای کاربر دیگری ثبت شده است |
|
||||
| `ERR_VALIDATION_001/002` | 422 | کد ملی/تاریخ/نام نامعتبر |
|
||||
| `ERR_IDENTITY_001` | 422 | عدم تطبیق هویت (شاهکار/ثبت احوال/نام) — پیام عمومی، ضد enumeration |
|
||||
| `ERR_RATE_LIMIT_001` | 429 | عبور از سقف تلاش |
|
||||
| `ERR_EXTERNAL_001` | 502 | خطا/تایماوت API.ir |
|
||||
| `ERR_EXTERNAL_002` | 503 | API.ir پیکربندی نشده (env) |
|
||||
|
||||
---
|
||||
|
||||
## POST `/api/v1/admin/doctors/{uuid}/transfer`
|
||||
|
||||
انتقال دستی (ابزار پشتیبانی ادمین) — مستند کامل در `docs/api/doctor-import.md`.
|
||||
**Permission:** `ROLE_ADMIN`. بدنه `{ "mobile": "09…" }`. همان نهاییسازی claim را اجرا میکند
|
||||
و رکورد ممیزی با `verification_method="admin_manual"` میسازد.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/admin/doctor-claims`
|
||||
|
||||
**Permission:** `ROLE_ADMIN` — لیست ممیزی درخواستهای claim برای پشتیبانی عملیاتی.
|
||||
|
||||
| Query | پیشفرض | توضیح |
|
||||
|---|---|---|
|
||||
| `status` | همه | `pending` \| `completed` \| `failed` |
|
||||
| `page` / `limit` | 1 / 20 (سقف 50) | صفحهبندی استاندارد |
|
||||
|
||||
### Response `200` (paginated)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "…",
|
||||
"status": "completed",
|
||||
"doctor": { "uuid": "…", "name": "دکتر فرخنده حسینی" },
|
||||
"mobile_masked": "0912***4567",
|
||||
"verification_method": "apiir_personinfo+shahkar",
|
||||
"failure_reason": null,
|
||||
"created_at": 1783750000,
|
||||
"completed_at": 1783750040
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
`failure_reason` فارسی و انسانیخوان است تا ادمین بدون خواندن لاگ سرور علت شکست را ببیند.
|
||||
|
||||
---
|
||||
|
||||
## env های مرتبط
|
||||
|
||||
| متغیر | نقش |
|
||||
|---|---|
|
||||
| `APIIR_*` (baseUrl/token موجود `ApiIrService`) | استعلام شاهکار و PersonInfo |
|
||||
| `CRAWLER_SERVICE_TOKEN` | فقط برای لاگین سرویسی کرالر (`doctor-import.md`) — ربطی به claim ندارد |
|
||||
|
||||
## تستها
|
||||
|
||||
`tests/Doctor/DoctorClaimTest.php` (۱۱ سناریو، API.ir همیشه mock — هیچ تستی به سرویس واقعی
|
||||
درخواست نمیزند) و `tests/Shared/PersianTextTest.php` (نرمالسازی نام).
|
||||
+37
-13
@@ -1,8 +1,8 @@
|
||||
# Doctor Import (IRIMC) API
|
||||
|
||||
> **Endpoint:** `POST /api/v1/admin/doctors/import`
|
||||
> **Permission:** `ROLE_ADMIN`
|
||||
> **Controller:** `App\Admin\Controller\AdminApiController::importDoctor`
|
||||
> **Permission:** `ROLE_ADMIN` **یا** `ROLE_IMPORTER` (نقش حداقلی کاربر سیستمی کرالر)
|
||||
> **Controller:** `App\Doctor\Controller\DoctorImportController::import` — منطق دامنه در `App\Doctor\Service\DoctorImportService`
|
||||
|
||||
وارد کردن یک پزشک از سازمان نظام پزشکی (`membersearch.irimc.org`) **بدون شماره موبایل**.
|
||||
برخلاف `POST /api/v1/admin/doctors` (که موبایل معتبر ایرانی میخواهد)، این اندپوینت برای
|
||||
@@ -31,7 +31,9 @@
|
||||
|
||||
## idempotency
|
||||
|
||||
کلید یکتای منطقی: `(source, medical_system_code)`.
|
||||
کلید یکتای `(source, medical_system_code)` — از این نسخه **در سطح دیتابیس** هم unique است
|
||||
(`uniq_doctors_source_code`، migration `Version20260711150000`)؛ درخواست همزمانِ همان پزشک
|
||||
با retry داخلی به مسیر update میرود و هرگز رکورد تکراری نمیسازد.
|
||||
|
||||
- اگر پزشکی با همان `source`+`medical_system_code` وجود نداشته باشد → **ساخته** میشود (`201`).
|
||||
- اگر وجود داشته باشد و `owner_status != claimed` → **بهروزرسانی** میشود (`200`).
|
||||
@@ -132,10 +134,16 @@
|
||||
// Response 200
|
||||
{ "success": true, "data": {
|
||||
"uuid": "…", "owner_status": "claimed",
|
||||
"transferred_to": "09120000000", "placeholder_deleted": true
|
||||
"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` | پزشک یافت نشد |
|
||||
@@ -154,13 +162,29 @@ php bin/console app:system-owner 0000000000 --password=<secret> --activate
|
||||
php bin/console app:system-owner 0000000000 --deactivate
|
||||
```
|
||||
|
||||
کاربر باید `ROLE_ADMIN` و `status=1` داشته باشد تا لاگینِ رمزی (`POST /api/v1/user/login`)
|
||||
و فراخوانی این اندپوینت ممکن باشد.
|
||||
کاربر سیستمی **least privilege** است: فقط `ROLE_USER,ROLE_IMPORTER` میگیرد (اجرای مجدد
|
||||
دستور، `ROLE_ADMIN` قدیمی را هم حذف میکند). `ROLE_IMPORTER` فقط به همین اندپوینت ایمپورت
|
||||
دسترسی دارد و به هیچ اندپوینت `/api/v1/admin/*` دیگری راه ندارد (تست: `DoctorImportTest::testImporterRoleCanImportButNothingElse`).
|
||||
|
||||
> ⚠️ **captcha:** مسیر `/api/v1/user/login` از `CaptchaGuard` رد میشود و این guard وقتی
|
||||
> `ALTCHA_ENABLED=true` باشد (مقدار فعلی `.env`) یک payloadِ altcha میخواهد. برای اجرای
|
||||
> بدونِمرورگرِ کرالر یکی از اینها لازم است:
|
||||
> ۱) روی همان سرور `ALTCHA_ENABLED=false` در `.env.local` (سادهترین برای dev)، یا
|
||||
> ۲) افزودن یک استثنا در `PasswordAuthenticator` که برای کاربر مالک سیستمی captcha را رد کند،
|
||||
> یا ۳) لاگین سرویس با یک هدر سرّیِ مورد اعتماد. تا وقتی این حل نشود، لاگین کرالر با ۴۲۲
|
||||
> (`ERR_CAPTCHA_001`) رد میشود.
|
||||
### لاگین سرویسی (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:
|
||||
|
||||
```bash
|
||||
php bin/console app:doctors:backfill-surrogate-role --dry-run # فقط گزارش
|
||||
php bin/console app:doctors:backfill-surrogate-role # اعمال
|
||||
```
|
||||
|
||||
+7
-1
@@ -1,5 +1,9 @@
|
||||
# Doctor API
|
||||
|
||||
> فیلد `owner_status` (`claimed` | `unclaimed` | `pending_transfer`) به خروجی لیست و جزئیات پزشک
|
||||
> اضافه شده است — پروفایل `unclaimed` (ایمپورت نظام پزشکی) در سایت دکمهٔ «تصاحب پروفایل» میگیرد
|
||||
> (`docs/api/doctor-claim.md`) و نوبتدهی آنلاینش غیرفعال است.
|
||||
|
||||
> **Prefix:** `/api/v1/doctor`, `/api/v1/doctors`, `/api/v1/clinic-pro/doctor-address*`
|
||||
>
|
||||
> Numeric path params on the address routes (`doctor-address/{id}`, `doctor-addresses/{doctorId}`) require `\d+`; a non-numeric value returns a clean `404` instead of a `500`.
|
||||
@@ -101,6 +105,7 @@ Get doctor detail with clinics.
|
||||
"free_turn": "دوشنبه 09:00–13:00",
|
||||
"hours_of_work": "شنبه: 09:00–13:00 و 14:00–18:00 | یکشنبه: 09:00–13:00",
|
||||
"active": true,
|
||||
"owner_status": "claimed",
|
||||
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق", "parent_id": null }],
|
||||
"expertise": [{ "uuid": "...", "id": "3", "name": "نوار قلب" }],
|
||||
"address": [],
|
||||
@@ -204,7 +209,8 @@ List doctors with pagination and filters.
|
||||
"point": "3.5",
|
||||
"free_turn": "دوشنبه 09:00–13:00",
|
||||
"hours_of_work": "شنبه: 09:00–13:00 و 14:00–18:00 | یکشنبه: 09:00–13:00",
|
||||
"active": true
|
||||
"active": true,
|
||||
"owner_status": "claimed"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
||||
Reference in New Issue
Block a user