feat(auth): add optional domain parameter to send OTP code for site personalization
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# افزودن اسم سایت شهر به پیامک کد تأیید (OTP) — بهصورت اختیاری
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend) — **cross-repo**؛ پرامپت همتای frontend: `nobat724_front/.claude/prompt/otp-sms-site-name.md`
|
||||
|
||||
## زمینه
|
||||
|
||||
سایت عمومی چند-دامنهای است (هر شهر یک دامنه: `yasuj-nobat.ir`، `ahvaz-nobat.ir`، ...). هر شهر در جدول `cities` فیلد `site_name` دارد (مثل «یاسوج نوبت»). الان پیامک کد تأیید ورود ثابت است: `کد تأیید شما: {code}` و اسم سایت شهر ندارد.
|
||||
|
||||
هدف: بتوان **اختیاری** اسم سایتِ شهرِ درخواستکننده را در پیامک OTP نمایش داد — مثلاً «کد تأیید شما در یاسوج نوبت: ۱۲۳۴۵». اختیاریبودن از طریق **قالب قابلویرایش پنل SMS** کنترل میشود: اگر ادمین `{site}` را در متن قالب بگذارد نمایش داده میشود، نگذارد نمیشود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
- درخواست `send-code` از دامنهی شهر به API (`api.clinic-pro.ir`) میرسد → `Host` = دامنهی API، نه شهر. پس backend خودش شهر را نمیفهمد.
|
||||
- **راهحل:** frontend دامنهی خودش را در body میفرستد؛ backend از روی `domain` در جدول `cities` → `site_name` را پیدا میکند و بهعنوان متغیر `{site}` به قالب پیامک تزریق میکند. اگر شهری پیدا نشد یا `domain` نیامد → fallback به برند پیشفرض «نوبت ۷۲۴».
|
||||
- متغیر `{site}` همیشه مقداردهی میشود؛ استفاده یا عدماستفادهاش به قالب بستگی دارد (اختیاری).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Auth/Controller/AuthController.php` | endpoint `POST /api/v1/user/send-code` — خواندن `domain` اختیاری از body |
|
||||
| `src/Auth/Service/OtpService.php` | ساخت و ارسال پیامک OTP — resolve اسم سایت و تزریق `{site}` |
|
||||
| `src/Location/Repository/CityRepository.php` | lookup شهر بر اساس `domain` |
|
||||
| `src/Location/Entity/City.php` | دارای `getDomain()` و `getSiteName()` (آماده، تغییر نمیخواهد) |
|
||||
| `src/Sms/Entity/SmsMessageTemplate.php` | ثبت `{site}` بهعنوان متغیر مجاز قالب OTP |
|
||||
| `docs/api/auth.md`, `docs/api/sms.md` | مستندسازی |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`OtpService::sendCode` (src/Auth/Service/OtpService.php):
|
||||
|
||||
```php
|
||||
public function sendCode(string $mobile): string
|
||||
{
|
||||
$uuid = Uuid::v4()->toRfc4122();
|
||||
$code = $this->appEnv === 'dev' ? '12345' : str_pad((string) random_int(10000, 99999), 5, '0', STR_PAD_LEFT);
|
||||
// ... cache set ...
|
||||
if ($this->appEnv !== 'dev') {
|
||||
$message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code]);
|
||||
$this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP);
|
||||
}
|
||||
return $uuid;
|
||||
}
|
||||
```
|
||||
|
||||
`AuthController::sendCode` (src/Auth/Controller/AuthController.php:136):
|
||||
|
||||
```php
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($data['mobile'] ?? '');
|
||||
// ... validation + rate limit ...
|
||||
$uuid = $this->otpService->sendCode($mobile);
|
||||
```
|
||||
|
||||
قالب پیشفرض OTP (src/Sms/Entity/SmsMessageTemplate.php):
|
||||
|
||||
```php
|
||||
SmsLog::TAG_OTP => [
|
||||
'title' => 'کد تأیید ورود',
|
||||
'body' => 'کد تأیید شما: {code}',
|
||||
'variables' => ['code'],
|
||||
],
|
||||
```
|
||||
|
||||
resolver (src/Sms/Service/SmsTextResolver.php) هر `{key}` را با str_replace جایگزین میکند — پس `{site}` بدون تغییر resolver کار میکند.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. `OtpService` — resolve اسم سایت و تزریق `{site}`
|
||||
|
||||
- `CityRepository` را به constructor تزریق کن.
|
||||
- امضای متد را به `sendCode(string $mobile, ?string $domain = null)` تغییر بده (پارامتر اختیاری → سازگاری عقبرو).
|
||||
- اسم سایت را resolve کن و به متغیرهای قالب اضافه کن:
|
||||
|
||||
```php
|
||||
public function sendCode(string $mobile, ?string $domain = null): string
|
||||
{
|
||||
// ... code + cache بدون تغییر ...
|
||||
if ($this->appEnv !== 'dev') {
|
||||
$site = $this->resolveSiteName($domain);
|
||||
$message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code, 'site' => $site]);
|
||||
$this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP);
|
||||
}
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
private function resolveSiteName(?string $domain): string
|
||||
{
|
||||
$default = 'نوبت ۷۲۴';
|
||||
if (!$domain) {
|
||||
return $default;
|
||||
}
|
||||
$domain = strtolower(trim(preg_replace('#^https?://#', '', $domain)));
|
||||
$domain = preg_replace('#/.*$#', '', $domain); // strip path
|
||||
$city = $this->cityRepo->findOneBy(['domain' => $domain]);
|
||||
return $city?->getSiteName() ?: $default;
|
||||
}
|
||||
```
|
||||
|
||||
- **نکته:** `{site}` همیشه مقدار دارد؛ اگر قالب فعلی `{site}` نداشته باشد، این متغیر بیاثر است (اختیاریبودن حفظ میشود).
|
||||
|
||||
### ۲. `AuthController::sendCode` — خواندن `domain` اختیاری از body
|
||||
|
||||
```php
|
||||
$mobile = trim($data['mobile'] ?? '');
|
||||
$domain = isset($data['domain']) ? trim((string) $data['domain']) : null;
|
||||
// ... validation + rate limit بدون تغییر ...
|
||||
$uuid = $this->otpService->sendCode($mobile, $domain ?: null);
|
||||
```
|
||||
|
||||
- `domain` اختیاری است؛ اگر نیامد، رفتار قبلی حفظ میشود.
|
||||
- روی `domain` **validation سختگیرانه لازم نیست** (فقط برای lookup استفاده میشود، نه اجرا)، اما trim و طول محدود (مثلاً حداکثر ۲۵۳ کاراکتر) اعمال کن.
|
||||
|
||||
### ۳. `SmsMessageTemplate` — ثبت `{site}` بهعنوان متغیر مجاز OTP
|
||||
|
||||
قالب پیشفرض را **بدون** `{site}` نگه دار (opt-in)، اما `site` را به لیست `variables` اضافه کن تا در پنل ویرایش قالب بهعنوان متغیر قابلاستفاده نمایش داده شود:
|
||||
|
||||
```php
|
||||
SmsLog::TAG_OTP => [
|
||||
'title' => 'کد تأیید ورود',
|
||||
'body' => 'کد تأیید شما: {code}', // پیشفرض بدون site
|
||||
'variables' => ['code', 'site'], // site بهعنوان متغیر اختیاری در پنل
|
||||
],
|
||||
```
|
||||
|
||||
- اینطوری ادمین در پنل SMS میتواند body را به `کد تأیید شما در {site}: {code}` تغییر دهد تا اسم سایت نمایش داده شود — یا ندهد.
|
||||
|
||||
### ۴. مستندسازی
|
||||
|
||||
- `docs/api/auth.md`: در `POST /api/v1/user/send-code`، فیلد **اختیاری** `domain` را به request body اضافه کن (نوع string، توضیح: دامنهی شهرِ درخواستکننده برای شخصیسازی متن پیامک؛ اگر نیامد از برند پیشفرض استفاده میشود). نمونه request/response را بهروز کن.
|
||||
- `docs/api/sms.md`: متغیر جدید `{site}` را برای قالب `OTP` مستند کن (مقدار = `site_name` شهرِ دامنه یا «نوبت ۷۲۴»).
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **backward-compatible**: هر دو `domain` (body) و پارامتر `sendCode` اختیاریاند؛ کلاینتهای فعلی (پنل ادمین clinicpro، لاگین بدون domain) بدون تغییر کار میکنند و برند پیشفرض را میگیرند.
|
||||
- محیط `dev`: پیامک ارسال نمیشود (کد ثابت `12345`) — این مسیر دستنخورده بماند.
|
||||
- `{site}` هرگز خالی نباشد (fallback «نوبت ۷۲۴») تا اگر ادمین `{site}` را در قالب گذاشت، متن ناقص «... در : ...» تولید نشود.
|
||||
- lookup دامنه: مقدار ارسالی frontend ممکن است `yasuj-nobat.ir` یا با `www.`/`https://` باشد — در `resolveSiteName` normalize کن (حذف پروتکل و path). اگر لازم شد `www.` را هم strip کن، اما مقادیر `cities.domain` بدون `www` ذخیره شدهاند (مثل `yasuj-nobat.ir`).
|
||||
- **پیامک OTP با پترن اپراتور:** provider پترن `{site}` را پشتیبانی میکند (تأییدشده). مطمئن شو ترتیب/نام متغیرها با پترن مصوب سامانهی پیامکی هماهنگ است.
|
||||
- تست:
|
||||
```bash
|
||||
ddev exec php -l src/Auth/Service/OtpService.php
|
||||
ddev exec php -l src/Auth/Controller/AuthController.php
|
||||
ddev exec php bin/console cache:clear
|
||||
# ارسال با domain و بدون domain را در محیط staging بررسی کن (dev پیامک نمیفرستد)
|
||||
```
|
||||
- بعد از تغییر API، `docs/api/auth.md` و `docs/api/sms.md` را در همین session بهروز کن (قانون استاندینگ پروژه).
|
||||
+3
-1
@@ -14,13 +14,15 @@ Send OTP code to mobile number.
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"mobile": "09123456789"
|
||||
"mobile": "09123456789",
|
||||
"domain": "yasuj-nobat.ir"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Validation |
|
||||
|-------|------|----------|------------|
|
||||
| `mobile` | string | ✅ | Format: `09XXXXXXXXX` (11 digits) |
|
||||
| `domain` | string | ⬜ | دامنهی شهرِ درخواستکننده (multi-domain). اگر ارسال شود، `site_name` شهرِ متناظر در جدول `cities` پیدا شده و بهعنوان متغیر `{site}` در متن پیامک OTP قابل استفاده است (شخصیسازی متن). اگر نیامد یا شهر پیدا نشد → مقدار پیشفرض «کلینیک پرو». حداکثر ۲۵۳ کاراکتر. |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
|
||||
+3
-1
@@ -484,6 +484,8 @@ Updated template with `status: "rejected"`.
|
||||
|
||||
> تگها: `otp`، `payment`، `clinic_invitation`، `pre_registration`، `notification_mobile`، `welcome`، `secretary`، `doctor_appointment`. (پیامک قالبیِ کاربر با تگ `user_template` جداگانه از طریق `POST /api/v1/sms/template` مدیریت میشود.)
|
||||
>
|
||||
> تگ `otp`: کد تأیید ورود. placeholderها: `{code}` (کد ۵ رقمی)، `{site}` (**اختیاری** — اسم سایتِ شهرِ درخواستکننده برای شخصیسازی متن). مقدار `{site}` از فیلد `domain` در `POST /api/v1/user/send-code` گرفته میشود: `site_name` شهرِ متناظر در جدول `cities`؛ اگر `domain` نیامد یا شهر پیدا نشد → «کلینیک پرو». برای فعالکردن اسم سایت کافی است `{site}` را در body قالب بگذارید (مثلاً `کد تأیید شما در {site}: {code}`)؛ اگر نگذارید، اسم سایت نمایش داده نمیشود. متن از قالب DB (fallback به `DEFAULTS`).
|
||||
>
|
||||
> تگ `welcome`: پیامک خوشآمد که هنگام افزودن پزشک/کلینیک توسط نماینده (`POST /api/v1/representation/doctor|clinic`) بهصورت async به موبایل پزشک/مالک ارسال میشود. متن فعلاً ثابت است (نام + `site_name`)، نه از قالب DB.
|
||||
>
|
||||
> تگ `secretary`: پیامک خوشآمد که هنگام تعریف منشی جدید (`POST /api/v1/secretary`) بهصورت async به موبایل منشی ارسال میشود. placeholderها: `{owner}` (نام دکتر یا کلینیک)، `{username}` (موبایل منشی)، `{link}` (لینک ورود). متن از قالب DB میآید (fallback به پیشفرض `SmsMessageTemplate::DEFAULTS`).
|
||||
@@ -506,7 +508,7 @@ Updated template with `status: "rejected"`.
|
||||
"tag": "otp",
|
||||
"title": "کد تأیید ورود",
|
||||
"body": "کد تأیید شما: {code}",
|
||||
"variables": ["code"],
|
||||
"variables": ["code", "site"],
|
||||
"updated_at": 1718000000
|
||||
}
|
||||
]
|
||||
|
||||
@@ -142,6 +142,7 @@ class AuthController extends BaseController
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($data['mobile'] ?? '');
|
||||
$domain = isset($data['domain']) ? substr(trim((string) $data['domain']), 0, 253) : null;
|
||||
|
||||
if (!preg_match('/^09[0-9]{9}$/', $mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت شماره موبایل نادرست است', 422, 'mobile');
|
||||
@@ -154,7 +155,7 @@ class AuthController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429);
|
||||
}
|
||||
|
||||
$uuid = $this->otpService->sendCode($mobile);
|
||||
$uuid = $this->otpService->sendCode($mobile, $domain ?: null);
|
||||
|
||||
return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Auth\Service;
|
||||
|
||||
use App\Location\Repository\CityRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
@@ -16,6 +17,7 @@ class OtpService
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly SmsService $sms,
|
||||
private readonly SmsTextResolver $smsText,
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly int $otpTtl = 1200,
|
||||
private readonly string $appEnv = 'dev',
|
||||
) {}
|
||||
@@ -56,7 +58,7 @@ class OtpService
|
||||
return $mobile;
|
||||
}
|
||||
|
||||
public function sendCode(string $mobile): string
|
||||
public function sendCode(string $mobile, ?string $domain = null): string
|
||||
{
|
||||
$uuid = Uuid::v4()->toRfc4122();
|
||||
$code = $this->appEnv === 'dev'
|
||||
@@ -69,13 +71,28 @@ class OtpService
|
||||
$this->cache->save($item);
|
||||
|
||||
if ($this->appEnv !== 'dev') {
|
||||
$message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code]);
|
||||
$site = $this->resolveSiteName($domain);
|
||||
$message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code, 'site' => $site]);
|
||||
$this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP);
|
||||
}
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
private function resolveSiteName(?string $domain): string
|
||||
{
|
||||
$default = 'کلینیک پرو';
|
||||
if (!$domain) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$domain = strtolower(trim((string) preg_replace('#^https?://#', '', $domain)));
|
||||
$domain = (string) preg_replace('#/.*$#', '', $domain);
|
||||
$domain = (string) preg_replace('#^www\.#', '', $domain);
|
||||
|
||||
return $this->cityRepo->findOneBy(['domain' => $domain])?->getSiteName() ?: $default;
|
||||
}
|
||||
|
||||
public function verifyCode(string $uuid, string $submittedCode): array
|
||||
{
|
||||
$item = $this->cache->getItem($this->key($uuid));
|
||||
|
||||
@@ -21,7 +21,7 @@ class SmsMessageTemplate
|
||||
SmsLog::TAG_OTP => [
|
||||
'title' => 'کد تأیید ورود',
|
||||
'body' => 'کد تأیید شما: {code}',
|
||||
'variables' => ['code'],
|
||||
'variables' => ['code', 'site'],
|
||||
],
|
||||
SmsLog::TAG_PAYMENT => [
|
||||
'title' => 'تأیید پرداخت و نوبت',
|
||||
|
||||
Reference in New Issue
Block a user