# ارسال پیامک OTP فقط از طریق Kavenegar VerifyLookup (پترن) ## پروژه `clinicpro` (Backend — SMS/Auth) ## زمینه پیامک کد تأیید ورود (OTP) الان با متد **متن‌آزاد** `send()` کاوه‌نگار (`/sms/send.json`) ارسال می‌شود. کاوه‌نگار برای ارسال کد تأیید، متد اختصاصی **VerifyLookup** (`/verify/lookup.json`) دارد که با **پترن مصوب** کار می‌کند، روی خطوط اشتراکی هم تحویل مطمئن‌تری دارد و برای OTP توصیه/لازم است. هدف: **فقط OTP** از این به بعد از طریق Lookup ارسال شود (بقیه پیامک‌ها — welcome، secretary، doctor_appointment، user_template — دست‌نخورده بمانند). ## اسپک Kavenegar VerifyLookup (از داکیومنت رسمی) - **Endpoint:** `https://api.kavenegar.com/v1/{API-KEY}/verify/lookup.json` (GET/POST) - **پارامترهای اجباری:** `receptor` (موبایل)، `token` (مقدار کد؛ max 100؛ **بدون فاصله**؛ بدون آندرلاین/خط جدید)، `template` (نام پترن مصوب در پنل) - **پارامترهای اختیاری توکن و قانون فاصله (بحرانی):** | توکن | فاصله | |------|-------| | `token`, `token2`, `token3` | **بدون فاصله** (رد می‌شود) | | `token10` | حداکثر **۵ فاصله** مجاز | | `token20` | حداکثر **۸ فاصله** مجاز | - `type`: `sms` (پیش‌فرض) یا `call`. - **پترن باید از قبل در پنل کاوه‌نگار تأیید شده باشد**؛ متنِ پیام روی پنل ثابت است (نه از DB). - نیازمند اشتراک advanced. **پیامد:** کد ۵ رقمی (بدون فاصله) → `token`. اسم سایت مثل «یاسوج نوبت» (۱ فاصله) → باید در **`token10`** برود (نه `token`/`token2`/`token3` که فاصله را رد می‌کنند). ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Auth/Service/OtpService.php` | ساخت و dispatch پیامک OTP — باید به Lookup سوییچ شود | | `src/Sms/Service/SmsService.php` | `dispatchAsync(...templateCode, templateVars...)` → اگر `templateCode` باشد `sendTemplate()` صدا می‌زند | | `src/Sms/Provider/KavehNegarProvider.php` | `sendTemplate()` → `/verify/lookup.json`؛ map توکن باید نام‌دار شود (برای `token10`) | | `.env` + `config/services.yaml` | افزودن `KAVENEGAR_OTP_TEMPLATE` و bind به OtpService | | `docs/api/sms.md` | مستندسازی سوییچ OTP به Lookup | ## وضعیت فعلی **OtpService::sendCode** (متن‌آزاد، بدون templateCode): ```php 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); } ``` Constructor فعلی: ```php public function __construct( 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', ) {} ``` **SmsService::dispatchAsync / sendNow** (مسیر انتخاب send vs lookup): ```php public function dispatchAsync( string $mobile, string $message, string $provider = 'kavenegar', ?string $templateUuid = null, array $templateVars = [], ?string $templateCode = null, string $tag = SmsLog::TAG_GLOBAL, ): void { /* dispatch SendSmsMessage */ } // sendNow: $success = ($msg->templateCode !== null) ? $provider->sendTemplate($msg->mobile, $msg->templateCode, $msg->templateVars) : $provider->send($msg->mobile, $msg->message); ``` **KavehNegarProvider::sendTemplate** (map ترتیبی فعلی — فاصله را در token2/3 می‌گذارد که رد می‌شود): ```php public function sendTemplate(string $mobile, string $templateCode, array $vars): bool { try { $params = ['receptor' => $mobile, 'template' => $templateCode]; foreach (array_values($vars) as $i => $v) { $params['token' . ($i > 0 ? $i + 1 : '')] = $v; } $resp = $this->httpClient->request('POST', self::BASE . '/' . $this->key() . '/verify/lookup.json', [ 'body' => http_build_query($params), 'timeout' => 10, ]); $data = $resp->toArray(); return ($data['return']['status'] ?? 0) === 200; } catch (\Throwable $e) { /* log; return false */ } } ``` **caller دیگر `sendTemplate`:** فقط `POST /api/v1/sms/send-template` در `src/Sms/Controller/SmsController.php:488` که `$vars` را با **کلیدهای معنایی** (مثل `name`,`date`) و وابسته به **ترتیب** پاس می‌دهد. پس map ترتیبی نباید برای این caller بشکند. **.env فعلی:** ``` OTP_TTL=1200 KAVENEGAR_API_KEY=change_me ``` ## وظایف ### ۱. `KavehNegarProvider::sendTemplate` — پشتیبانی توکنِ نام‌دار (backward-safe) اگر همه‌ی کلیدهای `$vars` نام slot معتبر کاوه‌نگار باشند (`token`,`token2`,`token3`,`token10`,`token20`) → همان‌ها را مستقیم استفاده کن (تا اسم سایتِ فاصله‌دار در `token10` برود). در غیر این صورت (کلیدهای معنایی/لیستی) → همان map ترتیبی فعلی (برای caller `send-template`). ```php $params = ['receptor' => $mobile, 'template' => $templateCode]; $slots = ['token', 'token2', 'token3', 'token10', 'token20']; if ($vars !== [] && array_keys($vars) !== range(0, count($vars) - 1) && array_diff(array_keys($vars), $slots) === []) { foreach ($vars as $slot => $v) { $params[$slot] = $v; } } else { foreach (array_values($vars) as $i => $v) { $params['token' . ($i > 0 ? $i + 1 : '')] = $v; } } ``` ### ۲. `.env` + `config/services.yaml` — نام پترن OTP `.env` (کاربر مقدار واقعی پترن مصوب را می‌گذارد): ``` KAVENEGAR_OTP_TEMPLATE= ``` `config/services.yaml` روی سرویس `OtpService`: ```yaml App\Auth\Service\OtpService: arguments: $otpTtl: '%env(int:OTP_TTL)%' $appEnv: '%kernel.environment%' $otpTemplate: '%env(default::KAVENEGAR_OTP_TEMPLATE)%' ``` ### ۳. `OtpService` — dispatch از طریق Lookup - پارامتر constructor جدید `?string $otpTemplate = null` **بعد از** پارامترهای بدون‌دیفالت و کنار `$appEnv` (ترتیب معتبر PHP؛ همه دیفالت‌دار در انتها). - در `sendCode`، وقتی پترن ست است → با `templateCode` و توکن‌های نام‌دار dispatch کن: ```php if ($this->appEnv !== 'dev') { $site = $this->resolveSiteName($domain); $message = $this->smsText->resolve(SmsLog::TAG_OTP, ['code' => $code, 'site' => $site]); // فقط برای لاگ SmsLog if ($this->otpTemplate) { $this->sms->dispatchAsync( $mobile, $message, templateCode: $this->otpTemplate, templateVars: ['token' => $code, 'token10' => $site], tag: SmsLog::TAG_OTP, ); } else { // fallback متن‌آزاد فقط وقتی پترن ست نشده (مثلاً محیط توسعه) $this->sms->dispatchAsync($mobile, $message, tag: SmsLog::TAG_OTP); } } ``` - `token` = کد ۵ رقمی (بدون فاصله ✓). `token10` = اسم سایت (تا ۵ فاصله مجاز ✓). - اگر پترن مصوب فقط `%token` داشته باشد، `token10` اضافی توسط کاوه‌نگار نادیده گرفته می‌شود (بی‌خطر) — اسم سایت فقط وقتی نمایش داده می‌شود که پترن `%token10` هم داشته باشد. ### ۴. مستندسازی `docs/api/sms.md` - در بخش «متن ویرایش‌پذیر پیامک‌های سیستمی»، تگ `otp`: تصریح کن که **ارسال OTP از طریق Kavenegar VerifyLookup** انجام می‌شود (نه `send` متن‌آزاد) وقتی `KAVENEGAR_OTP_TEMPLATE` ست باشد؛ متنِ پیام از **پترن مصوب کاوه‌نگار** می‌آید نه از body قابل‌ویرایش DB (body صرفاً برای لاگ `SmsLog`). - map توکن‌ها را مستند کن: `token` = کد، `token10` = اسم سایت (فاصله‌دار). - الزام `.env`: `KAVENEGAR_OTP_TEMPLATE` باید نام پترن مصوب باشد؛ بدون آن، fallback به متن‌آزاد. ## نکات مهم - **فقط OTP** به Lookup می‌رود؛ مسیر `send()` متن‌آزاد برای بقیه‌ی تگ‌ها و caller `send-template` دست‌نخورده بماند. - **قانون فاصله** رعایت شود: کد → `token`؛ هر مقدار فاصله‌دار (اسم سایت) → `token10`. هرگز اسم فاصله‌دار در `token`/`token2`/`token3` نگذار. - **پترن باید در پنل کاوه‌نگار مصوب باشد** و ساختار توکنش با map کد بخواند (`%token` برای کد، در صورت نیاز `%token10` برای سایت). این خارج از کد است و باید توسط صاحب حساب انجام شود. - نیازمند **اشتراک advanced** کاوه‌نگار. - **backward-safe**: تغییر `sendTemplate` نباید caller `send-template` (کلیدهای معنایی/ترتیبی) را بشکند — با شرط «همه کلیدها slot معتبرند» تضمین شود. - **cache**: بعد از تغییر constructor `OtpService` و bind جدید، `cache:clear` برای **هر دو محیط** `dev` و `test` لازم است (وگرنه کانتینر کامپایل‌شده‌ی قدیمی TypeError می‌دهد). worker پیامک (`messenger:consume async`) هم باید ری‌استارت شود. - **تست**: ```bash ddev exec php -l src/Auth/Service/OtpService.php ddev exec php -l src/Sms/Provider/KavehNegarProvider.php ddev exec php bin/console cache:clear ddev exec php bin/console cache:clear --env=test ddev exec php bin/console lint:container ddev exec php bin/phpunit tests/Auth/SendCodeMobileRateLimitTest.php ``` (تست واقعی ارسال Lookup نیاز به API key و پترن مصوب دارد؛ در `dev`/`test` پیامک ارسال نمی‌شود.) - بعد از تغییر، `docs/api/sms.md` را در همان session به‌روز کن (قانون استاندینگ پروژه).