| پروژهٔ اولیهٔ این سوال را میتوانید از [این لینک](/contest/assignments/103145/download_problem_initial_project/356753/) دانلود کنید. |
| :-: |
تابلوی نتایج زندهٔ **جام جهانی فناوری پردیس ۲۰۲۶** روی سایت رویداد، زیر لود درخواستهای پیاپی هواداران است. سرویس *API* که نتایج را میدهد کوچک است و این حجم درخواست را تاب نمیآورد! سلیب بهجای بزرگکردن خود سرویس، قرار است جلوی آن یک **گیتوی** قرار دهد که خودش کش کند، درخواستهای نوشتن را احراز هویت کند، نرخ هر کلاینت را محدود کند و آمار کار خودش را هم نمایش دهد. این گیتوی روی *OpenResty* ساخته میشود که *nginx* را با زبان **Lua** ترکیب میکند. سلیب از شما میخواهد این استک سهسرویسی را بسازید و منطق گیتوی را در **چند ماژول مستقل Lua** بنویسید.

# **پروژهٔ اولیه**
برای دانلود **پروژهٔ اولیه** روی [این لینک](/contest/assignments/103145/download_problem_initial_project/356753/) کلیک کنید. کد سرویس *API* در پوشهٔ `api/` **از قبل نوشته شده** و نیازی به تغییرش ندارید. شما باید `api/Dockerfile`، `docker-compose.yml`، `edge/nginx.conf` و ماژولهای `edge/lua/` را بنویسید.
<details class="grey">
<summary>**نکته: ساختار پروژه و اینکه چه چیزی را باید بسازید**</summary>
```
.
├── api/
│ ├── <mark class="blue" title="از قبل پیادهسازی شده">main.go</mark>
│ ├── <mark class="blue" title="از قبل پیادهسازی شده">go.mod</mark>
│ └── <mark class="green" title="باید پیادهسازی شود">Dockerfile</mark>
├── edge/
│ ├── <mark class="green" title="باید پیادهسازی شود">nginx.conf</mark>
│ └── lua/
│ ├── <mark class="green" title="باید پیادهسازی شود">redisc.lua</mark>
│ ├── <mark class="green" title="باید پیادهسازی شود">util.lua</mark>
│ ├── <mark class="green" title="باید پیادهسازی شود">limiter.lua</mark>
│ ├── <mark class="green" title="باید پیادهسازی شود">auth.lua</mark>
│ ├── <mark class="green" title="باید پیادهسازی شود">cache.lua</mark>
│ ├── <mark class="green" title="باید پیادهسازی شود">stats.lua</mark>
│ └── <mark class="green" title="باید پیادهسازی شود">gateway.lua</mark>
└── <mark class="green" title="باید پیادهسازی شود">docker-compose.yml</mark>
```
نام دقیق ماژولها دلخواه شماست، ولی منطق باید واقعاً به **دستکم چهار فایل مستقل `.lua`** شکسته شود. نوشتن همهٔ منطق در یک بلوک بزرگ داخل `nginx.conf` **به هیچ عنوان در سیستم داوری پذیرفته نمیشود.**
</details>
# **جزئیات**
**استک** از سه سرویس تشکیل میشود و فقط **گیتوی** روی میزبان در دسترس قرار میگیرد. سرویس *API* و *Redis* فقط از پشت گیتوی و روی شبکهٔ داخلی در دسترساند.
|  |
| :-: |
| گیتوی *OpenResty* روی هر دو شبکه است، سرویس *API* و *Redis* تنها روی شبکهٔ پشتی؛ فقط گیتوی پورت میزبان دارد. |
## **پیادهسازی فایل `api/Dockerfile`**
سرویس *API* یک برنامهٔ کوچک *Go* است که سه مسیر دارد: `GET /health` که `ok` میدهد، `GET /standings` که ردهبندی را بهصورت *JSON* میدهد و `POST /event` که یک گل را ثبت میکند. هر پاسخ `/standings` یک شمارندهٔ `served_by` دارد که فقط وقتی **واقعاً به سرویس رسیده باشد** یکی بالا میرود. همین شمارنده است که نشان میدهد پاسخ از کش گیتوی آمده یا از خود سرویس.
برای این سرویس یک `Dockerfile` بنویسید که:
- **چندمرحلهای** باشد، یعنی دستکم دو دستور `FROM` داشته باشد.
- مرحلهٔ ساخت از ایمیج `ghcr.io/qregistry/standard/golang:1.17` استفاده کند.
- مرحلهٔ نهایی روی یک ایمیج سبک مثل `ghcr.io/qregistry/standard/alpine:3.15` باشد، نه روی ایمیج بزرگ ساخت قبلی.
- پورت `8080` را `EXPOSE` کند.
- با دستور `USER` کانتینر را با کاربر **غیر روت** اجرا کند.
## **پیادهسازی فایل `docker-compose.yml`**
سه سرویس با این نامهای دقیق تعریف کنید:
| **سرویس** | **ایمیج یا ساخت** | **نکته** |
| --: | --: | --: |
| `api` | با `build` از `./api` ساخته شود | هیچ پورتی روی میزبان منتشر نکند |
| `redis` | `ghcr.io/qregistry/standard/redis:7-alpine` | هیچ پورتی روی میزبان منتشر نکند |
| `edge` | `ghcr.io/qregistry/standard/openresty:1.19.9.1-5-alpine-fat` | تنها سرویسی که پورت منتشر میکند |
- **دو شبکهٔ مجزا** تعریف کنید. سرویس `edge` روی هر دو شبکه است، ولی `api` و `redis` فقط روی شبکهٔ پشتی. یعنی باید شبکهای وجود داشته باشد که `edge` روی آن است و `api` روی آن نیست.
- سرویس `edge` باید پورت میزبان **`18080`** را منتشر کند.
- سرویس `edge` باید هم فایل `edge/nginx.conf` و هم **پوشهٔ** `edge/lua` را داخل کانتینر مانت کند، آن هم روی این مسیرهای دقیق:
```yaml docker-compose.yml docker
volumes:
- ./edge/nginx.conf:/usr/local/openresty/nginx/conf/nginx.conf:ro
- ./edge/lua:/etc/edge/lua:ro
```
> مسیر `/usr/local/openresty/nginx/conf/nginx.conf` همان جایی است که ایمیج *OpenResty* کانفیگ خودش را از آن میخواند. اگر روی `/etc/nginx/nginx.conf` مانت کنید، *OpenResty* کانفیگ پیشفرض خودش را بالا میآورد، سرویس ظاهراً سالم است ولی هیچکدام از رفتارهای خواستهشده کار نمیکند. مسیر `/etc/edge/lua` هم باید با چیزی که در `lua_package_path` مینویسید یکی باشد.
- سرویس `edge` باید این متغیرهای محیطی را داشته باشد: `API_KEY` با مقدار `pardis-secret-2026`، `RATE_LIMIT` و `WRITE_LIMIT`.
## **پیادهسازی فایل `edge/nginx.conf`**
این فایل باید **کوتاه و ساده** بماند و فقط مسیرها را به ماژولهای *Lua* وصل کند:
- با دستور `lua_package_path` مسیر ماژولها را معرفی کند، یعنی همان مسیری که پوشهٔ `edge/lua` را در آن مانت کردهاید.
- در هر مسیر، منطق را با `require(...)` از ماژولها صدا بزند، نه اینکه درجا بنویسد.
- با `proxy_pass` درخواستها را به سرویس `api` بفرستد.
+ **دربارهٔ معماری:** نوشتن منطق در *Lua* و استفاده از `proxy_pass` با هم تناقضی ندارند. الگوی رایج در *OpenResty* این است که یک مسیر **داخلی** با `proxy_pass` به سرویس *API* تعریف کنید و ماژول *Lua* با `ngx.location.capture` آن مسیر داخلی را صدا بزند. اینطوری هم منطق در *Lua* میماند و هم خود *nginx* کار پروکسی را انجام میدهد. روش دیگری هم انتخاب کنید مشکلی نیست؛ فقط رفتار نهایی سنجیده میشود.
## **پیادهسازی رفتار گیتوی**
|  |
| :-: |
| درخواست اول به سرویس میرسد و درخواست بعدی از کش پاسخ میگیرد. |
### **پیادهسازی کش کوتاهمدت و پاک کردن آن**
- **کل بدنهٔ پاسخ** `GET /standings` را همانطور که از سرویس *API* آمده، به مدت **۳ ثانیه** روی *Redis* نگه دارید. فقط همین یک مسیر کش میشود و یک کلید ثابت دارد؛ اسم کلید دلخواه شماست.
- روی هر پاسخ `GET /standings` هدر `X-Cache` بگذارید که مقدارش **دقیقاً** `HIT` یا `MISS` باشد.
- تا وقتی پاسخ در کش است، `served_by` نباید عوض شود، چون اصلاً به سرویس نمیرسیم.
- هر **نوشتن موفق** روی `POST /event` باید کش ردهبندی را پاک کند تا خواندن بعدی `MISS` شود و دادهٔ تازه را از سرویس بگیرد.
- مسیر مدیریتی `POST /admin/flush` هم کش را دستی پاک میکند و با کلید درست باید `200` برگرداند.
### **پیادهسازی احراز هویت با کلید**
+ **ترتیب مهم است:** روی مسیرهای نوشتن، **اول احراز هویت** انجام میشود و **بعد** محدودیت نرخ. یعنی یک درخواست نوشتن بدون کلید درست، همیشه `401` میگیرد حتی اگر شمارندهٔ نوشتن آن کلاینت پر شده باشد. روی مسیرهای خواندن اصلاً احراز هویتی در کار نیست و فقط محدودیت نرخ اعمال میشود.
- مسیرهای `POST /event` و `POST /admin/flush` محافظتشدهاند. گیتوی هدر `X-API-Key` را با مقدار متغیر `API_KEY` **دقیقاً و حساس به حروف** مقایسه میکند.
- اگر این هدر نبود یا مقدارش اشتباه بود، همان گیتوی با کد **`401`** رد میکند و درخواست اصلاً به سرویس نمیرسد.
- خواندن ردهبندی عمومی است و کلید نمیخواهد.
### **پیادهسازی محدودیت نرخ با دو شمارندهٔ جدا**
**شناسایی کلاینت:** اگر هدر `X-Forwarded-For` وجود داشت، **اولین** *IP* آن استفاده میشود و اگر نبود، نشانی مستقیم کلاینت. اولین ورودی این هدر یعنی کلاینت اصلی و چون اینجا میخواهیم نرخ را برای خودِ کاربر نهایی بشماریم نه برای پروکسی میانی، همان ملاک است. در تستهای داوری این هدر همیشه فقط یک *IP* دارد.
خواندن و نوشتن **دو شمارندهٔ کاملاً جدا** دارند و هرکدام یک **پنجرهٔ ثابت ده ثانیهای** دارند:
| **شمارنده** | **سقف از کدام متغیر** | **مقدار مورد انتظار** |
| --: | --: | :-: |
| **خواندن** | `RATE_LIMIT` | `20` |
| **نوشتن** | `WRITE_LIMIT` | `10` |
+ **توجه:** این عددها دلخواه نیستند. سیستمداوری انتظار دارد دستکم ۱۵ خواندن پیاپی قبول شود ولی ۳۵ خواندن به `429` برسد و ۲۰ نوشتن پیاپی به `429` برسد در حالی که بعد از ۳۵ خواندن هنوز یک نوشتن قبول شود. مقدارهای بالا این شرطها را برآورده میکنند.
قاعدهٔ دقیق شمارش این است:
- برای هر کلاینت و هر شمارنده یک کلید جدا نگه دارید و در هر درخواست یکی به آن اضافه کنید.
- **فقط در اولین افزایش**، برای آن کلید یک انقضای ده ثانیهای بگذارید. اگر در هر درخواست انقضا را دوباره ست کنید، پنجره مدام تمدید میشود و دیگر پنجرهٔ ثابت ندارید.
- اگر مقدار شمارنده **از سقف بیشتر شد** پاسخ `429` بدهید. یعنی با سقف `20`، بیست درخواست اول `200` میگیرند و درخواست بیستویکم `429`.
- بعد از پایان پنجره، کلید منقضی میشود و شمارش از صفر شروع میشود.
قواعد دیگر:
- پر شدن شمارندهٔ خواندن نباید نوشتن را ببندد و برعکس. بعد از ۳۵ خواندنِ پیاپی، یک نوشتن از همان کلاینت باید همچنان `200` بگیرد.
- مسیرهای `/health` و `/edge/stats` **هرگز** محدود نمیشوند. دلیلش عملی است: سیستم داوری اول با یک سیل درخواست شمارنده را پر میکند و بعد از **همان کلاینت** آمار را میخواند؛ اگر `/edge/stats` هم محدود شود، بهجای شمارندهها `429` میگیرد.
- مسیر `/admin/flush` در **دستهٔ نوشتن** حساب میشود، چون یک عمل تغییردهنده است.
- شمارندههای `/edge/stats` **تجمعی**اند، یعنی از ابتدای بالا آمدن گیتوی جمع میشوند و با پایان پنجرهٔ نرخ صفر نمیشوند. هر `401` هم در `auth_failures` شمرده میشود، چه روی نوشتن باشد چه روی `/admin/flush`.
- اگر خود سرویس *API* پاسخی با وضعیت خطا برگرداند، گیتوی باید **همان وضعیت را عیناً عبور دهد** و آن را به `502` تبدیل نکند. مثلاً بدنهٔ نامعتبر روی `/event` باید همان `400` سرویس را به کلاینت برساند؛ `502` فقط وقتی معنا دارد که گیتوی اصلاً نتواند به سرویس وصل شود.
- فقط `429`هایی که **خودِ گیتوی** تولید میکند شمارندهٔ `rate_limited` را بالا میبرند.
|  |
| :-: |
| شمارندهٔ خواندن و شمارندهٔ نوشتن جدا هستند؛ پر شدن یکی دیگری را نمیبندد و مسیر سلامت هرگز محدود نمیشود. |
### **پیادهسازی شناسهٔ درخواست و متریک**
- هر پاسخ باید هدر `X-Request-Id` داشته باشد و مقدار آن در هر درخواست **یکتا** باشد.
- مسیر `GET /edge/stats` باید یک *JSON* با این چهار کلید بدهد که خود گیتوی روی *Redis* نگه میدارد:
```json output json
{"cache_hits":12,"cache_misses":5,"rate_limited":3,"auth_failures":2}
```
> هر `HIT` باید `cache_hits` را یکی بالا ببرد و هر `MISS` باید `cache_misses` را. هر پاسخ `429` باید `rate_limited` را زیاد کند و هر رد شدن احراز هویت باید `auth_failures` را. سیستم داوری این شمارندهها را قبل و بعد از هر رویداد میخواند و انتظار دارد عددشان بالا رفته باشد، پس هر چهار کلید باید واقعاً بهروز شوند.
<details class="pink">
<summary>**راهنمایی: شکستن منطق به ماژولهای Lua و صدا زدن آنها**</summary>
در `nginx.conf` مسیر ماژولها را معرفی کنید و در هر مسیر، بهجای نوشتن منطق درجا، آن را از ماژول صدا بزنید:
```nginx edge/nginx.conf nginx
env API_KEY;
env RATE_LIMIT;
env WRITE_LIMIT;
http {
resolver 127.0.0.11 ipv6=off;
lua_package_path "/etc/edge/lua/?.lua;;";
server {
listen 80;
location = /standings {
content_by_lua_block { require("gateway").standings() }
}
}
}
```
> دستور `env` باعث میشود متغیرهای محیطی داخل *Lua* با `os.getenv` دیده شوند؛ بدون آن مقدارشان `nil` میشود. دستور `resolver 127.0.0.11` هم *DNS* داخلی داکر را معرفی میکند تا نام `redis` از داخل *Lua* و از طریق کوسوکت پیدا شود. مسیر `/etc/edge/lua` همان جایی است که پوشهٔ `edge/lua` را مانت کردهاید، پس اگر جای دیگری مانت کردید این مسیر را هم عوض کنید.
هر ماژول یک جدول برمیگرداند و یک مسئولیت دارد:
- ماژول اتصال به *Redis* با `resty.redis` و `red:connect`.
- ماژول محدودیت نرخ با `red:incr` روی یک کلید مخصوص کلاینت و `red:expire` روی همان کلید برای بازهٔ ۱۰ ثانیهای.
- ماژول کش با `red:setex` برای ذخیره و `red:del` برای پاک کردن.
- ماژول اصلی که اینها را به هندلرهای مسیر میچسباند.
</details>
|  |
| :-: |
| فایل `nginx.conf` کوتاه میماند و منطق را با `require` از ماژولهای مستقل `edge/lua/` صدا میزند؛ هر ماژول یک مسئولیت دارد. |
|  |
| :-: |
| خواندن و نوشتن سقف جداگانه دارند و دو مسیر معاف هرگز محدود نمیشوند. |
# **نمونه**
بعد از بالا آوردن استک با `docker compose up`، دو خواندن پیاپی ردهبندی، بار دوم از کش پاسخ میگیرد و بعد از یک نوشتن، کش پاک میشود:
```text terminal terminal
$ curl -s -D- http://localhost:18080/standings | grep -iE 'X-Cache|served_by'
X-Cache: MISS
{"served_by":7,"standings":[...]}
$ curl -s -D- http://localhost:18080/standings | grep -i X-Cache
X-Cache: HIT
$ curl -s -X POST -H 'X-API-Key: pardis-secret-2026' -d '{"team":"IRAN"}' http://localhost:18080/event
recorded
$ curl -s -D- http://localhost:18080/standings | grep -i X-Cache
X-Cache: MISS
```
> خواندن اول در کش نیست، پس گیتوی از سرویس میگیرد و `X-Cache: MISS` میگذارد. خواندن دوم چون داخل بازهٔ سه ثانیهای است همان پاسخ را با `X-Cache: HIT` برمیگرداند و چون سرویس اصلاً صدا زده نشده `served_by` ثابت میماند. بعد از نوشتن موفق، گیتوی کش را پاک میکند، بنابراین خواندن بعدی دوباره `MISS` میشود و دادهٔ تازه را نشان میدهد.
نوشتن بدون کلید و مسیر مدیریتی بدون کلید رد میشوند:
```text terminal terminal
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -d '{"team":"IRAN"}' http://localhost:18080/event
401
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:18080/admin/flush
401
$ curl -s http://localhost:18080/edge/stats
{"cache_hits":12,"cache_misses":5,"rate_limited":3,"auth_failures":2}
```
> نوشتن و پاک کردن کش بدون هدر `X-API-Key` را همان گیتوی با `401` رد میکند و درخواست به سرویس نمیرسد. اگر یک کلاینت خیلی سریع درخواست بفرستد، بعد از عبور از سقف شمارندهٔ مربوطه پاسخ `429` میگیرد. مسیر `/edge/stats` هم شمارندههایی را که خود گیتوی روی *Redis* نگه داشته گزارش میدهد.
+ **دو بخش بهصورت صفر و یکی نمره میگیرند:** بودن فایلهای لازم (۳ درصد از امتیاز) و بالا آمدن و جواب دادن استک (۶ درصد از امتیاز). یعنی اگر `docker compose up` استک شما را کامل بالا نیاورد، این ۹ درصد کامل از دست میرود و چون بیشتر تستهای دیگر هم به استکِ در حال اجرا نیاز دارند، عملاً باید اول این را درست کنید.
## **سابتسکها**
| **سابتسک** | **درصد** |
| --: | :-: |
| بودن فایلهای لازم | ۳ |
| `Dockerfile` چندمرحلهای و سختشدهٔ *API* | ۸ |
| سرویسها و ایمیجهای `docker-compose.yml` | ۷ |
| جداسازی شبکهها و انتشار درست لبه | ۸ |
| ساختار چندفایلی دروازهٔ *Lua* | ۱۲ |
| بالا آمدن و جواب دادن استک | ۶ |
| افزوده شدن شناسهٔ درخواست به پاسخها | ۴ |
| کش کوتاهمدت در دو حالت اصابت و خطا | ۹ |
| باطل شدن کش هنگام نوشتن | ۸ |
| مسیر محافظتشدهٔ پاک کردن کش | ۷ |
| احراز هویت با کلید *API* روی نوشتنها | ۸ |
| محدودیت نرخ خواندن | ۷ |
| دستهٔ جداگانهٔ محدودیت نرخ نوشتن | ۶ |
| مسیر آمار متریکهای لبه | ۷ |
> دو ردیف «بودن فایلهای لازم» و «بالا آمدن استک» همان دو بخش «صفر و یکی»اند. بقیه بهازای هر تست موفق امتیاز میدهند، پس اگر مثلاً کش را پیاده کنید ولی به محدودیت نرخ نرسید، **نمرهٔ کش را کامل میگیرید.**
# **آنچه باید آپلود کنید**
کل درخت پروژه را *ZIP* کنید و بفرستید. سیستم داوری این فایلها را میپذیرد:
- `docker-compose.yml`
- `api/Dockerfile`
- `edge/nginx.conf`
- `edge/lua/*.lua`
- `api/main.go` و `api/go.mod` که از قبل آمادهاند **و نباید تغییرشان دهید!**
+ **توجه:** منطق گیتوی باید واقعاً به **دستکم چهار ماژول مستقل `.lua`** در `edge/lua/` شکسته شود. سیستم داوری تعداد فایلها و صدا زده شدنشان با `require` را بررسی میکند.
+ **توجه:** سیستم داوری استک را با `docker compose up` بالا میآورد و رفتار گیتوی را زنده از پورت `18080` میسنجد. همهٔ ایمیجها باید از `ghcr.io/qregistry/standard/...` باشند و ساخت باید کاملاً آفلاین انجام شود.
+ **توجه:** فایل `docker-compose.yml` باید `version: "3"` باشد.
+ **توجه:** اگر *Redis* در دسترس نبود، گیتوی باید درخواست را رد نکند و اجازه دهد عبور کند، یعنی رفتار *fail-open*. این حالت تست نمیشود ولی سرویس نباید بهخاطرش از کار بیفتد.
+ **توجه:** فقط سرویس `edge` باید روی میزبان منتشر شود؛ `api` و `redis` نباید هیچ پورتی روی میزبان باز کنند.
+ **توجه:** هدرها و کدهای وضعیت باید دقیق باشند: `X-Cache` برابر `HIT` یا `MISS`، احراز هویت ناموفق `401`، عبور از سقف نرخ `429` و هر پاسخ یک `X-Request-Id` یکتا.