Live off-site Postgres copy on 247 replaces the nightly pg_dump; backup retention in deploy.sh

- ops/pgbackup: SSH tunnel + pg_receivewal (slot zareian_247) + daily verified pg_basebackup (keep 7)
  and a restore check, all run on 78.38.35.247 as systemd units
- /api/health reports offsiteBackup (slot active/lag/lost) and warns; OFFSITE_WAL_SLOT setting
- removed statuslog backup_db/backup_verify and their health warnings
- deploy.sh keeps 3 pre-deploy dumps, 3 local patches, drops manual backups after 30 days
- docs: operations backup section, security item 14 closed, CLAUDE.md status

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 16:53:10 +03:30
parent 19072ee534
commit a9cccc6550
22 changed files with 374 additions and 447 deletions

View File

@@ -30,6 +30,9 @@ DB_USER=
DB_PASSWORD=
DB_HOST=127.0.0.1
DB_PORT=5432
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده می‌گیرد (ops/pgbackup/). روی سرور: zareian_247.
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. روی لپ‌تاپ خالی.
OFFSITE_WAL_SLOT=
# ═══════════════════════════════════════════════════════════════════════
# ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند

View File

@@ -328,7 +328,7 @@ reliably pull the final answer out of a solution and produced ~40% broken output
## Known gaps (per README pre-deploy checklist)
- Postgres is live; no off-machine backup yet (owner waiting for the destination from the senior).
- Postgres is live and streamed live to the backup server (see «بکاپ» below); restoring onto 249 itself never rehearsed.
- Throttle rates need periodic review against real traffic.
- `DEV_MASTER_OTP` (`0000`) must stay gated behind `DEBUG` — never enabled in prod.
- No video content exists yet, though `Lesson.kind`/`video_url` can express it.
@@ -445,6 +445,29 @@ preferred setup, because there is then no key to leak. **Never let the server ru
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
anyone who can read this repo can forge a token for any user.**
## بکاپ: کپیِ زنده روی سرورِ ۲۴۷ — از ۲۰۲۶-۱۰-۱۰
**هر تغییرِ دیتابیس لحظه‌ای به سرورِ دیگری می‌رود؛ بکاپِ شبانه‌ی خودمان (`backup_db`/`backup_verify`/cron) حذف شد.**
شرحِ کامل و رویه‌ی بازیابی: `docs/operations.md` «بکاپ و بازیابی». فایل‌ها: `ops/pgbackup/`.
- **سرورِ بکاپ:** `administrator@78.38.35.247`، همان رمزِ ۲۴۹ (همان فایلِ رمز). شبکه‌ی داخلی: ۲۴۹ = `192.168.200.164`،
۲۴۷ = `192.168.200.167`. **۲۴۷ هم ماشینِ مشترک است** (artina، judge0، metaverse، docker، و `postgresql@14-main`ِ
دیگران روی ۵۴۳۲): همان قواعدِ «The server» این‌جا هم هست. مالِ ما فقط `~/zareian-pgbackup/` و یونیت‌های
`zareian-pg-*` است. Postgresِ آن‌جا را به کار نمی‌گیریم؛ فقط ابزارهای Postgres 14 را اجرا می‌کنیم.
- **چطور:** روی ۲۴۷ `zareian-pg-tunnel` (SSH با کلیدِ `~/.ssh/zareian_pgbackup` که در `authorized_keys`ِ ۲۴۹ به
`permitopen="127.0.0.1:5440"` محدود است، شِل نمی‌دهد) → `zareian-pg-wal` (`pg_receivewal`، اسلاتِ `zareian_247`،
نقشِ `zareian_replicator`، رمزش فقط در `~/.pgpass`ِ ۲۴۷) → `wal/`؛ و `zareian-pg-basebackup.timer` هر روز ۰۴:۳۰ →
`base/` (۷ تا، با `pg_verifybackup`؛ WALِ قدیمی‌تر از قدیمی‌ترین پاک می‌شود).
- **۲۴۹ را نمی‌خواباند:** تکثیر ناهمزمان است (`sync_state: async`)؛ `max_slot_wal_keep_size=1GB` (با `ALTER SYSTEM`،
نه `/etc`) سقفِ چیزی است که در قطعیِ ۲۴۷ روی دیسکِ ۲۴۹ جمع می‌شود. قطعیِ طولانی‌تر اسلات را `lost` می‌کند (رویه در docs).
- **پایش:** `OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹؛ `/api/health` (با کلید) `offsiteBackup` و هشدار می‌دهد
(`config/tests_offsite_backup.py`). شکستِ نسخه‌ی روزانه فقط در `journalctl -u zareian-pg-basebackup`ِ ۲۴۷ دیده می‌شود.
- **سنجیده (۲۰۲۶-۱۰-۱۰):** کلید شِل نمی‌گیرد و تونل به پورتِ دیگر رد می‌شود؛ `restore-check.sh` روی ۲۴۷ شمارش و آخرین
زمانِ کاربر/ورود/سفارش/تراکنش/تغییرِ وضعیت را برابرِ ۲۴۹ داد؛ یک ردیفِ آزمایشی که بعد از نسخه‌ی کامل روی ۲۴۹ نوشته شد
۲ ثانیه بعد در بازیابیِ ۲۴۷ بود (جدولش پاک شد). **نسنجیده:** ری‌بوتِ ۲۴۷، قطعیِ طولانی تا `lost`، اجرای خودکارِ timer
(اولین بار ۲۰۲۶-۱۰-۱۱ ۰۴:۳۰)، و برگرداندنِ بازیابی روی خودِ ۲۴۹.
- ایمیجِ روزانه‌ی کلِ ماشین کارِ ادمینِ سرور است؛ محتوایش را ما نسنجیده‌ایم. کپیِ ما به آن تکیه نمی‌کند.
## اشتراک و دیوارِ پرداخت — روی سرور از ۲۰۲۶-۱۰-۱۰ ۰۱:۱۹ (بک‌اند `64c4c09`، فرانت `a8a5351`)
- **فقط فصلِ اولِ هر پایه رایگان است** (`content.views.unit_is_free`). بیرون از آن، بی‌اشتراکِ فعال، سوال‌های درس،
@@ -903,9 +926,9 @@ migrationها اعمال شدند، سه‌یار و `nextLessonId` از بیر
(`templates/tadrisino/`) تا متنِ فارسی راست‌به‌چپ رندر شود. قالبِ drf-spectacular
دست‌نخورده است و با `{{ block.super }}` گسترش داده شده.
**بکاپ:** `deploy.sh` پیش از `migrate` خودش بکاپ می‌گیرد (`db-before-deploy-*`، ۲ تای آخر،
بی‌بکاپ migrate نمی‌شود)؛ روی سرور هم اجرا شد (۲۰۲۶-۱۰-۰۷، `integrity_check: ok`). بیرون از ماشین بکاپی
نیست.
**بکاپ:** `deploy.sh` پیش از `migrate` خودش بکاپ می‌گیرد (`db-before-deploy-*`، ۳ تای آخر، بی‌بکاپ
migrate نمی‌شود) و مرحله‌ی «نگه‌داریِ بکاپ‌ها» بقیه‌ی `.deploy-backups` را مرتب می‌کند (بکاپِ دستیِ پیش از
تغییر بعد از ۳۰ روز پاک می‌شود). کپیِ واقعیِ داده بیرون از ماشین است؛ بخشِ پایین.
**⚠️ Gitea از روی سرور:** از ۲۰۲۶-۱۰-۰۷ `https://gitea.tadrisino2.ir` از خودِ سرور (حل‌شده به IPهای
ArvanCloud) ۵۰۴ می‌دهد و از لپ‌تاپ ۲۰۰؛ علتش پیدا نشد (مسیرِ آروان↔سرور). `deploy.sh` در `git fetch` می‌ایستد.

View File

@@ -134,11 +134,6 @@ curl https://mathapi.tadrisino2.ir/api/health
اینها عمداً نوشته شده‌اند تا کسی دوباره کشفشان نکند:
- **دیتابیس هنوز SQLite است**، نه Postgres. برای حجمِ فعلی کافی‌ست، ولی
تک‌نویسنده بودنش اولین گلوگاهِ مقیاس است.
- **بکاپ فقط روی همان ماشین است.** `deploy.sh` پیش از `migrate` از `db.sqlite3` بکاپ می‌گیرد
(۲ تای آخر)، ولی نسخه‌ای بیرون از سرور نیست. جزئیات در
[`docs/operations.md`](docs/operations.md).
- **`CACHE_URL` روی سرور خالی است، عمداً**، پس محدودیتِ نرخ بینِ workerها مشترک نیست. Redisِ روی آن ماشین
مالِ تیم‌های دیگر است و به آن وصل نمی‌شویم؛ Redisِ اختصاصیِ خودمان یک تصمیمِ باز پیش از لانچ است.
`/api/health` این را هشدار می‌دهد.

View File

@@ -144,41 +144,62 @@ def _ai_provider_state() -> dict:
return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
def _backup_warnings() -> list[str]:
"""بکاپِ روزانه، کپیِ بیرونی و آزمونِ بازیابی؛ فقط وقتی Postgres است (SQLite یعنی توسعه‌ی محلی)."""
from datetime import datetime, timezone as dt_tz
def _offsite_backup() -> dict:
"""
کپیِ زنده‌ی دیتابیس روی سرورِ دیگر (`ops/pgbackup/`، شرح در docs/operations.md «بکاپ»).
from statuslog.backup import read_status
آن سرور با `pg_receivewal` از اسلاتِ تکثیرِ `OFFSITE_WAL_SLOT` هر تغییر را لحظه‌ای می‌گیرد؛ این‌جا از
سمتِ همین دیتابیس دیده می‌شود که وصل است و چقدر عقب است. ست‌نبودنِ اسلات روی Postgres یعنی کپیِ
بیرونی نداریم و هشدار می‌شود. base backupِ روزانه‌ی آن سرور از این‌جا دیده نمی‌شود (journalِ همان‌جا).
"""
if connection.vendor != 'postgresql':
return {'state': 'not_postgres'}
slot = settings.OFFSITE_WAL_SLOT
if not slot:
return {'state': 'not_configured'}
try:
with connection.cursor() as cursor:
cursor.execute(
'SELECT active, wal_status, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) '
'FROM pg_replication_slots WHERE slot_name = %s',
[slot],
)
row = cursor.fetchone()
except Exception as exc: # noqa: BLE001
return {'state': 'error', 'error': type(exc).__name__}
return _slot_state(row)
if settings.DEBUG or connection.vendor != 'postgresql':
return []
st = read_status()
now = datetime.now(dt_tz.utc)
def age_hours(iso: str):
return (now - datetime.fromisoformat(iso)).total_seconds() / 3600 if iso else None
def _slot_state(row) -> dict:
if row is None:
return {'state': 'slot_missing'}
active, wal_status, lag = row
lag = int(lag or 0)
if wal_status == 'lost':
# سقفِ max_slot_wal_keep_size خورده: دیسکِ این سرور امن ماند ولی زنجیره‌ی WAL آن‌جا بریده است.
state = 'lost'
elif not active:
state = 'disconnected'
elif lag > settings.OFFSITE_WAL_MAX_LAG_BYTES:
state = 'lagging'
else:
state = 'ok'
return {'state': state, 'lagBytes': lag}
out = []
if st.get('corrupt'):
return ['status.json بکاپ خوانده نشد؛ لاگ را ببینید.']
age = age_hours(st.get('last_ok_at'))
if age is None:
out.append('هیچ بکاپِ زمان‌بندی‌شده‌ای ثبت نشده (manage.py backup_db با cron اجرا نمی‌شود).')
elif age > settings.BACKUP_MAX_AGE_HOURS:
out.append(f'آخرین بکاپِ موفق {age:.0f} ساعت پیش بود (سقف {settings.BACKUP_MAX_AGE_HOURS}).')
if st.get('last_error'):
out.append(f"آخرین تلاشِ بکاپ شکست خورد: {st['last_error'][:200]}")
if (st.get('offsite') or {}).get('state') != 'ok':
out.append('کپیِ بکاپ بیرون از این ماشین نیست یا شکست خورده (BACKUP_OFFSITE_CMD).')
rc = st.get('restore_check') or {}
rc_age = age_hours(rc.get('at'))
if rc_age is None:
out.append('بازیابیِ بکاپ هنوز آزموده نشده (manage.py backup_verify).')
elif not rc.get('ok'):
out.append('آخرین آزمونِ بازیابی ناهمخوان بود.')
elif rc_age > settings.RESTORE_CHECK_MAX_AGE_DAYS * 24:
out.append(f'آخرین آزمونِ بازیابی {rc_age / 24:.0f} روز پیش بود.')
return out
_OFFSITE_MESSAGES = {
'not_configured': 'کپیِ زنده‌ی دیتابیس بیرون از این سرور تنظیم نشده (OFFSITE_WAL_SLOT).',
'slot_missing': 'اسلاتِ تکثیرِ OFFSITE_WAL_SLOT در Postgres نیست؛ سرورِ بکاپ هیچ‌وقت وصل نشده یا اسلات حذف شده.',
'disconnected': 'سرورِ بکاپ الان وصل نیست؛ تغییرات روی همین سرور جمع می‌شوند تا سقفِ max_slot_wal_keep_size.',
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.',
}
def _offsite_warnings(offsite: dict) -> list[str]:
msg = _OFFSITE_MESSAGES.get(offsite['state'])
return [msg] if msg else []
def _warnings() -> list[str]:
@@ -219,7 +240,6 @@ def _warnings() -> list[str]:
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها '
'«در دسترس نیست» می‌دهد تا حساب شارژ شود.'
)
out.extend(_backup_warnings())
backend = settings.CACHES['default']['BACKEND']
if not settings.DEBUG and 'LocMem' in backend:
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
@@ -261,7 +281,8 @@ class HealthView(APIView):
if not HasInternalServiceKey().has_permission(request, self):
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
warnings = _warnings()
offsite = _offsite_backup()
warnings = _warnings() + _offsite_warnings(offsite)
payload = {
'status': 'ok' if critical_ok else 'error',
'debug': bool(settings.DEBUG),
@@ -269,6 +290,7 @@ class HealthView(APIView):
'checks': checks,
'integrations': _check_integrations(),
'traffic': _traffic(),
'offsiteBackup': offsite,
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
# IPای که throttle برای همین درخواست می‌بیند. باید IPِ عمومیِ خودِ
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط

View File

@@ -970,11 +970,11 @@ LOGGING = {
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
LOG_DIR = os.environ.get('LOG_DIR', '')
# بکاپِ زمان‌بندی‌شده (statuslog/backup.py). پیش‌فرض داخلِ همان پوشه‌ی gitignoreشده‌ی deploy.sh است؛
# مقصدِ بیرونِ ماشین جداست: BACKUP_OFFSITE_CMD در .env (ست‌نبودنش را /api/health هشدار می‌دهد).
BACKUP_DIR = os.environ.get('BACKUP_DIR', str(BASE_DIR / '.deploy-backups' / 'scheduled'))
BACKUP_MAX_AGE_HOURS = 26 # روزانه + دو ساعت جا؛ دیرتر از این یعنی cron نخورده
RESTORE_CHECK_MAX_AGE_DAYS = 35
# کپیِ زنده‌ی دیتابیس روی سرورِ دیگر (ops/pgbackup/): نامِ اسلاتِ تکثیری که آن سرور از آن می‌خواند.
# خالی روی Postgres یعنی کپیِ بیرونی نداریم و /api/health هشدار می‌دهد.
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
# بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است.
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
if LOG_DIR:
os.makedirs(LOG_DIR, exist_ok=True)

View File

@@ -0,0 +1,40 @@
"""
وضعیتِ کپیِ زنده‌ی دیتابیس در /api/health (config/health.py: _slot_state، _offsite_warnings).
پرسشِ واقعی از pg_replication_slots این‌جا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ روی سرور با اسلاتِ
واقعی دیده شد (docs/operations.md «بکاپ»).
"""
from django.test import SimpleTestCase, override_settings
from config import health
class SlotStateTests(SimpleTestCase):
def test_missing_slot(self):
self.assertEqual(health._slot_state(None)['state'], 'slot_missing')
def test_streaming_and_caught_up_is_ok(self):
self.assertEqual(health._slot_state((True, 'reserved', 0)), {'state': 'ok', 'lagBytes': 0})
def test_inactive_slot_is_disconnected(self):
self.assertEqual(health._slot_state((False, 'reserved', 0))['state'], 'disconnected')
def test_lost_slot_wins_over_everything(self):
self.assertEqual(health._slot_state((False, 'lost', None))['state'], 'lost')
@override_settings(OFFSITE_WAL_MAX_LAG_BYTES=100)
def test_lag_above_limit(self):
self.assertEqual(health._slot_state((True, 'reserved', 101))['state'], 'lagging')
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
class OffsiteWarningTests(SimpleTestCase):
def test_ok_and_dev_are_silent(self):
self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), [])
self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), [])
def test_every_bad_state_warns(self):
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error'):
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
def test_sqlite_reports_not_postgres(self):
self.assertEqual(health._offsite_backup(), {'state': 'not_postgres'})

View File

@@ -222,9 +222,10 @@ printf ' دیتابیس و بکاپ‌ها فقط برای %s خواندنی‌
#
# با sqlite3.Connection.backup() نه cp: کپیِ ساده وسطِ یک نوشتن فایلِ نیمه‌کاره می‌دهد (sqlite3
# به‌عنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمی‌شود: دیپلوی بی‌بکاپ را
# عمداً نمی‌پذیریم. فقط ۲ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و یکی قبل‌تر) نگه داشته می‌شود تا دیسکِ
# عمداً نمی‌پذیریم. فقط ۳ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و دو تای قبل) نگه داشته می‌شود تا دیسکِ
# مشترک پر نشود؛ بکاپ‌های دستی با نامِ دیگر دست نمی‌خورند.
# Postgres با pg_dump (آزموده‌نشده روی سرور تا انتقال)؛ بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چک‌لیستِ لانچ).
# Postgres با pg_dump. این بکاپ فقط برای برگشتن از migrationِ خراب است؛ کپیِ زنده‌ی بیرونِ ماشین جداست
# (ops/pgbackup/، docs/operations.md «بکاپ و بازیابی»).
step "بکاپِ دیتابیس"
python - <<'PYBACKUP' || fail "بکاپِ دیتابیس نشد؛ migrate اجرا نشد و هیچ تغییری روی داده اعمال نشد."
import datetime
@@ -252,7 +253,7 @@ if db['ENGINE'] == 'django.db.backends.postgresql':
# فقط خوانا بودنِ فایل را می‌سنجد؛ بازیابیِ واقعی روی دیتابیسِ دیگر آزموده نشده.
subprocess.run(['pg_restore', '--list', dst], check=True, stdout=subprocess.DEVNULL)
print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، pg_restore --list: ok)')
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.pgdump'))[:-2]
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.pgdump'))[:-3]
for path in old:
os.remove(path)
raise SystemExit(0)
@@ -285,13 +286,30 @@ if result != 'ok':
raise SystemExit(f' بکاپ ساخته شد ولی integrity_check «{result}» داد: {dst}')
print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، integrity_check: ok)')
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.sqlite3'))[:-2]
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.sqlite3'))[:-3]
for path in old:
os.remove(path)
if old:
print(f' {len(old)} بکاپِ خودکارِ قدیمی‌تر پاک شد (۲ تای آخر می‌ماند).')
print(f' {len(old)} بکاپِ خودکارِ قدیمی‌تر پاک شد (۳ تای آخر می‌ماند).')
PYBACKUP
# ─── ۳.۸ نگه‌داریِ بکاپ‌ها ─────────────────────────────────────────────────
# .deploy-backups بی‌سقف بزرگ می‌شد و دیسکِ این ماشینِ مشترک ~۸۸٪ پُر است. قاعده‌ی هر انتشار:
# db-before-deploy-* ۳ تای آخر (مرحله‌ی قبل)
# local-changes/commits-* ۳ تای آخر از هر کدام
# هر چیزِ دیگر بکاپ‌های دستیِ پیش از تغییر (env-before-*، db-before-*، unit-*…): پس از ۳۰ روز
# پاک. کپیِ .env رمز دارد و یک ماه بعد کسی به آن برنمی‌گردد.
# کپیِ واقعیِ داده این‌جا نیست: کپیِ زنده روی سرورِ دیگر است (ops/pgbackup/).
step "نگه‌داریِ بکاپ‌ها"
if [ -d .deploy-backups ]; then
for pattern in 'local-changes-*.patch' 'local-commits-*.patch'; do
ls -1t .deploy-backups/$pattern 2>/dev/null | tail -n +4 | xargs -r rm -f --
done
find .deploy-backups -maxdepth 1 -type f ! -name 'db-before-deploy-*' ! -name 'local-changes-*' \
! -name 'local-commits-*' -mtime +30 -print -delete | sed 's/^/ پاک شد: /'
printf ' .deploy-backups: %s\n' "$(du -sh .deploy-backups | cut -f1)"
fi
# ─── ۴. دیتابیس ────────────────────────────────────────────────────────────
step "اعمالِ مهاجرت‌های دیتابیس"
python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد."

View File

@@ -72,25 +72,73 @@ curl -i https://mathapi.tadrisino2.ir/api/health
## بکاپ و بازیابی
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). سه لایه:
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`).
| لایه | چه زمانی | نگه‌داری | کجا |
| لایه | چه چیزی | از دست رفتنِ داده در بدترین حالت | کجا |
|---|---|---|---|
| پیش از migrate | هر `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمی‌شود) | ۲ تای آخر | `.deploy-backups/db-before-deploy-*.pgdump` |
| روزانه | cron: `python manage.py backup_db` (`statuslog/backup.py`) | ۷ روزِ آخر + ۴ هفته‌ی آخر | `.deploy-backups/scheduled/` + `status.json` |
| بیرونِ ماشین | همان فرمان، اگر `BACKUP_OFFSITE_CMD` در `.env` باشد (فرمانی با `{file}`، مثلاً rsync/scp/rclone) | دست‌ِ مقصد | مقصد را ادمین تعیین می‌کند؛ تا تعیین نشده `/api/health` هشدار می‌دهد |
| کپیِ زنده (اصلی) | هر تغییرِ دیتابیس لحظه‌ای به سرورِ دیگر می‌رود + یک نسخه‌ی کامل در روز؛ بازیابی تا هر لحظه از ۷ روزِ اخیر | چند ثانیه‌ی آخر | `administrator@78.38.35.247:~/zareian-pgbackup/` |
| پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمی‌شود)؛ برای برگشتن از migrationِ خراب | — | `~/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) |
| ایمیجِ کلِ ماشین | کارِ ادمینِ سرور، روزانه؛ ما محتوایش را نسنجیده‌ایم | — | دستِ ادمین |
نصبِ بکاپِ شبانه (یک بار، با `crontab -e` حسابِ `administrator`؛ بکاپِ crontab را پیش از ویرایش بگیرید):
بکاپِ شبانه‌ی جداگانه (`backup_db` با cron) حذف شد: کپیِ زنده همان کار را بهتر می‌کند و ایمیجِ روزانه را ادمین دارد.
### کپیِ زنده چطور کار می‌کند
Postgres هر تغییر را اول در یک دفترِ پیوسته (WAL) می‌نویسد. سرورِ ۲۴۷ این دفتر را با ابزارِ خودِ Postgres
(`pg_receivewal`) لحظه‌ای می‌گیرد، نه کپیِ کلِ دیتابیس: فقط تغییرات می‌روند. روزی یک بار هم یک نسخه‌ی کامل
(`pg_basebackup`) گرفته می‌شود؛ ۷ نسخه‌ی آخر می‌مانند و WALِ قدیمی‌تر از قدیمی‌ترینشان پاک می‌شود، پس حجم
محدود است. بازیابی = آخرین نسخه‌ی کاملِ پیش از لحظه‌ی دلخواه + بازپخشِ WAL تا آن لحظه.
```
15 2 * * * cd /home/administrator/mathapp-v2 && venv/bin/python manage.py backup_db >> .deploy-backups/scheduled/cron.log 2>&1
۲۴۹ (اپ) ۲۴۷ (بکاپ)
Postgres 5440 (localhost) ◄── تونلِ SSH ─── zareian-pg-tunnel (127.0.0.1:15440)
اسلاتِ zareian_247 zareian-pg-wal → wal/ (لحظه‌ای)
نقشِ zareian_replicator zareian-pg-basebackup.timer → base/ (هر روز ۰۴:۳۰، ۷ تا)
```
**آزمونِ بازیابی (ماهی یک بار، و پس از هر تغییرِ بکاپ):** `venv/bin/python manage.py backup_verify` آخرین بکاپ را روی دیتابیسِ آزمایشیِ `<DB_NAME>_restorecheck` برمی‌گرداند، شمارشِ کاربر/ورود/سفارش/تراکنش/لاگِ وضعیت را با لحظه‌ی بکاپ می‌سنجد و آن دیتابیس را می‌اندازد؛ به دیتابیسِ اصلی دست نمی‌زند. نقشِ برنامه باید `CREATEDB` داشته باشد (`ALTER ROLE zareian_app CREATEDB;` با ادمینِ Postgres). فایلِ دست‌خورده را با هش رد می‌کند.
- **۲۴۹ را نمی‌خواباند:** اپ منتظرِ ۲۴۷ نمی‌ماند (تکثیرِ ناهمزمان). اگر ۲۴۷ قطع شود، اسلات تغییرات را نگه می‌دارد تا
وصل شود، ولی حداکثر ۱ گیگ (`max_slot_wal_keep_size`)؛ بیش از آن اسلات «lost» می‌شود و دیسکِ ۲۴۹ امن می‌ماند.
نسخه‌ی کامل با `--checkpoint=spread --max-rate=20M` گرفته می‌شود.
- **امنیت:** کلیدِ SSHِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با `restrict,port-forwarding,permitopen="127.0.0.1:5440"`
محدود است (نه شِل، نه پورتِ دیگر). رمزِ نقشِ `zareian_replicator` فقط در `~/.pgpass`ِ ۲۴۷ است. این نقش
همه‌ی داده را می‌خواند (طبیعتِ بکاپ)، پس `~/zareian-pgbackup` با مجوز ۷۰۰ است. ترافیک در شبکه‌ی داخلی است
(۲۴۹ = `192.168.200.164`، ۲۴۷ = `192.168.200.167`).
- **Postgresِ ۲۴۷ را به کار نمی‌گیریم:** سرویسِ `postgresql@14-main` آن‌جا مالِ دیگران است. ما فقط ابزارهای
Postgres 14 را اجرا می‌کنیم؛ آزمونِ بازیابی یک Postgresِ موقت روی سوکتِ داخلِ پوشه‌ی خودمان بالا می‌آورد.
- **پایش:** `/api/health` (با `X-Internal-Key`) زیرِ `offsiteBackup` وضعیت و عقب‌ماندگی را می‌دهد و اگر ۲۴۷
وصل نباشد، عقب باشد یا اسلات lost شده باشد هشدار می‌دهد (`OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹).
شکستِ نسخه‌ی کاملِ روزانه از ۲۴۹ دیده نمی‌شود: روی ۲۴۷ `systemctl status zareian-pg-basebackup` و
`journalctl -u zareian-pg-basebackup`. شکستش داده را از دست نمی‌دهد (WAL از آخرین نسخه‌ی سالم نگه داشته
می‌شود)، فقط بازیابی را کندتر می‌کند.
**بازیابیِ واقعی** (دیتابیسِ اصلی خراب شد): `sudo systemctl stop zareian-mathapp` ← از دیتابیسِ فعلی یک `pg_dump` دیگر بگیرید ← `dropdb`/`createdb` ← `pg_restore --no-owner -d zareian_mathapp <فایل>` ← `start` ← `/api/health`. سفارش‌های بعد از زمانِ بکاپ را با پنلِ زرین‌پال/بازار تطبیق دهید.
فایل‌ها در `ops/pgbackup/` ریپو. روی ۲۴۷: یونیت‌ها در `/etc/systemd/system/zareian-pg-*`، اسکریپت‌ها در
`~/zareian-pgbackup/bin/`. هر دو با دست کپی می‌شوند (`deploy.sh` فقط ۲۴۹ را می‌شناسد)؛ بعد از تغییرِ
یکی از آن‌ها: کپی، `sudo systemctl daemon-reload`، و `restart` همان یونیت.
آزموده‌شده: بکاپ، نگه‌داری، بازیابی و رد‌شدنِ فایلِ خراب روی Postgresِ لپ‌تاپ. آزموده‌نشده: زیر cron روی سرور، دیسکِ نزدیک به پُر، `BACKUP_OFFSITE_CMD` واقعی.
### آزمونِ بازیابی (ماهی یک بار)
روی ۲۴۷: `~/zareian-pgbackup/bin/restore-check.sh` (یا با یک لحظه: `… '2026-10-10 16:30:00+03:30'`).
آخرین نسخه‌ی کامل + همه‌ی WAL را در پوشه‌ی موقت بالا می‌آورد، شمارش و آخرین زمانِ کاربر/ورود/سفارش/تراکنش/
تغییرِ وضعیت را چاپ می‌کند و پاک می‌کند. همان پرسش را روی ۲۴۹ بزنید؛ باید یکی باشند (جز تغییرِ همان چند ثانیه).
### اگر ۲۴۷ مدتی قطع بود
`/api/health` می‌گوید `disconnected`. با برگشتنِ ۲۴۷ سرویس‌ها خودشان وصل می‌شوند و عقب‌ماندگی را می‌گیرند. اگر
`lost` شد (بیش از ۱ گیگ تغییر در قطعی): روی ۲۴۷
`systemctl stop zareian-pg-wal`، روی ۲۴۹ اسلات را بیندازید
(`SELECT pg_drop_replication_slot('zareian_247')`)، روی ۲۴۷ بسازید
(`pg_receivewal -h 127.0.0.1 -p 15440 -U zareian_replicator --create-slot --slot=zareian_247`)،
`systemctl start zareian-pg-wal` و `systemctl start zareian-pg-basebackup` (نسخه‌ی کاملِ تازه؛ WALِ پیش از آن
بریده است).
### بازیابیِ واقعی
- **دیتابیسِ ۲۴۹ خراب شد یا داده‌ای اشتباهی پاک شد:** لحظه‌ی درست را پیدا کنید، روی ۲۴۷ با `restore-check.sh`
تا آن لحظه آزمایش کنید؛ وقتی درست بود، با `KEEP=1` اجرا کنید تا Postgresِ موقت بماند، از آن با
`pg_dump -Fc -h <پوشه> -U postgres zareian_mathapp` خروجی بگیرید و روی ۲۴۹ با `pg_restore --clean --no-owner -d zareian_mathapp` برگردد (اپ را پیش از آن stop کنید و از
وضعیتِ فعلی هم یک `pg_dump` بگیرید). این مسیر روی سرور آزموده نشده؛ با کمک بروید.
- **کلِ ۲۴۹ رفت:** همان بازیابی تا انتهای WAL روی ماشینِ تازه، و سفارش‌های چند ثانیه‌ی آخر را با پنلِ زرین‌پال/بازار
تطبیق دهید.
بکاپِ دستیِ SQLite (توسعه‌ی محلی):

View File

@@ -28,7 +28,6 @@
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقف‌های مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
| ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر؛ بکاپِ پیش از migrate خودکار است ولی بیرون از ماشین نیست | کم (داده) | زیرساخت |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت |
@@ -116,19 +115,6 @@
با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ
`quota_exhausted` شد.
## ۱۴. دیتابیس و بکاپ
- **چیست:** SQLite در `~/mathapp-v2/db.sqlite3`، روی دیسکی که بینِ چند سرویس مشترک است و
~۸۳٪ پر است. `transaction_mode: IMMEDIATE` هم‌زمانی را درست ولی سریالی می‌کند. `deploy.sh`
پیش از `migrate` بکاپ می‌گیرد (`.deploy-backups/db-before-deploy-*`، ۲ تای آخر؛ اگر بکاپ
نشد، migrate اجرا نمی‌شود)، ولی همه در همان پوشه و روی همان دیسک‌اند. روی لپ‌تاپ و در دیپلوی ۲۰۲۶-۱۰-۰۷ روی سرور آزموده شد
(ساخت و `integrity_check`؛ حذفِ قدیمی‌ها فقط روی لپ‌تاپ). دیتابیسِ لپ‌تاپ ۱۲۶ مگابایت
است و دو بکاپ ~۲۵۰ مگابایت می‌گیرند؛ پیش از اولین دیپلو `df -h /` را ببینید.
- **خطر:** از دست رفتنِ کلِ داده با یک خطای دیسک یا یک دستورِ اشتباه؛ پُر شدنِ دیسک
سرویس‌های دیگر (Gitea، ایمیل) را هم می‌خواباند.
- **رفع:** مهاجرت به Postgres (روی همان ماشین نصب است؛ بعدش بکاپِ `deploy.sh` باید `pg_dump` شود،
الان فقط هشدار می‌دهد) و نگه‌داریِ یک نسخه بیرون از ماشین.
## ۱۵. دسترسیِ سرور
- **چیست:** ورود با رمز به حسابِ مشترکِ `administrator` با `sudo (ALL : ALL) ALL` و عضوِ
@@ -235,6 +221,7 @@
| قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (بی‌جواب‌ماندن و جزیره‌ی قفل جداگانه در ردیفِ بعدی بسته شده‌اند) | `content/tests_hearts_gate.py` |
| `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد، `integrity_check` می‌کند و اگر نشد migrate را اجرا نمی‌کند | مرحله‌ی «بکاپِ دیتابیس»: ۷ بار روی لپ‌تاپ و یک بار روی سرور (۶ مگابایت، `integrity_check: ok`) |
| دیتابیس Postgresِ اختصاصی است و هر تغییرش لحظه‌ای روی سرورِ دیگری (۲۴۷) کپی می‌شود، با نسخه‌ی کاملِ روزانه و بازیابی تا هر لحظه از ۷ روزِ اخیر؛ قطعیِ ۲۴۷ اپ را نمی‌خواباند و بیش از ۱ گیگ روی دیسکِ ۲۴۹ جمع نمی‌شود (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: بازیابی روی ۲۴۷ با شمارش‌های برابرِ ۲۴۹ و یک ردیفِ آزمایشی که ۲ ثانیه بعد آن‌جا بود |
| دیتابیس و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد |
| پانزده مسیرِ خواندنی و نوشتنیِ کم‌خطر سقفِ نرخ دارند (بانکِ سوال و intro بی‌ورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلن‌ها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ می‌گیرد | `content/tests_read_throttles.py` |
| ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) |

28
ops/pgbackup/basebackup.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# نسخه‌ی کاملِ روزانه از Postgresِ ۲۴۹، روی ۲۴۷. هفت نسخه‌ی آخر می‌ماند و WALِ قدیمی‌تر از قدیمی‌ترینِ
# آن‌ها پاک می‌شود؛ پس بازیابی تا هر لحظه از هفت روزِ اخیر ممکن است و حجم محدود می‌ماند.
# --checkpoint=spread و --max-rate بار روی ۲۴۹ را کم نگه می‌دارند (آن‌جا سرورِ اصلی است).
# نسخه تا pg_verifybackup قبولش نکند جای قبلی‌ها را نمی‌گیرد و چیزی پاک نمی‌شود.
set -euo pipefail
ROOT=/home/administrator/zareian-pgbackup
BIN=/usr/lib/postgresql/14/bin
KEEP=7
cd "$ROOT"
umask 077
ts=$(date +%Y%m%d-%H%M%S)
rm -rf base/.inprogress-*
tmp="base/.inprogress-$ts"
"$BIN/pg_basebackup" -h 127.0.0.1 -p 15440 -U zareian_replicator --no-password \
-D "$tmp" -Fp -X stream --checkpoint=spread --max-rate=20M
"$BIN/pg_verifybackup" "$tmp"
mv "$tmp" "base/$ts"
ls -1d base/2* | head -n -"$KEEP" | xargs -r rm -rf
oldest=$(ls -1d base/2* | head -1)
start_wal=$(sed -n 's/^START WAL LOCATION: .* (file \([0-9A-F]*\))$/\1/p' "$oldest/backup_label")
[ -n "$start_wal" ] || { echo "START WAL در $oldest/backup_label پیدا نشد" >&2; exit 1; }
"$BIN/pg_archivecleanup" wal "$start_wal"
echo "ok base/$ts ($(du -sh "base/$ts" | cut -f1)), kept $(ls -1d base/2* | wc -l), wal since $start_wal ($(du -sh wal | cut -f1))"

51
ops/pgbackup/restore-check.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/bin/bash
# آزمونِ بازیابی روی خودِ ۲۴۷: آخرین نسخه‌ی کامل + همه‌ی WALِ رسیده را در پوشه‌ی موقت بالا می‌آورد
# (Postgresِ موقت روی سوکتِ داخلِ همان پوشه، بی‌پورتِ شبکه؛ به Postgresِ دیگرانِ این ماشین دست نمی‌زند)،
# شمارشِ جدول‌های کاربر/ورود/پرداخت و آخرین زمان‌ها را چاپ می‌کند و همه‌چیز را پاک می‌کند.
# همان پرسش را روی ۲۴۹ بزنید و مقایسه کنید. با آرگومان، تا یک لحظه برمی‌گردد:
# restore-check.sh '2026-10-10 16:30:00+03:30'
# با KEEP=1 پوشه و Postgresِ موقت می‌مانند (برای بازیابیِ واقعی: از آن pg_dump بگیرید، بعد pg_ctl stop و rm).
set -euo pipefail
ROOT=/home/administrator/zareian-pgbackup
BIN=/usr/lib/postgresql/14/bin
TARGET="${1:-}"
latest=$(ls -1d "$ROOT"/base/2* | tail -1)
work=$(mktemp -d "$ROOT/restore-check.XXXXXX")
if [ "${KEEP:-0}" != 1 ]; then
trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT
fi
cp -a "$latest" "$work/data"
mkdir "$work/wal"
cp "$ROOT"/wal/* "$work/wal/"
# فایلِ در حالِ نوشتن (.partial) هم داده‌ی واقعی است؛ برای بازیابی نامِ کامل می‌گیرد.
for f in "$work"/wal/*.partial; do [ -e "$f" ] && mv "$f" "${f%.partial}"; done
# postgresql.conf و pg_hba.conf روی اوبونتو در /etc اند، نه در پوشه‌ی داده؛ برای نمونه‌ی موقت ساخته می‌شوند.
cat > "$work/data/postgresql.conf" <<CONF
listen_addresses = ''
unix_socket_directories = '$work'
restore_command = 'cp $work/wal/%f %p'
recovery_target_action = 'promote'
${TARGET:+recovery_target_time = '$TARGET'}
CONF
echo 'local all all trust' > "$work/data/pg_hba.conf"
: > "$work/data/postgresql.auto.conf"
touch "$work/data/recovery.signal"
"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null
for _ in $(seq 120); do
[ "$("$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break
sleep 1
done
echo "base: $(basename "$latest") target: ${TARGET:-end of WAL}"
"$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -c "
select 'users' what, count(*)::text n, max(created_at)::text latest from accounts_user
union all select 'login_events', count(*)::text, max(created_at)::text from accounts_loginevent
union all select 'orders', count(*)::text, max(created_at)::text from payment_order
union all select 'transactions', count(*)::text, max(created_at)::text from payment_transaction
union all select 'status_changes', count(*)::text, max(created_at)::text from statuslog_statuschangelog;"
[ "${KEEP:-0}" = 1 ] && echo "kept: $work (psql -h $work -U postgres -d zareian_mathapp)"
exit 0

View File

@@ -0,0 +1,10 @@
# روی سرورِ بکاپ (۲۴۷). یک نسخه‌ی کاملِ روزانه؛ بازیابی = آخرین نسخه + WALِ بعدش تا هر لحظه.
[Unit]
Description=Zareian MathApp: daily base backup from 249
Requires=zareian-pg-tunnel.service
After=zareian-pg-tunnel.service
[Service]
Type=oneshot
User=administrator
ExecStart=/home/administrator/zareian-pgbackup/bin/basebackup.sh

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Zareian MathApp: daily base backup from 249
[Timer]
OnCalendar=*-*-* 04:30:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,19 @@
# روی سرورِ بکاپ (۲۴۷). تونلِ SSH از 127.0.0.1:15440ِ این‌جا به Postgresِ ما روی ۲۴۹ (127.0.0.1:5440).
# کلید در authorized_keysِ ۲۴۹ با restrict,port-forwarding,permitopen="127.0.0.1:5440" محدود است:
# با آن نه شِل باز می‌شود نه پورتِ دیگری. ترافیک در شبکه‌ی داخلی (192.168.200.x) می‌ماند.
[Unit]
Description=Zareian MathApp: SSH tunnel to the app Postgres on 249
After=network-online.target
Wants=network-online.target
[Service]
User=administrator
ExecStart=/usr/bin/ssh -N -i /home/administrator/.ssh/zareian_pgbackup \
-o BatchMode=yes -o ExitOnForwardFailure=yes \
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
-L 127.0.0.1:15440:127.0.0.1:5440 administrator@192.168.200.164
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,19 @@
# روی سرورِ بکاپ (۲۴۷). هر تغییرِ دیتابیسِ ۲۴۹ را لحظه‌ای در wal/ می‌نویسد (pg_receivewal، ابزارِ خودِ Postgres).
# --synchronous: هر تکه همان لحظه روی دیسکِ این‌جا fsync می‌شود. اپ روی ۲۴۹ منتظرِ این‌جا نمی‌ماند
# (synchronous_standby_names خالی است)، پس قطعیِ ۲۴۷ هیچ پرداختی را کند یا متوقف نمی‌کند.
# اسلاتِ zareian_247 روی ۲۴۹ نگه می‌دارد که در قطعی چیزی جا نیفتد؛ سقفش max_slot_wal_keep_size=1GB است.
[Unit]
Description=Zareian MathApp: live WAL stream from 249
After=zareian-pg-tunnel.service
BindsTo=zareian-pg-tunnel.service
[Service]
User=administrator
ExecStart=/usr/lib/postgresql/14/bin/pg_receivewal -h 127.0.0.1 -p 15440 -U zareian_replicator \
--no-password --slot=zareian_247 --synchronous --no-loop \
-D /home/administrator/zareian-pgbackup/wal
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,26 @@
"""
ردِ نتیجه‌ی درگاه در لاگِ فنی: کال‌بکِ بی‌سفارش و نتیجه‌ی تکراری فقط همین‌جا رد دارند (پولی جابه‌جا
نمی‌کنند، پس ردیفِ دیتابیس ندارند). نتیجه‌های پذیرفته‌شده در Transaction/StatusChangeLog‌اند.
"""
from django.test import TestCase
class PaymentAuditLogTests(TestCase):
def test_unknown_authority_callback_is_404_and_logged(self):
with self.assertLogs('payment.views', level='WARNING') as cm:
r = self.client.get('/api/payment/zarinpal/callback?Authority=A000NOPE&Status=OK')
self.assertEqual(r.status_code, 404)
self.assertIn('no order', cm.output[0])
def test_ignored_duplicate_paid_result_is_logged_as_warning(self):
from payment.models import Order
from payment.services import apply_order_payment_result
o = Order.objects.create(user_id=1, amount=1000, idempotency_key='k1', gateway_authority='A1')
with self.assertLogs('payment.services', level='INFO') as cm:
apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1',
new_status='paid', event='t')
self.assertIn('ignored=False', cm.output[0])
with self.assertLogs('payment.services', level='INFO') as cm:
apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1',
new_status='paid', event='t')
self.assertIn('ignored=True', cm.output[0])

View File

@@ -1,224 +0,0 @@
"""
بکاپِ زمان‌بندی‌شده‌ی Postgres و آزمونِ بازیابی.
چرا فایلِ جدا: تا امروز تنها بکاپِ خودکار همان بود که `deploy.sh` پیش از migrate می‌گیرد (۲ تای آخر)؛
اگر چند روز دیپلویی نبود، بکاپِ تازه‌ای هم نبود. این‌جا یک بکاپِ روزانه، نگه‌داری، هوک برای کپیِ
بیرونِ ماشین، و بازیابی روی دیتابیسِ آزمایشی هست. وضعیتش در `status.json` کنارِ بکاپ‌ها می‌ماند و
`/api/health` از روی آن هشدار می‌دهد.
آزموده‌شده: pg_dump/pg_restore روی Postgres ۱۴ی لپ‌تاپ (statuslog/tests_backup.py، تست‌های واقعی فقط
وقتی Postgres در دسترس باشد). آزموده‌نشده: روی سرور، زیر cron، با دیسکِ نزدیک به پُر، با
`BACKUP_OFFSITE_CMD` واقعی.
"""
import datetime
import hashlib
import json
import logging
import os
import shlex
import shutil
import subprocess
from pathlib import Path
from django.apps import apps
from django.conf import settings
from django.db import connection
logger = logging.getLogger(__name__)
STATUS_NAME = 'status.json'
KEEP_DAILY = 7 # هفت روزِ آخر، از هر روز آخرین بکاپ
KEEP_WEEKLY = 4 # چهار هفته‌ی آخر، از هر هفته آخرین بکاپ
# جدول‌هایی که پرسشِ «پرداخت و ورود» به آن‌ها بسته است؛ شمارششان با بکاپ و بازیابی مقایسه می‌شود.
TRACKED = ['accounts.User', 'accounts.LoginEvent', 'payment.Order', 'payment.Transaction',
'statuslog.StatusChangeLog']
class BackupError(Exception):
pass
def backup_dir() -> Path:
return Path(settings.BACKUP_DIR)
def status_path() -> Path:
return backup_dir() / STATUS_NAME
def read_status() -> dict:
try:
return json.loads(status_path().read_text())
except FileNotFoundError:
return {}
except (OSError, ValueError):
# فایلِ خراب همان «بکاپ نیست» نیست؛ صدا کن تا /api/health آن را نشان دهد.
logger.exception('status.json خوانده نشد: %s', status_path())
return {'corrupt': True}
def write_status(update: dict) -> dict:
status = read_status()
status.pop('corrupt', None)
status.update(update)
backup_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
tmp = status_path().with_suffix('.tmp')
tmp.write_text(json.dumps(status, ensure_ascii=False, indent=1))
os.chmod(tmp, 0o600)
os.replace(tmp, status_path())
return status
def require_postgres() -> dict:
db = settings.DATABASES['default']
if db['ENGINE'] != 'django.db.backends.postgresql':
raise BackupError('بکاپِ زمان‌بندی‌شده فقط برای Postgres است (DB_NAME در .env ست نیست).')
return db
def _pg_env(db: dict) -> dict:
# رمز از env می‌رود نه argv: ps روی ماشینِ مشترک argv را به همه نشان می‌دهد.
return dict(os.environ, PGPASSWORD=db['PASSWORD'])
def _pg_args(db: dict) -> list:
return ['-h', db['HOST'], '-p', str(db['PORT']), '-U', db['USER']]
def table_counts(conn=None) -> dict:
conn = conn or connection
out = {}
with conn.cursor() as cur:
for label in TRACKED:
table = apps.get_model(label)._meta.db_table
cur.execute(f'SELECT count(*) FROM "{table}"')
out[label] = cur.fetchone()[0]
return out
def _run(cmd: list, env: dict, what: str) -> subprocess.CompletedProcess:
try:
return subprocess.run(cmd, env=env, check=True, capture_output=True, text=True)
except FileNotFoundError as exc:
raise BackupError(f'{what}: فرمانِ {cmd[0]} روی این ماشین نیست.') from exc
except subprocess.CalledProcessError as exc:
raise BackupError(f'{what} شکست خورد: {(exc.stderr or "").strip()[-500:]}') from exc
def _prune(directory: Path) -> list:
files = sorted(directory.glob('db-*.pgdump'))
by_day, by_week = {}, {}
for f in files: # مرتب‌شده؛ آخری برای هر کلید می‌ماند
stamp = datetime.datetime.strptime(f.name[3:18], '%Y%m%d-%H%M%S')
by_day[stamp.date()] = f
iso = stamp.isocalendar()
by_week[(iso[0], iso[1])] = f
keep = {v for _, v in sorted(by_day.items())[-KEEP_DAILY:]}
keep |= {v for _, v in sorted(by_week.items())[-KEEP_WEEKLY:]}
removed = [f for f in files if f not in keep]
for f in removed:
f.unlink()
return removed
def run_offsite(path: Path) -> dict:
"""
`BACKUP_OFFSITE_CMD` یک فرمان با جاینگهدار `{file}` است (مثلاً rsync/scp/rclone به مقصدی که ادمین
تعیین می‌کند). ست‌نبودنش خطا نیست ولی صریح «تنظیم نشده» ثبت می‌شود و health هشدار می‌دهد.
"""
template = os.environ.get('BACKUP_OFFSITE_CMD', '').strip()
if not template:
return {'state': 'not_configured'}
if '{file}' not in template:
return {'state': 'failed', 'error': 'BACKUP_OFFSITE_CMD باید {file} داشته باشد.'}
cmd = [part.replace('{file}', str(path)) for part in shlex.split(template)]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=1800)
except (subprocess.SubprocessError, OSError) as exc:
detail = getattr(exc, 'stderr', '') or str(exc)
logger.error('کپیِ بیرونیِ بکاپ شکست خورد: %s', detail.strip()[-300:])
return {'state': 'failed', 'error': detail.strip()[-300:]}
return {'state': 'ok', 'at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')}
def run_backup() -> dict:
db = require_postgres()
directory = backup_dir()
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
try:
previous = sorted(directory.glob('db-*.pgdump'))
need = (previous[-1].stat().st_size * 2) if previous else 100 * 2**20
free = shutil.disk_usage(directory).free
if free < need:
raise BackupError(f'فضای خالی ({free // 2**20} MB) کمتر از لازم ({need // 2**20} MB) است.')
before = table_counts()
now = datetime.datetime.now()
dst = directory / f'db-{now:%Y%m%d-%H%M%S}.pgdump'
_run(['pg_dump', '-Fc', *_pg_args(db), '-f', str(dst), db['NAME']], _pg_env(db), 'pg_dump')
os.chmod(dst, 0o600)
after = table_counts()
# فقط خواناییِ فایل؛ بازیابیِ واقعی را backup_verify می‌سنجد.
_run(['pg_restore', '--list', str(dst)], _pg_env(db), 'pg_restore --list')
digest = hashlib.sha256(dst.read_bytes()).hexdigest()
except BackupError as exc:
logger.error('بکاپِ زمان‌بندی‌شده شکست خورد: %s', exc)
write_status({'last_error': str(exc),
'last_error_at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')})
raise
offsite = run_offsite(dst)
removed = _prune(directory)
status = write_status({
'last_ok_at': now.astimezone().isoformat(timespec='seconds'),
'file': dst.name, 'size_bytes': dst.stat().st_size, 'sha256': digest,
'counts_before': before, 'counts_after': after,
'offsite': offsite, 'pruned': [f.name for f in removed],
'last_error': '', 'last_error_at': '',
})
return status
def run_verify(file: str = '') -> dict:
"""
فایلِ بکاپ را روی دیتابیسِ آزمایشیِ جدا بازیابی می‌کند، شمارشِ جدول‌های پرداخت/ورود را با لحظه‌ی
بکاپ می‌سنجد و دیتابیسِ آزمایشی را می‌اندازد. به دیتابیسِ اصلی دست نمی‌زند. نقشِ برنامه باید
CREATEDB داشته باشد؛ نداشتنش با پیامِ روشن خطا می‌دهد، نه موفقیتِ ساختگی.
"""
db = require_postgres()
status = read_status()
name = file or status.get('file')
if not name:
raise BackupError('هنوز بکاپی ثبت نشده؛ اول backup_db.')
path = backup_dir() / name
if not path.exists():
raise BackupError(f'فایلِ بکاپ نیست: {path}')
if hashlib.sha256(path.read_bytes()).hexdigest() != status.get('sha256') and not file:
raise BackupError('هشِ فایل با آنچه هنگامِ بکاپ ثبت شد نمی‌خواند (فایل عوض یا خراب شده).')
scratch = f"{db['NAME']}_restorecheck"
env, args = _pg_env(db), _pg_args(db)
_run(['dropdb', '--if-exists', *args, scratch], env, 'dropdb')
_run(['createdb', *args, scratch], env,
'createdb (نقشِ برنامه به CREATEDB نیاز دارد: ALTER ROLE <role> CREATEDB)')
try:
_run(['pg_restore', '--no-owner', '--no-privileges', *args, '-d', scratch, str(path)], env, 'pg_restore')
restored = {}
for label in TRACKED:
table = apps.get_model(label)._meta.db_table
out = _run(['psql', '-tA', *args, '-d', scratch, '-c', f'SELECT count(*) FROM "{table}"'], env, 'psql')
restored[label] = int(out.stdout.strip())
finally:
_run(['dropdb', '--if-exists', *args, scratch], env, 'dropdb')
lo, hi = status.get('counts_before', {}), status.get('counts_after', {})
bad = {k: {'restored': v, 'expected_between': [min(lo.get(k, v), hi.get(k, v)), max(lo.get(k, v), hi.get(k, v))]}
for k, v in restored.items()
if file == '' and not min(lo.get(k, v), hi.get(k, v)) <= v <= max(lo.get(k, v), hi.get(k, v))}
result = {'file': name, 'counts': restored, 'ok': not bad, 'mismatch': bad,
'at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')}
write_status({'restore_check': result})
if bad:
logger.error('آزمونِ بازیابی ناهمخوان: %s', bad)
raise BackupError(f'شمارش‌ها بعد از بازیابی نمی‌خوانند: {bad}')
return result

View File

@@ -1,16 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
from statuslog.backup import BackupError, run_backup
class Command(BaseCommand):
help = 'بکاپِ زمان‌بندی‌شده‌ی Postgres (pg_dump)، نگه‌داری ۷ روزه + ۴ هفته‌ای، هوکِ کپیِ بیرونی.'
def handle(self, *args, **opts):
try:
s = run_backup()
except BackupError as exc:
raise CommandError(str(exc))
self.stdout.write(f"{s['file']} {s['size_bytes'] // 1024} KB offsite={s['offsite']['state']}")
if s['offsite']['state'] == 'failed':
raise CommandError(f"بکاپِ محلی ساخته شد ولی کپیِ بیرونی شکست خورد: {s['offsite'].get('error')}")

View File

@@ -1,17 +0,0 @@
from django.core.management.base import BaseCommand, CommandError
from statuslog.backup import BackupError, run_verify
class Command(BaseCommand):
help = 'آخرین بکاپ را روی دیتابیسِ آزمایشیِ جدا بازیابی و شمارشِ جدول‌های پرداخت/ورود را می‌سنجد.'
def add_arguments(self, parser):
parser.add_argument('--file', default='', help='نامِ یک فایلِ مشخص در پوشه‌ی بکاپ (پیش‌فرض: آخرین).')
def handle(self, *args, **opts):
try:
r = run_verify(opts['file'])
except BackupError as exc:
raise CommandError(str(exc))
self.stdout.write(f"بازیابی از {r['file']} سالم بود: {r['counts']}")

View File

@@ -1,114 +0,0 @@
"""
بکاپِ زمان‌بندی‌شده: نگه‌داری، هوکِ بیرونی، هشدارِ health، و لاگِ پرداخت.
خودِ pg_dump/pg_restore اینجا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ آن را یک اجرای دستی روی Postgresِ
لپ‌تاپ سنجید (شرحش در docs/operations.md). آزموده‌نشده: زیر cron روی سرور.
"""
import datetime
import json
import tempfile
from pathlib import Path
from unittest import mock
from django.test import SimpleTestCase, TestCase, override_settings
from statuslog import backup
def _touch(d: Path, stamp: str):
f = d / f'db-{stamp}.pgdump'
f.write_bytes(b'x')
return f
class PruneTests(SimpleTestCase):
def test_keeps_last_7_days_and_4_weeks_only(self):
with tempfile.TemporaryDirectory() as t:
d = Path(t)
start = datetime.datetime(2026, 8, 1, 2, 0, 0)
for i in range(60): # شصت روز، هر روز یک بکاپ
_touch(d, (start + datetime.timedelta(days=i)).strftime('%Y%m%d-%H%M%S'))
removed = backup._prune(d)
left = sorted(p.name for p in d.glob('db-*.pgdump'))
self.assertEqual(len(left) + len(removed), 60)
self.assertIn('db-20260929-020000.pgdump', left) # آخرین
self.assertLessEqual(len(left), 7 + 4)
self.assertGreaterEqual(len(left), 7)
def test_two_backups_same_day_keep_latest(self):
with tempfile.TemporaryDirectory() as t:
d = Path(t)
_touch(d, '20261001-020000')
_touch(d, '20261001-140000')
backup._prune(d)
self.assertEqual([p.name for p in d.glob('db-*')], ['db-20261001-140000.pgdump'])
class OffsiteTests(SimpleTestCase):
def test_not_configured_is_explicit(self):
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': ''}):
self.assertEqual(backup.run_offsite(Path('/x')), {'state': 'not_configured'})
def test_placeholder_required(self):
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'rsync a b'}):
self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'failed')
def test_failing_command_is_reported_not_swallowed(self):
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'false {file}'}):
self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'failed')
def test_ok(self):
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'true {file}'}):
self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'ok')
class HealthWarningTests(SimpleTestCase):
def _warn(self, status, **settings_kw):
from config import health
with tempfile.TemporaryDirectory() as t, override_settings(BACKUP_DIR=t, DEBUG=False, **settings_kw):
if status is not None:
(Path(t) / 'status.json').write_text(json.dumps(status))
with mock.patch.object(health.connection, 'vendor', 'postgresql'):
return health._backup_warnings()
def test_no_status_warns(self):
self.assertTrue(any('هیچ بکاپ' in w for w in self._warn(None)))
def test_stale_backup_warns(self):
old = (datetime.datetime.now().astimezone() - datetime.timedelta(hours=40)).isoformat()
self.assertTrue(any('ساعت پیش' in w for w in self._warn({'last_ok_at': old})))
def test_fully_healthy_has_no_warning(self):
now = datetime.datetime.now().astimezone().isoformat()
st = {'last_ok_at': now, 'offsite': {'state': 'ok'}, 'restore_check': {'ok': True, 'at': now}}
self.assertEqual(self._warn(st), [])
def test_offsite_missing_and_restore_untested_warn(self):
now = datetime.datetime.now().astimezone().isoformat()
w = self._warn({'last_ok_at': now, 'offsite': {'state': 'not_configured'}})
self.assertTrue(any('بیرون از این ماشین' in x for x in w))
self.assertTrue(any('آزموده نشده' in x for x in w))
def test_sqlite_dev_is_silent(self):
from config import health
self.assertEqual(health._backup_warnings(), [])
class PaymentAuditLogTests(TestCase):
def test_unknown_authority_callback_is_404_and_logged(self):
with self.assertLogs('payment.views', level='WARNING') as cm:
r = self.client.get('/api/payment/zarinpal/callback?Authority=A000NOPE&Status=OK')
self.assertEqual(r.status_code, 404)
self.assertIn('no order', cm.output[0])
def test_ignored_duplicate_paid_result_is_logged_as_warning(self):
from payment.models import Order
from payment.services import apply_order_payment_result
o = Order.objects.create(user_id=1, amount=1000, idempotency_key='k1', gateway_authority='A1')
with self.assertLogs('payment.services', level='INFO') as cm:
apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1',
new_status='paid', event='t')
self.assertIn('ignored=False', cm.output[0])
with self.assertLogs('payment.services', level='INFO') as cm:
apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1',
new_status='paid', event='t')
self.assertIn('ignored=True', cm.output[0])