feat: implement OTP sending via Kavenegar VerifyLookup and add image cropping modal

- Added support for sending OTP messages using Kavenegar's VerifyLookup method, ensuring compliance with specified token formatting and template usage.
- Updated OtpService to handle new template parameters and fallback mechanisms.
- Introduced ImageCropModal component for cropping images with a user-friendly interface.
- Created utility function for cropping images and generating downloadable files.
This commit is contained in:
hamed
2026-07-04 21:50:55 +03:30
parent 1bb9cedd22
commit 6ade17a5b4
10 changed files with 822 additions and 2185 deletions
+191
View File
@@ -0,0 +1,191 @@
# ارسال پیامک 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 به‌روز کن (قانون استاندینگ پروژه).