- DoctorClaimController: ALTCHA CaptchaGuard on /claim (dev no-op via ALTCHA_ENABLED=false); optional `mobile` field must match the logged-in user's number (422 ERR_CONFLICT_001 on mismatch) - DoctorController::delete: now IS_AUTHENTICATED_FULLY — admin (any) or the owner of a claimed profile (IDOR-guarded); FK appointment guard kept - DoctorDetailPage address map: MapController calls map.invalidateSize() before flyTo (fixes needing to pick a city twice on a freshly-mounted map); geocode retries once (nominatim empty/429 on first hit) - tests: mobile mismatch, owner-delete allowed + others 403, unclaimed not deletable by random user - docs: doctor-claim.md (mobile+captcha), doctor.md (delete permission) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.7 KiB
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
{ "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 — ۵ تلاش در ساعت بهازای هر (کاربر، پزشک)
Captcha: ALTCHA — بدنه باید payload کپچا بفرستد (CaptchaGuard::assertValid)؛ در dev با ALTCHA_ENABLED=false بیاثر است، در prod اجباری. خطا → ERR_CAPTCHA_001 (۴۲۲).
Request
{
"national_code": "0010007700",
"birth_date": "1371/1/1",
"first_name": "فرخنده",
"last_name": "حسینی",
"mobile": "09121234567",
"altcha": "<payload کپچا>"
}
| فیلد | الزامی | قاعده |
|---|---|---|
national_code |
✅ | ۱۰ رقم (ارقام فارسی پذیرفته و نرمال میشوند) |
birth_date |
✅ | شمسی Y/m/d |
first_name / last_name |
✅ | با هویت ثبت احوال و نام پروفایل تطبیق داده میشود (نرمالسازی ي/ی، ك/ک، نیمفاصله — PersianText) |
mobile |
❌ | اگر داده شود، باید با موبایل حساب کاربری یکی باشد وگرنه 422 ERR_CONFLICT_001؛ اگر خالی باشد از موبایل کاربر لاگینشده استفاده میشود |
altcha |
prod | payload کپچای ALTCHA |
مراحل سرور (اتمیک/ضد race)
- قفل
PESSIMISTIC_WRITEروی ردیف پزشک → اگرunclaimedنبود409؛ اگر کاربر از قبل پزشکی دارد409؛ اگر کد ملی متعلق به کاربر دیگری است409— سپسpending_transfer+ رکورد ممیزی (تراکنش کوتاه، بدون فراخوان خارجی داخل قفل). - شاهکار (
ApiIrService::shahkarMatch) — تطبیق موبایل کاربر با کد ملی. اگر API.ir پیکربندی نشده باشد، این گام skip و مبنا موبایلِ OTP-تأییدشده است. PersonInfo— تطبیق کدملی+تاریخ تولد؛alive=false→ رد.- تطبیق نام: ورودی کاربر ↔ هویت تأییدشده ↔ نام پروفایل (بدون پیشوند «دکتر»).
- نهاییسازی اتمیک:
user_id→ کاربر واقعی،ROLE_DOCTOR،national_code_verified=true،owner_status=claimed، حذف امنِ کاربر جانشین (فقط باROLE_UNCLAIMED_DOCTORو بدون پزشک دیگر). - پیامک خوشآمد (تمپلیت
welcome، async).
شکست در گامهای ۲-۴ پروفایل را به
unclaimedبرمیگرداند تا پزشک واقعی بتواند دوباره تلاش کند. نوبتدهی (active_doctor_appointment) خاموش میماند تا مالک جدید برنامهٔ کاری تعریف کند.
Response 200
{
"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)
{
"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 (نرمالسازی نام).