Compare commits
22 Commits
82fec6bf75
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92b157eda4 | |||
| 4efe6e3f6b | |||
| 43e02adf37 | |||
| e25ffb5575 | |||
| eb03b467d4 | |||
| f2c57c1ad1 | |||
| f6d779255d | |||
| 4cd859b5fa | |||
| dd98f16d12 | |||
| bd329f83ae | |||
| 670513b462 | |||
| 133233144e | |||
| a5a93ef6ba | |||
| 5f963e2898 | |||
| 7bd7c637b4 | |||
| ac96f93abc | |||
| 9903e0ec91 | |||
| 759bb0d575 | |||
| f8a93a37c9 | |||
| 9eae99340d | |||
| f57deb9c1d | |||
| 5fbbd371fe |
@@ -39,6 +39,9 @@ DB_PORT=5432
|
|||||||
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده میگیرد (ops/pgbackup/). روی سرور: zareian_247.
|
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده میگیرد (ops/pgbackup/). روی سرور: zareian_247.
|
||||||
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار میدهد. روی لپتاپ خالی.
|
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار میدهد. روی لپتاپ خالی.
|
||||||
OFFSITE_WAL_SLOT=
|
OFFSITE_WAL_SLOT=
|
||||||
|
# سرورِ اختصاصی (۲۱۶): فایلی که tadrisino-offsite-sync زمانِ آخرین ارسالِ بکاپِ رمزشده به ۲۴۷ را در آن مینویسد.
|
||||||
|
# روی ۲۱۶: /var/lib/tadrisino-backup/offsite-synced-at (بر OFFSITE_WAL_SLOT مقدم است). روی لپتاپ خالی.
|
||||||
|
OFFSITE_SYNC_STATUS_FILE=
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# ۲) دامنهها — دو آدرس، بقیه از آنها ساخته میشوند
|
# ۲) دامنهها — دو آدرس، بقیه از آنها ساخته میشوند
|
||||||
|
|||||||
154
CLAUDE.md
154
CLAUDE.md
@@ -27,7 +27,7 @@ answers from the same book. Two repos, one API contract: this Django + DRF backe
|
|||||||
| Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` |
|
| Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` |
|
||||||
| Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` |
|
| Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` |
|
||||||
| Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` |
|
| Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` |
|
||||||
| Load testing (laptop and the shared server) | `tools/loadtest/README.md` |
|
| Load testing (laptop and the server) | `tools/loadtest/README.md` |
|
||||||
| Frontend structure and its deploy | `~/Desktop/MathApp_frontend/README.md` |
|
| Frontend structure and its deploy | `~/Desktop/MathApp_frontend/README.md` |
|
||||||
| Android build, signing, what the next APK must carry | `~/Desktop/MathApp_frontend/android/README.md` |
|
| Android build, signing, what the next APK must carry | `~/Desktop/MathApp_frontend/android/README.md` |
|
||||||
|
|
||||||
@@ -37,35 +37,65 @@ Before writing a new doc, check this table: the same fact in three places drifts
|
|||||||
|
|
||||||
## ⛔ Working on the servers — read before every SSH session
|
## ⛔ Working on the servers — read before every SSH session
|
||||||
|
|
||||||
The owner often says "put it on the server" / "deploy it" and expects you to do it yourself. You
|
The owner often says "put it on the server" / "deploy it" and expects you to do it yourself, and
|
||||||
may, but these machines are **shared company servers**, not ours. Our app is one tenant among many.
|
"the server" means **216**. You may. 216 is ours alone, but it is production, and two other machines
|
||||||
A careless command can take down the whole company's git, mail and other teams' production apps,
|
we still hold files on (249, 247) are **shared company servers** where our account has full root and
|
||||||
and there is no technical guard rail: our account has full root. **The only guard rail is you.**
|
a careless command can take down the whole company's git, mail and other teams' apps. **The only
|
||||||
|
guard rail is you.**
|
||||||
|
|
||||||
|
### Standing authorization (the owner's, permanent: do not ask again)
|
||||||
|
|
||||||
|
The owner does not want to repeat this each session. When a task needs a server, start working:
|
||||||
|
|
||||||
|
- **Granted without asking:** read the passwords from `~/Desktop/رمز سرور.txt` («The password» below),
|
||||||
|
SSH to 216/249/247, look at anything of ours (status, logs, disk, DB counts, nginx config); on **216**,
|
||||||
|
deploy backend and frontend, restart `zareian-mathapp`, reload nginx, run management commands, take
|
||||||
|
backups, write into our own directories, and install a package from the mirror when a task needs it;
|
||||||
|
move files between servers over the LAN through 216.
|
||||||
|
- **Ask first (one Persian line saying what and why, then wait):** anything that deletes or overwrites
|
||||||
|
data (dropping/restoring a DB, removing backups, `rm` of more than our own temp files), any change on
|
||||||
|
249 or 247 beyond reading, `.env` and `/etc` edits on 216 (after the backup of rule 5), SSH keys,
|
||||||
|
`sshd_config`, firewall, users, reboots, and anything that spends GapGPT/SMS credit beyond a single check.
|
||||||
|
- **Never, even if asked in passing:** touch anything listed as not ours on 249/247, power a machine off,
|
||||||
|
print a password or secret.
|
||||||
|
- Every command still follows «Announce before you act» and the rules below. The auto-mode classifier may
|
||||||
|
block a shared-server action even with this authorization; then stop, say what was blocked, and give the
|
||||||
|
owner the exact command or ask them to approve it.
|
||||||
|
|
||||||
### What is ours — and nothing else is
|
### What is ours — and nothing else is
|
||||||
|
|
||||||
**`78.38.35.249` (`haghzad`, app server)**, account `administrator`:
|
**`78.38.35.216` (hostname `mathapp`, the live server)**: a dedicated VM, the whole machine is ours. Log in
|
||||||
|
as `zareian` (sudo); the senior and the company owner share `mathapp-admin` (sudo); no other logins. The app
|
||||||
|
runs as the non-sudo user `mathapp`:
|
||||||
|
|
||||||
| Ours | Note |
|
| Ours | Note |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/home/administrator/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `logs/`, `.deploy-backups/` are untracked and live here |
|
| `/home/mathapp/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `.pg_password`, `.redis_password`, `logs/`, `.deploy-backups/` are untracked and live here |
|
||||||
| `/home/administrator/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` |
|
| `/home/mathapp/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` |
|
||||||
| `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias |
|
| `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias |
|
||||||
| `zareian-redis.service` (port 6390) | our own Redis |
|
| `zareian-redis.service` (6390), `postgresql@16-zareian` (5440, DB `zareian_mathapp`) | |
|
||||||
| Postgres cluster `14/zareian` (port 5440, DB `zareian_mathapp`) | our own cluster |
|
| `tadrisino-*` timers (pgBackRest, offsite sync, payment CSV, blocklist), `zareian-applog-archive.timer` | sources in `ops/server216/` |
|
||||||
| `zareian-applog-archive.timer` | nightly log archive |
|
| nginx `sites-enabled/mathapp`, `mathapp-front`, `snippets/zareian-*.conf` | sources in `ops/nginx/` |
|
||||||
| nginx: `sites-enabled/mathapp`, `sites-available/mathapp-front`, `snippets/zareian-*.conf` | source of truth is `ops/nginx/` |
|
| `/home/zareian/stage/` | DB dumps from the cut-over (2026-10-18) |
|
||||||
|
|
||||||
**Not ours, never touch:** `~/mathapp` (old, not git, not what runs), the generic `gunicorn.service`
|
**`78.38.35.249` (`haghzad`, shared, retired for us since 2026-10-18)**, account `administrator`: our app
|
||||||
(another team's app), `gitea`, `postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (port 5432),
|
there is stopped and disabled, the replication slot is gone, Arvan no longer points at it. Still ours
|
||||||
`redis-server` (6379), `rabbitmq`, `metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`,
|
and left in place as the rollback until about 2026-10-25: `/home/administrator/mathapp-v2`,
|
||||||
other people's home directories (`aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`) and every
|
`/home/administrator/mathapp_frontend_new`, `gunicorn-mathapp.service` (disabled; the `zareian-mathapp`
|
||||||
other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a
|
alias returns with `enable`), `zareian-redis`, Postgres cluster `14/zareian` (5440, plaintext data as of
|
||||||
request to the server admin, not something to fix.
|
the cut-over), `zareian-applog-archive.timer`, our nginx sites and snippets. Removing them is the owner's
|
||||||
|
call. **Not ours, never touch:** `~/mathapp`, the generic `gunicorn.service`, `gitea`,
|
||||||
|
`postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (5432), `redis-server` (6379), `rabbitmq`,
|
||||||
|
`metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`, other people's home directories and every
|
||||||
|
other nginx site.
|
||||||
|
|
||||||
**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/`
|
**`78.38.35.247` (backup server, shared)**, same account and password as 249: ours is only
|
||||||
and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its
|
`~/zareian-pgbackrest-216/` (216's live backups, encrypted on 216), `~/zareian-pgbackup/` (the 249-era
|
||||||
own `postgresql@14-main`, which we never use.
|
archive, every file now `age`-encrypted to 216's key; its `zareian-pg-*` / `zareian-applog-pull` units are
|
||||||
|
disabled), our lines in `~/.ssh/authorized_keys` and `~/.pgpass`, and `~/.ssh/zareian_*`. 247 never
|
||||||
|
holds a decryption key. It also runs other teams' services and its own `postgresql@14-main`.
|
||||||
|
|
||||||
|
On 216 every rule below applies except 4 and 7 (nothing there is shared); on 249 and 247 all of them.
|
||||||
|
|
||||||
### Rules (not negotiable)
|
### Rules (not negotiable)
|
||||||
|
|
||||||
@@ -73,18 +103,18 @@ own `postgresql@14-main`, which we never use.
|
|||||||
`df -h /`. Know what is there before you act on it.
|
`df -h /`. Know what is there before you act on it.
|
||||||
2. **No destructive command outside our directories.** No `rm -rf`, `chown -R`, `chmod -R`,
|
2. **No destructive command outside our directories.** No `rm -rf`, `chown -R`, `chmod -R`,
|
||||||
`find … -delete`, `DROP`, `docker system prune`, package upgrades, reboots, firewall changes.
|
`find … -delete`, `DROP`, `docker system prune`, package upgrades, reboots, firewall changes.
|
||||||
Inside our directories, write paths out in full (`/home/administrator/mathapp-v2/...`), never
|
Inside our directories, write paths out in full (`/home/mathapp/mathapp-v2/...`), never
|
||||||
rely on the current directory.
|
rely on the current directory.
|
||||||
3. **Never `git clean`, never delete and re-clone the project.** `.env`, `.django_secret_key`, logs
|
3. **Never `git clean`, never delete and re-clone the project.** `.env`, `.django_secret_key`, logs
|
||||||
and backups are untracked; `git reset --hard origin/main` is the way back to a known tree.
|
and backups are untracked; `git reset --hard origin/main` is the way back to a known tree.
|
||||||
4. **Restart only `zareian-mathapp`.** `systemctl restart gunicorn` is another team's app. nginx is
|
4. **Restart only `zareian-mathapp`.** `systemctl restart gunicorn` is another team's app. nginx is
|
||||||
shared: `sudo nginx -t` then `reload`, never `restart`.
|
shared: `sudo nginx -t` then `reload`, never `restart`.
|
||||||
5. **Back up before editing anything under `/etc` or `.env`**, into
|
5. **Back up before editing anything under `/etc` or `.env`**, into
|
||||||
`~/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>`, and say where it is.
|
`/home/mathapp/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>`, and say where it is.
|
||||||
Database: `deploy.sh` already takes a `pg_dump` before `migrate`; for any manual data change take
|
Database: `deploy.sh` already takes a `pg_dump` before `migrate`; for any manual data change take
|
||||||
one yourself first.
|
one yourself first.
|
||||||
6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. A full disk
|
6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. On 216 a full disk
|
||||||
takes Gitea and mail down with us.
|
stops our DB; on 249/247 it takes Gitea and mail down with us.
|
||||||
7. **Do not use other teams' resources**, even "just an empty database" on their Redis or Postgres,
|
7. **Do not use other teams' resources**, even "just an empty database" on their Redis or Postgres,
|
||||||
their queues or their nginx sites. Ask the owner; it becomes a request to the server admin.
|
their queues or their nginx sites. Ask the owner; it becomes a request to the server admin.
|
||||||
8. **Secrets stay put.** Read `.env` only through Django (`venv/bin/python manage.py shell -c` printing
|
8. **Secrets stay put.** Read `.env` only through Django (`venv/bin/python manage.py shell -c` printing
|
||||||
@@ -107,19 +137,25 @@ destroy the password copy, **never** power anything off.
|
|||||||
|
|
||||||
### The password
|
### The password
|
||||||
|
|
||||||
Access is a password for the shared `administrator` account (same for 249 and 247). The owner
|
The owner keeps the passwords in `~/Desktop/رمز سرور.txt`; read them from there, never ask for them in
|
||||||
keeps it in `~/Desktop/رمز سرور.txt` and wants agents to read it from there rather than ask in
|
chat. 216 (`zareian`) uses the `AdminUser-Password` line; 249 and 247 (`administrator`) use the
|
||||||
chat; the auto-mode classifier may still block that read until the owner confirms in the
|
first `Password` line. Take only the value, everything after the first `: ` (stripping only the label
|
||||||
session. Take only the password line, everything after the first `: ` (stripping only the label
|
|
||||||
once sent the whole line and SSH refused it):
|
once sent the whole line and SSH refused it):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sed -n '/^Password/{s/^[^:]*: *//;p;}' ~/Desktop/'رمز سرور.txt' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw"
|
awk '/^AdminUser-Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pwn" && chmod 600 "$SCRATCH/.pwn" # 216
|
||||||
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
|
sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" # 249/247
|
||||||
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
|
# one connection, reused (the shell is zsh: write the -o options out, a "$C" variable does not split)
|
||||||
rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time
|
sshpass -f "$SCRATCH/.pwn" ssh -o ControlMaster=auto -o ControlPath=/tmp/z216-%C -o ControlPersist=30m zareian@78.38.35.216 '<command>'
|
||||||
|
sshpass -f "$SCRATCH/.pwn" ssh -o ControlPath=/tmp/z216-%C zareian@78.38.35.216 'sudo -S -p "" <command>' < "$SCRATCH/.pwn"
|
||||||
|
ssh -o ControlPath=/tmp/z216-%C -O exit zareian@78.38.35.216 # when done, in its own call
|
||||||
|
rm -P "$SCRATCH/.pwn" # as soon as the work is done (macOS has no shred); recreate it next time
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The laptop's connection is slow: move anything bigger than a few MB **between servers over the LAN**
|
||||||
|
(216 = `192.168.200.163`, 249 = `.164`, 247 = `.167`), never piped through the laptop. 216 has `sshpass`;
|
||||||
|
a password copied to 216 for that goes in `/home/zareian/` with mode 600 and is deleted in the same command.
|
||||||
|
|
||||||
Never put it on a command line (`ps` shows argv to every user on the server), in the repo, a
|
Never put it on a command line (`ps` shows argv to every user on the server), in the repo, a
|
||||||
memory, a commit or a note; never echo it. Many SSH connections in a row can trip a lockout: open
|
memory, a commit or a note; never echo it. Many SSH connections in a row can trip a lockout: open
|
||||||
one `ControlMaster` connection (socket under `/tmp`, macOS limits the path to 104 bytes). Do not
|
one `ControlMaster` connection (socket under `/tmp`, macOS limits the path to 104 bytes). Do not
|
||||||
@@ -130,17 +166,18 @@ create SSH keys, edit `sshd_config` or change the password unless the owner asks
|
|||||||
Backend (commit and push to Gitea first; the server deploys what is on `origin/main`):
|
Backend (commit and push to Gitea first; the server deploys what is on `origin/main`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'cd /home/administrator/mathapp-v2 && ./deploy.sh'
|
sshpass -f "$SCRATCH/.pwn" ssh zareian@78.38.35.216 'sudo -S -p "" -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"' < "$SCRATCH/.pwn"
|
||||||
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pw"
|
sshpass -f "$SCRATCH/.pwn" ssh zareian@78.38.35.216 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pwn"
|
||||||
```
|
```
|
||||||
|
|
||||||
`deploy.sh` saves any hand edits as a patch, resets to `origin/main`, installs, backs up the DB,
|
`deploy.sh` runs as `mathapp`: it saves any hand edits as a patch, resets to `origin/main`, installs,
|
||||||
migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn socket. The
|
backs up the DB, migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn
|
||||||
restart is separate because `sudo` inside the script cannot prompt. Then **verify; never assume**:
|
socket. Its own restart fails (`mathapp` has no sudo) and says so; the restart is the second line. Then
|
||||||
|
**verify; never assume**:
|
||||||
|
|
||||||
- `systemctl show zareian-mathapp -p ActiveEnterTimestamp` is after your deploy (a file on disk is not
|
- `systemctl show zareian-mathapp -p ActiveEnterTimestamp` is after your deploy (a file on disk is not
|
||||||
a deployed file: once the server served two-day-old code while every file was current);
|
a deployed file: once the server served two-day-old code while every file was current);
|
||||||
- `git -C /home/administrator/mathapp-v2 log -1 --format=%h` equals your local `main`;
|
- `sudo -u mathapp git -C /home/mathapp/mathapp-v2 log -1 --format=%h` equals your local `main`;
|
||||||
- `curl https://mathapi.tadrisino2.ir/api/health` → `{"status":"ok"}` (details need `X-Internal-Key`);
|
- `curl https://mathapi.tadrisino2.ir/api/health` → `{"status":"ok"}` (details need `X-Internal-Key`);
|
||||||
- the thing you changed, exercised once from outside.
|
- the thing you changed, exercised once from outside.
|
||||||
|
|
||||||
@@ -148,7 +185,7 @@ If the server cannot reach Gitea (`git fetch` 504 from the server, 200 from the
|
|||||||
`DEPLOY_BUNDLE` in `docs/operations.md` «دیپلوی».
|
`DEPLOY_BUNDLE` in `docs/operations.md` «دیپلوی».
|
||||||
|
|
||||||
Frontend: push to `upstream` (`Tadrisino/MathApp_frontend`) `main` (the owner authorized pushing and
|
Frontend: push to `upstream` (`Tadrisino/MathApp_frontend`) `main` (the owner authorized pushing and
|
||||||
deploying it without asking), then `cd /home/administrator/mathapp_frontend_new && ./deploy.sh` (builds
|
deploying it without asking), then as `mathapp` `cd /home/mathapp/mathapp_frontend_new && ./deploy.sh` (builds
|
||||||
into `dist.next`, swaps, keeps `dist.prev`). `config.js` and `index.html` are cached by ArvanCloud;
|
into `dist.next`, swaps, keeps `dist.prev`). `config.js` and `index.html` are cached by ArvanCloud;
|
||||||
purge them there when they change.
|
purge them there when they change.
|
||||||
|
|
||||||
@@ -264,9 +301,13 @@ Invariants that are not obvious from one file:
|
|||||||
|
|
||||||
## Current state, by area
|
## Current state, by area
|
||||||
|
|
||||||
### Infrastructure (server 249)
|
### Infrastructure (server 216)
|
||||||
|
|
||||||
- Postgres cluster `14/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app
|
- Dedicated VMware VM, Ubuntu 24.04, 8 cores (Xeon E5-2690 v3), 12 GB RAM, 97 GB disk; outbound IP
|
||||||
|
`78.38.35.228`. ufw allows 22/80/443; FireHOL blocklist, `ufw limit 22` and stepped fail2ban against SSH
|
||||||
|
brute force. apt/pip/npm come from Iranian mirrors (`docs/operations.md` «سرور»).
|
||||||
|
- Traffic: ArvanCloud → 216:443 for both `math.` and `mathapi.tadrisino2.ir` (origin switched 2026-10-18).
|
||||||
|
- Postgres 16 cluster `16/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app
|
||||||
uses SQLite, which is what local dev and tests use). `max_connections=60`.
|
uses SQLite, which is what local dev and tests use). `max_connections=60`.
|
||||||
- Connections come from a per-worker pool (`DB_POOL_MAX_SIZE` 8); a full pool answers 503 + `Retry-After`
|
- Connections come from a per-worker pool (`DB_POOL_MAX_SIZE` 8); a full pool answers 503 + `Retry-After`
|
||||||
(`config/db.py`), and `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`.
|
(`config/db.py`), and `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`.
|
||||||
@@ -280,27 +321,28 @@ Invariants that are not obvious from one file:
|
|||||||
- nginx (versions in `ops/nginx/`): only ArvanCloud and the machine itself get through (direct IP → 403);
|
- nginx (versions in `ops/nginx/`): only ArvanCloud and the machine itself get through (direct IP → 403);
|
||||||
CSP and security headers on the frontend; API body limit 20 MB; HSTS. Verified in Chrome; **not** verified
|
CSP and security headers on the frontend; API body limit 20 MB; HSTS. Verified in Chrome; **not** verified
|
||||||
in the Android WebView or through `pay.html` to the gateway.
|
in the Android WebView or through `pay.html` to the gateway.
|
||||||
- Capacity (load test on the server, `tools/loadtest/play.py`): 300 players ≈ 88 req/s, median 0.1 s;
|
- Capacity: measured only on 249 (shared CPU): 300 players ≈ 88 req/s, median 0.1 s; 600 ≈ 137 req/s,
|
||||||
600 ≈ 137 req/s, median 1.4 s, no errors; 1,000 hits the ceiling (~135 req/s, p95 15 s). The bottleneck
|
median 1.4 s; 1,000 hit the ceiling. **216 has not been load-tested yet** (`tools/loadtest/README.md`); with
|
||||||
is the shared CPU, not the DB pool. More capacity needs the neighbours' crash-loops fixed plus more cores
|
more cores, 8 workers and `DB_POOL_MAX_SIZE` 6.
|
||||||
(then 8 workers with `DB_POOL_MAX_SIZE` 6).
|
|
||||||
- `/api/health`: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations,
|
- `/api/health`: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations,
|
||||||
`aiProvider`, `offsiteBackup`, warnings, `clientIp`) need `X-Internal-Key`. Swagger/ReDoc/schema need an
|
`aiProvider`, `offsiteBackup`, warnings, `clientIp`) need `X-Internal-Key`. Swagger/ReDoc/schema need an
|
||||||
admin session.
|
admin session.
|
||||||
|
|
||||||
### Backups and logs (server 247)
|
### Backups and logs (encrypted on 216, copied to 247)
|
||||||
|
|
||||||
Every Postgres change streams live to 247 (`pg_receivewal` over a restricted SSH tunnel, slot
|
pgBackRest on 216 archives WAL continuously (at most a minute unarchived) with a weekly full and a daily
|
||||||
`zareian_247`, async; `max_slot_wal_keep_size=1GB` protects 249's disk) plus a daily base backup (7 kept).
|
diff backup, encrypted (`aes-256-cbc`); payment CSVs every 6 hours, encrypted with `age`.
|
||||||
`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). Payment events are exported to CSV on
|
`tadrisino-offsite-sync` copies both to `~/zareian-pgbackrest-216/` on 247 every minute with a write-only
|
||||||
247 every 6 hours; the app log is archived nightly on 249 and pulled hourly to 247, where it is kept forever.
|
key. 247 holds only ciphertext; the keys are in `/etc/tadrisino-backup/RECOVERY-KEYS` on 216, and the owner
|
||||||
Restore procedure, monitoring and what was tested: `docs/operations.md` «بکاپ و بازیابی». Restoring onto 249
|
keeps a copy outside the servers (since 2026-10-18); without them 247's copy is unreadable.
|
||||||
has been done before.
|
`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). The app log is archived nightly on
|
||||||
|
216 but **not yet shipped to 247**. `sudo -u postgres tadrisino-restore-check` restores into a temp dir and
|
||||||
|
compares counts (run after the cut-over: equal). Details: `docs/operations.md` «بکاپ و بازیابی».
|
||||||
|
|
||||||
### Content
|
### Content
|
||||||
|
|
||||||
- `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects,
|
- `content/seed/` is the whole curriculum and question bank as committed JSON: 10 grades (3–12), 5 subjects,
|
||||||
89 units, 1,145 islands, 5,406 lessons, 18,522 questions. `deploy.sh` runs `seed_content` on every release.
|
89 units, 1,145 islands, 5,406 lessons (every one has at least one question), 39,156 questions. `deploy.sh` runs `seed_content` on every release.
|
||||||
- `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers
|
- `seed_content` is idempotent, never deletes, and does not undo admin work: the `SeedRecord` ledger remembers
|
||||||
what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`,
|
what it delivered, so anything the admin deleted or renamed is not recreated; lesson text, `example`,
|
||||||
`steps` and `figures` are only filled when empty. Questions bind to lessons **by title path**
|
`steps` and `figures` are only filled when empty. Questions bind to lessons **by title path**
|
||||||
@@ -369,6 +411,10 @@ has been done before.
|
|||||||
- `POST /api/ai/ask` is RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation
|
- `POST /api/ai/ask` is RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation
|
||||||
(`OPENAI_BASE_URL`, model `gpt-4.1-mini`, prompt `tutor-v12-age-tone`). `usedChunks` names the book chunks
|
(`OPENAI_BASE_URL`, model `gpt-4.1-mini`, prompt `tutor-v12-age-tone`). `usedChunks` names the book chunks
|
||||||
used. Without `OPENAI_API_KEY`/`QDRANT_API_KEY` and `DEBUG=0` it answers 503, never a stub.
|
used. Without `OPENAI_API_KEY`/`QDRANT_API_KEY` and `DEBUG=0` it answers 503, never a stub.
|
||||||
|
- **Broken since the cut-over (2026-10-18):** `qdrant.tadrisino2.ir` (behind Arvan, origin server not ours
|
||||||
|
and unknown to us) now answers a Django `400 Bad Request` page, not Qdrant, from 216 and from the laptop;
|
||||||
|
every retrieval fails and the student sees «سرویس در دسترس نیست». Its Arvan origin has to be put back by
|
||||||
|
the senior; nothing in our code or `.env` changed.
|
||||||
- Answers are plain text, step by step, math-only (`rag/services/guardrails.py`; self-harm messages get a
|
- Answers are plain text, step by step, math-only (`rag/services/guardrails.py`; self-harm messages get a
|
||||||
kind reply with helpline numbers), tone by age, with an optional `<diagram>` JSON block that the frontend
|
kind reply with helpline numbers), tone by age, with an optional `<diagram>` JSON block that the frontend
|
||||||
draws (`rag/services/diagram.py`, frontend `src/diagram/`). Solutions marked `disagrees` never reach the
|
draws (`rag/services/diagram.py`, frontend `src/diagram/`). Solutions marked `disagrees` never reach the
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` |
|
| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` |
|
||||||
| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud |
|
| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud |
|
||||||
| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) |
|
| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) |
|
||||||
| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۱۸٬۵۲۲ سوال |
|
| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۳۹٬۱۵۶ سوال |
|
||||||
| **تست** | ۹۷۳ تستِ خودکار، همه سبز |
|
| **تست** | ۹۷۳ تستِ خودکار، همه سبز |
|
||||||
| **سرویسهای بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوهنگار (پیامک) · زرینپال و کافهبازار (پرداخت) |
|
| **سرویسهای بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوهنگار (پیامک) · زرینپال و کافهبازار (پرداخت) |
|
||||||
|
|
||||||
@@ -97,11 +97,11 @@ python manage.py spectacular --file schema.yaml --format openapi # خروجی
|
|||||||
|
|
||||||
## استقرار
|
## استقرار
|
||||||
|
|
||||||
سرویس روی یک سرورِ مشترکِ شرکت اجرا میشود و مالِ این پروژه فقط پوشهی `~/mathapp-v2` و سرویسِ
|
سرویس روی سرورِ اختصاصیِ `78.38.35.216` اجرا میشود؛ اپ با کاربرِ `mathapp` در `/home/mathapp/mathapp-v2` و سرویسِ
|
||||||
`zareian-mathapp` است. کلِ انتشار:
|
`zareian-mathapp`. کلِ انتشار (جزئیات در `docs/operations.md` «دیپلوی»):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
|
ssh -t zareian@78.38.35.216 'sudo -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"; sudo systemctl restart zareian-mathapp'
|
||||||
```
|
```
|
||||||
|
|
||||||
`deploy.sh` کد را با `origin/main` یکی میکند، وابستگیها را نصب میکند، پیش از `migrate` از دیتابیس بکاپ میگیرد،
|
`deploy.sh` کد را با `origin/main` یکی میکند، وابستگیها را نصب میکند، پیش از `migrate` از دیتابیس بکاپ میگیرد،
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -160,6 +161,8 @@ def _offsite_backup() -> dict:
|
|||||||
"""
|
"""
|
||||||
if connection.vendor != 'postgresql':
|
if connection.vendor != 'postgresql':
|
||||||
return {'state': 'not_postgres'}
|
return {'state': 'not_postgres'}
|
||||||
|
if settings.OFFSITE_SYNC_STATUS_FILE:
|
||||||
|
return _pushed_backup()
|
||||||
slot = settings.OFFSITE_WAL_SLOT
|
slot = settings.OFFSITE_WAL_SLOT
|
||||||
if not slot:
|
if not slot:
|
||||||
return {'state': 'not_configured'}
|
return {'state': 'not_configured'}
|
||||||
@@ -193,6 +196,45 @@ def _slot_state(row) -> dict:
|
|||||||
return {'state': state, 'lagBytes': lag}
|
return {'state': state, 'lagBytes': lag}
|
||||||
|
|
||||||
|
|
||||||
|
def _pushed_backup() -> dict:
|
||||||
|
"""
|
||||||
|
بکاپِ رمزشدهی ۲۱۶: pgBackRest هر WAL را با archive_command آرشیو میکند (pg_stat_archiver) و
|
||||||
|
`tadrisino-offsite-sync` مخزن را هر دقیقه به ۲۴۷ میفرستد و زمانش را در OFFSITE_SYNC_STATUS_FILE مینویسد.
|
||||||
|
کهنگیِ آخرین آرشیو هشدار نیست: دیتابیسِ بیتغییر WALِ تازهای ندارد که آرشیو شود.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute('SELECT last_archived_time, last_failed_time FROM pg_stat_archiver')
|
||||||
|
last_archived, last_failed = cursor.fetchone()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {'state': 'error', 'error': type(exc).__name__}
|
||||||
|
try:
|
||||||
|
with open(settings.OFFSITE_SYNC_STATUS_FILE) as f:
|
||||||
|
synced_at = int(f.read().strip())
|
||||||
|
except FileNotFoundError:
|
||||||
|
synced_at = None
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return {'state': 'error', 'error': type(exc).__name__}
|
||||||
|
return _pushed_state(last_archived, last_failed, synced_at, time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _pushed_state(last_archived, last_failed, synced_at, now) -> dict:
|
||||||
|
age = None if synced_at is None else int(now - synced_at)
|
||||||
|
if last_failed is not None and (last_archived is None or last_failed > last_archived):
|
||||||
|
state = 'archive_failing'
|
||||||
|
elif age is None:
|
||||||
|
state = 'never_synced'
|
||||||
|
elif age > settings.OFFSITE_SYNC_MAX_AGE_SECONDS:
|
||||||
|
state = 'sync_stale'
|
||||||
|
else:
|
||||||
|
state = 'ok'
|
||||||
|
return {
|
||||||
|
'state': state,
|
||||||
|
'syncAgeSeconds': age,
|
||||||
|
'lastArchivedAt': last_archived.isoformat() if last_archived else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# اتصالهایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
|
# اتصالهایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
|
||||||
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
|
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
|
||||||
# عوض میکند و تا بسته شدنِ قبلی همزمان بالاست (یک pool).
|
# عوض میکند و تا بسته شدنِ قبلی همزمان بالاست (یک pool).
|
||||||
@@ -267,6 +309,9 @@ _OFFSITE_MESSAGES = {
|
|||||||
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
|
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
|
||||||
'lost': 'سرورِ بکاپ آنقدر قطع بود که زنجیرهی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
|
'lost': 'سرورِ بکاپ آنقدر قطع بود که زنجیرهی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
|
||||||
'error': 'وضعیتِ کپیِ زندهی دیتابیس خوانده نشد.',
|
'error': 'وضعیتِ کپیِ زندهی دیتابیس خوانده نشد.',
|
||||||
|
'archive_failing': 'آرشیوِ WAL (pgBackRest) شکست میخورد و تغییراتِ تازه بکاپ نمیشوند: sudo -u postgres pgbackrest --stanza=zareian check',
|
||||||
|
'never_synced': 'بکاپِ رمزشده هنوز هیچوقت به سرورِ بیرونی (۲۴۷) نرسیده (فایلِ OFFSITE_SYNC_STATUS_FILE نیست).',
|
||||||
|
'sync_stale': 'ارسالِ بکاپِ رمزشده به ۲۴۷ عقب افتاده: journalctl -u tadrisino-offsite-sync',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1078,6 +1078,12 @@ LOG_DIR = os.environ.get('LOG_DIR', '')
|
|||||||
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
|
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
|
||||||
# بیش از این عقبماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقبماندگی نزدیکِ صفر است.
|
# بیش از این عقبماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقبماندگی نزدیکِ صفر است.
|
||||||
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
|
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
|
||||||
|
# سرورِ اختصاصی (۲۱۶): بکاپِ رمزشدهی pgBackRest؛ `tadrisino-offsite-sync` هر دقیقه آن را به ۲۴۷ میفرستد و زمانِ
|
||||||
|
# آخرین ارسالِ موفق (epoch) را در این فایل مینویسد (docs/operations.md «سرورِ اختصاصیِ جدید»). ست باشد، بر
|
||||||
|
# OFFSITE_WAL_SLOT مقدم است. OFFSITE_WAL_SLOT را فقط ۲۴۹ دارد و با سوئیچ به ۲۱۶ همراهِ _slot_state حذف میشود.
|
||||||
|
OFFSITE_SYNC_STATUS_FILE = os.environ.get('OFFSITE_SYNC_STATUS_FILE', '')
|
||||||
|
# ارسال هر دقیقه است؛ ۱۰ دقیقه بیارسال یعنی چند اجرای پشتِ سرِ هم شکست خورده.
|
||||||
|
OFFSITE_SYNC_MAX_AGE_SECONDS = 600
|
||||||
|
|
||||||
# چرخشِ فایلِ لاگ: 'size' (پیشفرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمیها پاک میشوند؛
|
# چرخشِ فایلِ لاگ: 'size' (پیشفرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمیها پاک میشوند؛
|
||||||
# 'external' (سرور) = برنامه هرگز پاک نمیکند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابهجا و
|
# 'external' (سرور) = برنامه هرگز پاک نمیکند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابهجا و
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
پرسشِ واقعی از pg_replication_slots اینجا اجرا نمیشود (تستها روی SQLiteاند)؛ روی سرور با اسلاتِ
|
پرسشِ واقعی از pg_replication_slots اینجا اجرا نمیشود (تستها روی SQLiteاند)؛ روی سرور با اسلاتِ
|
||||||
واقعی دیده شد (docs/operations.md «بکاپ»).
|
واقعی دیده شد (docs/operations.md «بکاپ»).
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from django.test import SimpleTestCase, override_settings
|
from django.test import SimpleTestCase, override_settings
|
||||||
|
|
||||||
from config import health
|
from config import health
|
||||||
@@ -27,13 +29,45 @@ class SlotStateTests(SimpleTestCase):
|
|||||||
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
|
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(OFFSITE_SYNC_MAX_AGE_SECONDS=600)
|
||||||
|
class PushedStateTests(SimpleTestCase):
|
||||||
|
"""بکاپِ رمزشدهی ۲۱۶ (_pushed_state). پرسشِ pg_stat_archiver و خواندنِ فایل روی سرور دیده شد، نه اینجا."""
|
||||||
|
|
||||||
|
T0 = datetime(2026, 10, 17, 16, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
def test_recent_sync_is_ok(self):
|
||||||
|
r = health._pushed_state(self.T0, None, 1000, now=1060)
|
||||||
|
self.assertEqual((r['state'], r['syncAgeSeconds']), ('ok', 60))
|
||||||
|
self.assertEqual(r['lastArchivedAt'], self.T0.isoformat())
|
||||||
|
|
||||||
|
def test_old_sync_is_stale(self):
|
||||||
|
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1601)['state'], 'sync_stale')
|
||||||
|
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1600)['state'], 'ok')
|
||||||
|
|
||||||
|
def test_no_status_file_is_never_synced(self):
|
||||||
|
self.assertEqual(health._pushed_state(self.T0, None, None, now=1000)['state'], 'never_synced')
|
||||||
|
|
||||||
|
def test_failure_after_last_success_wins(self):
|
||||||
|
later = self.T0 + timedelta(minutes=1)
|
||||||
|
self.assertEqual(health._pushed_state(self.T0, later, 1000, now=1001)['state'], 'archive_failing')
|
||||||
|
self.assertEqual(health._pushed_state(None, later, 1000, now=1001)['state'], 'archive_failing')
|
||||||
|
|
||||||
|
def test_old_failure_then_success_is_ok(self):
|
||||||
|
earlier = self.T0 - timedelta(minutes=1)
|
||||||
|
self.assertEqual(health._pushed_state(self.T0, earlier, 1000, now=1001)['state'], 'ok')
|
||||||
|
|
||||||
|
def test_idle_database_with_no_archive_yet_is_ok(self):
|
||||||
|
self.assertEqual(health._pushed_state(None, None, 1000, now=1001)['state'], 'ok')
|
||||||
|
|
||||||
|
|
||||||
class OffsiteWarningTests(SimpleTestCase):
|
class OffsiteWarningTests(SimpleTestCase):
|
||||||
def test_ok_and_dev_are_silent(self):
|
def test_ok_and_dev_are_silent(self):
|
||||||
self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), [])
|
self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), [])
|
||||||
self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), [])
|
self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), [])
|
||||||
|
|
||||||
def test_every_bad_state_warns(self):
|
def test_every_bad_state_warns(self):
|
||||||
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error'):
|
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error',
|
||||||
|
'archive_failing', 'never_synced', 'sync_stale'):
|
||||||
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
|
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
|
||||||
|
|
||||||
def test_sqlite_reports_not_postgres(self):
|
def test_sqlite_reports_not_postgres(self):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
356308
content/seed/questions.json
356308
content/seed/questions.json
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,11 @@ class _Ledger:
|
|||||||
self.write[key] = (kind, value)
|
self.write[key] = (kind, value)
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
existing = set(SeedRecord.objects.filter(key__in=list(self.write)).values_list('key', flat=True))
|
# دستهدسته: SQLite بیش از ۳۲٬۷۶۶ متغیر در یک پرسوجو نمیپذیرد و دفترِ یک seedِ تازه از آن بیشتر است.
|
||||||
|
keys = list(self.write)
|
||||||
|
existing = set()
|
||||||
|
for i in range(0, len(keys), 500):
|
||||||
|
existing.update(SeedRecord.objects.filter(key__in=keys[i:i + 500]).values_list('key', flat=True))
|
||||||
SeedRecord.objects.bulk_create([
|
SeedRecord.objects.bulk_create([
|
||||||
SeedRecord(key=k, kind=kind, value=value)
|
SeedRecord(key=k, kind=kind, value=value)
|
||||||
for k, (kind, value) in self.write.items() if k not in existing
|
for k, (kind, value) in self.write.items() if k not in existing
|
||||||
|
|||||||
61
content/tests_apply_fixups.py
Normal file
61
content/tests_apply_fixups.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""tools/figures/apply_fixups.py --db: اصلاحِ محتوای منتشرشده روی دیتابیس، بدون از دست رفتنِ پاسخهای ثبتشده."""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from content.models import Choice, Grade, Lesson, Node, Question, Unit, UserAnswer
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
'apply_fixups', os.path.join(settings.BASE_DIR, 'tools', 'figures', 'apply_fixups.py'))
|
||||||
|
apply_fixups = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(apply_fixups)
|
||||||
|
|
||||||
|
PATH = ['پایه سوم', 'فصل ۱', 'جزیره', 'درس']
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyFixupsDbTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
g = Grade.objects.create(title='پایه سوم', icon='', color='')
|
||||||
|
u = Unit.objects.create(grade=g, title='فصل ۱', order=1)
|
||||||
|
n = Node.objects.create(unit=u, title='جزیره', order=1)
|
||||||
|
self.lesson = Lesson.objects.create(node=n, title='درس', intro_text='متن', example='مثالِ غلط',
|
||||||
|
steps=['گام'], figures=[{'where': 'example', 'diagram': {}}])
|
||||||
|
self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='(۱)/(۲) کدام است؟')
|
||||||
|
self.c = [Choice.objects.create(question=self.q, value=v, is_correct=(i == 0))
|
||||||
|
for i, v in enumerate(['(۱)/(۲)', '۲', '۳', '۴'])]
|
||||||
|
self.answer = UserAnswer.objects.create(user_id=7, question=self.q, choice=self.c[0], correct=True)
|
||||||
|
self.dup = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='تکراری')
|
||||||
|
|
||||||
|
def test_question_set_keeps_rows_and_answers(self):
|
||||||
|
problems = apply_fixups.apply_db({'question_set': [[PATH, '(۱)/(۲) کدام است؟', {
|
||||||
|
'prompt': '۱/۲ کدام است؟', 'choices': ['۱/۲', '۲', '۳', '۴'], 'correct': 0}]]})
|
||||||
|
self.assertEqual(problems, [])
|
||||||
|
self.q.refresh_from_db()
|
||||||
|
self.assertEqual(self.q.prompt, '۱/۲ کدام است؟')
|
||||||
|
self.assertEqual([c.value for c in self.q.choices.order_by('id')], ['۱/۲', '۲', '۳', '۴'])
|
||||||
|
self.assertEqual([c.pk for c in self.q.choices.order_by('id')], [c.pk for c in self.c])
|
||||||
|
self.assertTrue(UserAnswer.objects.filter(pk=self.answer.pk, choice=self.c[0]).exists())
|
||||||
|
|
||||||
|
def test_choice_count_mismatch_is_reported_not_applied(self):
|
||||||
|
problems = apply_fixups.apply_db({'question_set': [[PATH, '(۱)/(۲) کدام است؟', {'choices': ['۱', '۲']}]]})
|
||||||
|
self.assertEqual(len(problems), 1)
|
||||||
|
self.assertEqual(self.c[0].__class__.objects.get(pk=self.c[0].pk).value, '(۱)/(۲)')
|
||||||
|
|
||||||
|
def test_lesson_fields_and_delete(self):
|
||||||
|
problems = apply_fixups.apply_db({
|
||||||
|
'lesson': [[PATH, {'example': 'مثالِ درست', 'figures': []}]],
|
||||||
|
'delete': [[PATH, 'تکراری']]})
|
||||||
|
self.assertEqual(problems, [])
|
||||||
|
self.lesson.refresh_from_db()
|
||||||
|
self.assertEqual((self.lesson.example, self.lesson.figures, self.lesson.steps), ('مثالِ درست', [], ['گام']))
|
||||||
|
self.assertFalse(Question.objects.filter(pk=self.dup.pk).exists())
|
||||||
|
|
||||||
|
def test_missing_targets_are_reported(self):
|
||||||
|
problems = apply_fixups.apply_db({'delete': [[PATH, 'نیست']], 'lesson': [[['x', 'y', 'z', 'w'], {'steps': []}]]})
|
||||||
|
self.assertEqual(len(problems), 2)
|
||||||
|
|
||||||
|
def test_unknown_field_stops(self):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
apply_fixups.apply_db({'lesson': [[PATH, {'title': 'نه'}]]})
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
| فایل | محتوا |
|
| فایل | محتوا |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدمها و شکلِ درس (`figures`) |
|
| `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدمها و شکلِ درس (`figures`) |
|
||||||
| `questions.json` | ۱۸٬۵۲۲ سوالِ چهارگزینهای، بعضی با شکل (`figure`) |
|
| `questions.json` | ۳۹٬۱۵۶ سوالِ چهارگزینهای، بعضی با شکل (`figure`) |
|
||||||
|
|
||||||
هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بیآن رد میشود. شکلها
|
هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بیآن رد میشود. شکلها
|
||||||
(`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی میشوند و شکلِ نامعتبر کلِ
|
(`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی میشوند و شکلِ نامعتبر کلِ
|
||||||
@@ -180,3 +180,5 @@ python tools/figures/batch.py merge batch.json # افزود
|
|||||||
|
|
||||||
اصلاحِ سوالِ منتشرشده: `tools/figures/apply_fixups.py` (حذف و خالیکردنِ عنوان، هم روی seed هم با `--db` روی سرور،
|
اصلاحِ سوالِ منتشرشده: `tools/figures/apply_fixups.py` (حذف و خالیکردنِ عنوان، هم روی seed هم با `--db` روی سرور،
|
||||||
پس از `pg_dump`)؛ seed خودش چیزی را بازنویسی نمیکند.
|
پس از `pg_dump`)؛ seed خودش چیزی را بازنویسی نمیکند.
|
||||||
|
فایل `tools/figures/server_fixups.json` اصلاحهای سوالها و درسهایی است که پیش از بازسازیِ پایههای ۳ تا ۱۲ منتشر شده بودند
|
||||||
|
(۹۳ حذف، ۵۸۵ اصلاحِ سوال، ۲۷۳۸ اصلاحِ درس)؛ پس از هر استقرارِ seed با `apply_fixups.py --db` روی سرور اجرا میشود.
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
## دیپلوی
|
## دیپلوی
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -t administrator@78.38.35.249 \
|
ssh -t zareian@78.38.35.216 \
|
||||||
'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
|
'sudo -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"; sudo systemctl restart zareian-mathapp'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`deploy.sh` با کاربرِ `mathapp` اجرا میشود که sudo ندارد، پس ریاستارتِ خودش شکست میخورد و میگوید؛ ریاستارت
|
||||||
|
همان دستورِ دوم است. فرانت: `sudo -u mathapp bash -c "cd /home/mathapp/mathapp_frontend_new && ./deploy.sh"`.
|
||||||
|
|
||||||
دو بار رمز میپرسد — یکبار برای `ssh` و یکبار برای `sudo`. **منتظرِ هر پرامپت
|
دو بار رمز میپرسد — یکبار برای `ssh` و یکبار برای `sudo`. **منتظرِ هر پرامپت
|
||||||
بمانید**؛ چسباندنِ چند خط با هم، متنِ صفشده را به پرامپتِ رمز میدهد و بعد از
|
بمانید**؛ چسباندنِ چند خط با هم، متنِ صفشده را به پرامپتِ رمز میدهد و بعد از
|
||||||
سه تلاش قفل میشوید.
|
سه تلاش قفل میشوید.
|
||||||
@@ -40,7 +43,7 @@ poll کردنِ `/api/health`.
|
|||||||
میشود، چون از داخلِ سرور آدرسِ عمومی از مسیرِ ArvanCloud برمیگردد و ۵۰۴/۰۰۰ میداد (۲۰۲۶-۱۰-۰۷).
|
میشود، چون از داخلِ سرور آدرسِ عمومی از مسیرِ ArvanCloud برمیگردد و ۵۰۴/۰۰۰ میداد (۲۰۲۶-۱۰-۰۷).
|
||||||
- **اگر سرور به Gitea نمیرسد** (از سرور ۵۰۴، از لپتاپ ۲۰۰): روی لپتاپ
|
- **اگر سرور به Gitea نمیرسد** (از سرور ۵۰۴، از لپتاپ ۲۰۰): روی لپتاپ
|
||||||
`git bundle create x.bundle <هشِ origin/main روی سرور>..main`، `scp` به
|
`git bundle create x.bundle <هشِ origin/main روی سرور>..main`، `scp` به
|
||||||
`~/mathapp-v2/.deploy-backups/`، و روی سرور
|
`/home/zareian/stage/` و `sudo install -o mathapp -m 600` به `/home/mathapp/mathapp-v2/.deploy-backups/`، و روی سرور (با `sudo -u mathapp`)
|
||||||
`DEPLOY_BUNDLE=.deploy-backups/x.bundle ./deploy.sh`. هشِ `origin/main` که چاپ میشود را با لپتاپ
|
`DEPLOY_BUNDLE=.deploy-backups/x.bundle ./deploy.sh`. هشِ `origin/main` که چاپ میشود را با لپتاپ
|
||||||
مقایسه کنید. بدونِ این متغیر جایگزینی نیست و نرسیدن به Gitea دیپلو را میایستاند.
|
مقایسه کنید. بدونِ این متغیر جایگزینی نیست و نرسیدن به Gitea دیپلو را میایستاند.
|
||||||
|
|
||||||
@@ -72,102 +75,75 @@ curl -i https://mathapi.tadrisino2.ir/api/health
|
|||||||
|
|
||||||
## بکاپ و بازیابی
|
## بکاپ و بازیابی
|
||||||
|
|
||||||
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`).
|
دیتابیس Postgres 16 روی ۲۱۶ است (cluster `16/zareian`، پورت ۵۴۴۰، فقط localhost). **همهی بکاپها روی خودِ ۲۱۶ رمز
|
||||||
|
میشوند و ۲۴۷ فقط فایلِ رمزشده میبیند**، چون ۲۴۷ مشترک است و هر کس `administrator` را دارد root است.
|
||||||
|
|
||||||
| لایه | چه چیزی | از دست رفتنِ داده در بدترین حالت | کجا |
|
| لایه | چه چیزی | از دست رفتنِ داده در بدترین حالت | کجا |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| کپیِ زنده (اصلی) | هر تغییرِ دیتابیس لحظهای به سرورِ دیگر میرود + یک نسخهی کامل در روز؛ بازیابی تا هر لحظه از ۷ روزِ اخیر | چند ثانیهی آخر | `administrator@78.38.35.247:~/zareian-pgbackup/` |
|
| pgBackRest (اصلی) | آرشیوِ پیوستهی WAL، نسخهی کامل یکشنبهها ۰۳:۰۰، diff هر روز ۰۳:۴۰؛ دو نسخهی کامل میماند؛ `aes-256-cbc`؛ بازیابی تا هر لحظه | حدودِ ۱ دقیقه (`archive_timeout=60`) و تا ۱ دقیقهی دیگر تا رسیدن به ۲۴۷ | `/var/lib/pgbackrest` روی ۲۱۶ و کپی در `~/zareian-pgbackrest-216/repo/` روی ۲۴۷ |
|
||||||
| پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمیشود)؛ برای برگشتن از migrationِ خراب | — | `~/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) |
|
| پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمیشود)؛ برای برگشتن از migrationِ خراب | — | `/home/mathapp/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) |
|
||||||
| ایمیجِ کلِ ماشین | کارِ ادمینِ سرور، روزانه؛ ما محتوایش را نسنجیدهایم | — | دستِ ادمین |
|
| ایمیجِ کلِ ماشین | کارِ ادمینِ سرور؛ ما محتوایش را نسنجیدهایم | — | دستِ ادمین |
|
||||||
|
| آرشیوِ دورهی ۲۴۹ | ۷ نسخهی کاملِ ۱۲ تا ۱۸ اکتبر، WAL، CSVهای پرداخت و لاگِ اپ از وقتی اپ روی ۲۴۹ بود؛ همه با age برای کلیدِ ۲۱۶ رمز شدهاند | فقط برای مراجعه | `~/zareian-pgbackup/` روی ۲۴۷ (پایینِ همین بخش) |
|
||||||
|
|
||||||
بکاپِ شبانهی جداگانه (`backup_db` با cron) حذف شد: کپیِ زنده همان کار را بهتر میکند و ایمیجِ روزانه را ادمین دارد.
|
`tadrisino-offsite-sync` هر دقیقه `/var/lib/pgbackrest` و `/var/lib/tadrisino-backup/payment-log` را با `rsync --delete` به
|
||||||
|
۲۴۷ میفرستد. کلیدِ postgres در `authorized_keys`ِ ۲۴۷ با `from=192.168.200.163,restrict,command="rrsync -wo …"` فقط
|
||||||
|
میتواند در همان پوشه بنویسد (نه بخواند، نه شِل). `/api/health` → `offsiteBackup` از `pg_stat_archiver` و
|
||||||
|
`OFFSITE_SYNC_STATUS_FILE` میگوید آرشیو و sync چند ثانیه عقباند.
|
||||||
|
|
||||||
### دفترِ خرید (`PaymentEvent`) روی هر دو سرور
|
### کلیدهای بازیابی
|
||||||
|
|
||||||
|
`/etc/tadrisino-backup/RECOVERY-KEYS` روی ۲۱۶ (root، 600): کلیدِ pgBackRest و کلیدِ خصوصیِ age
|
||||||
|
(`age-identity.txt` کنارش؛ `age-recipient.txt` کلیدِ عمومی است و رمز کردن با آن بیخطر است). **بیاینها هیچ
|
||||||
|
بکاپی روی ۲۴۷ خواندنی نیست**؛ یک نسخه بیرون از سرورها نزدِ صاحبِ محصول است (از ۲۰۲۶-۱۰-۱۸). اگر کلیدها عوض شدند، نسخهی تازه را
|
||||||
|
او در ترمینالِ خودش برمیدارد: `ssh -t zareian@78.38.35.216 sudo cat /etc/tadrisino-backup/RECOVERY-KEYS` (نه در چت).
|
||||||
|
|
||||||
|
### دفترِ خرید (`PaymentEvent`)
|
||||||
|
|
||||||
هر قدمِ مسیرِ خرید یک ردیف در جدولِ `payment_paymentevent` مینویسد (`payment/events.py`): `order_created`،
|
هر قدمِ مسیرِ خرید یک ردیف در جدولِ `payment_paymentevent` مینویسد (`payment/events.py`): `order_created`،
|
||||||
`discount_applied`، `gateway_redirect`، `gateway_request_failed`، `callback_received`، `callback_unknown_authority`،
|
`discount_applied`، `gateway_redirect`، `gateway_request_failed`، `callback_received`، `callback_unknown_authority`،
|
||||||
`payment_result` (همهی مسیرها: کالبک، بازسنجی، بازار، تمدیدِ بازار، کدِ ۱۰۰٪؛ با `ignored` برای تکراری/جعلی)،
|
`payment_result` (همهی مسیرها: کالبک، بازسنجی، بازار، تمدیدِ بازار، کدِ ۱۰۰٪؛ با `ignored` برای تکراری/جعلی)،
|
||||||
`bazaar_start`، `admin_cancel`، `admin_refund`. توکنِ خریدِ بازار در آن نمیرود. ادمین: «Payment events» (فقطخواندنی).
|
`bazaar_start`، `admin_cancel`، `admin_refund`. توکنِ خریدِ بازار در آن نمیرود. ادمین: «Payment events» (فقطخواندنی).
|
||||||
|
|
||||||
چون جدول در همان Postgres است با WAL لحظهای به ۲۴۷ میرسد. روی ۲۴۷، `zareian-pg-export-events.timer`
|
جدا از بکاپِ دیتابیس، `tadrisino-export-payment-events.timer` (۰۱:۱۵، ۰۷:۱۵، ۱۳:۱۵، ۱۹:۱۵) سه CSVِ تجمعی
|
||||||
(۰۱:۱۵، ۰۷:۱۵، ۱۳:۱۵، ۱۹:۱۵) با `bin/export-payment-events.sh` از روی همان بکاپ (نمونهی موقت روی سوکتِ داخلِ پوشه،
|
(`payment-events-*`، `orders-*`، `transactions-*`) را مستقیم با age رمز میکند و در `/var/lib/tadrisino-backup/payment-log/`
|
||||||
بیاتصال به ۲۴۹ یا Postgresِ دیگران) سه CSVِ تجمعی در `~/zareian-pgbackup/payment-log/` مینویسد:
|
میگذارد؛ ۱۴ تای آخرِ هر نوع میماند و با sync به ۲۴۷ میرود. متنِ خوانا هیچجا ذخیره نمیشود. باز کردن:
|
||||||
`payment-events-*`، `orders-*`، `transactions-*`؛ ۱۴ تای آخرِ هر نوع میماند. شکستش در
|
`age -d -i age-identity.txt payment-events-….csv.age`.
|
||||||
`journalctl -u zareian-pg-export-events`ِ ۲۴۷. زیرِ ۳ گیگ فضای آزاد اجرا نمیشود.
|
|
||||||
|
|
||||||
### لاگِ برنامه (`app.log`): برای همیشه روی ۲۴۷
|
### لاگِ برنامه (`app.log`)
|
||||||
|
|
||||||
با `LOG_FILE_ROTATION=external` در `.env`ِ ۲۴۹ برنامه لاگ را پاک نمیکند (`WatchedFileHandler`). هر شب ۰۰:۰۵
|
با `LOG_FILE_ROTATION=external` برنامه لاگ را پاک نمیکند (`WatchedFileHandler`). هر شب ۰۰:۰۵
|
||||||
`zareian-applog-archive.timer` (۲۴۹) با `ops/applogs/archive-app-log.sh` فایل را به `logs/archive/app-<UTC>.log.gz`
|
`zareian-applog-archive.timer` با `ops/applogs/archive-app-log.sh` فایل را به `logs/archive/app-<UTC>.log.gz` میبرد و
|
||||||
میبرد و فشرده میکند؛ هر ساعت `zareian-applog-pull.timer` (۲۴۷) با `bin/pull-app-logs.sh` فایلهای تازه را به
|
فشرده میکند. **روی ۲۱۶ این آرشیو هنوز به ۲۴۷ نمیرود**؛ راهِ پیشنهادی: رمز کردن با age و افزودنِ `logs/archive` به
|
||||||
`~/zareian-pgbackup/app-logs/` میآورد (`gzip -t` پیش از نگهداشتن) و **هرگز پاک نمیکند** (تصمیمِ مالک ۲۰۲۶-۱۰-۱۳).
|
`tadrisino-offsite-sync` (همان rrsync، پوشهی `app-logs/`). حجم (برآورد): با ۶۰۰ کاربرِ فعال ۲ تا ۳ مگِ فشرده در روز.
|
||||||
روی ۲۴۹ فقط ۳۰ روز میماند و فقط فایلی پاک میشود که ۲۴۷ گرفتنش را تأیید کرده (`.shipped`).
|
اگر آرشیوِ شبانه بخوابد و `app.log` از ۲۵۶ مگ بگذرد، `/api/health` هشدار میدهد. خواندن: `zcat app-*.log.gz | grep user_id=26`.
|
||||||
|
|
||||||
دسترسی: کلیدِ `~/.ssh/zareian_applogs`ِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با
|
|
||||||
`restrict,command="…/ops/applogs/serve-archive.sh"` است: فقط `list`، `get NAME`، `ack NAME`؛ شِل و پورت ندارد
|
|
||||||
(سنجیده: `id` و `cat /etc/passwd` → `bad request`). بکاپِ `authorized_keys` پیش از افزودن کنارش است.
|
|
||||||
|
|
||||||
حجم (برآورد، نه سنجش): لاگِ JSON حدودِ ۱۵ برابر فشرده میشود (۷۰۰ک → ۴۰ک در اولین اجرا)؛ با ۶۰۰ کاربرِ فعال
|
|
||||||
حدودِ ۳۰ مگِ خام و ۲ تا ۳ مگِ فشرده در روز، یعنی حدودِ ۱ گیگ در سال روی ۲۴۷. اگر آرشیوِ شبانه بخوابد و
|
|
||||||
`app.log` از ۲۵۶ مگ بگذرد، `/api/health` هشدار میدهد. خواندن: `zcat app-*.log.gz | grep user_id=26`.
|
|
||||||
|
|
||||||
### کپیِ زنده چطور کار میکند
|
|
||||||
|
|
||||||
Postgres هر تغییر را اول در یک دفترِ پیوسته (WAL) مینویسد. سرورِ ۲۴۷ این دفتر را با ابزارِ خودِ Postgres
|
|
||||||
(`pg_receivewal`) لحظهای میگیرد، نه کپیِ کلِ دیتابیس: فقط تغییرات میروند. روزی یک بار هم یک نسخهی کامل
|
|
||||||
(`pg_basebackup`) گرفته میشود؛ ۷ نسخهی آخر میمانند و WALِ قدیمیتر از قدیمیترینشان پاک میشود، پس حجم
|
|
||||||
محدود است. بازیابی = آخرین نسخهی کاملِ پیش از لحظهی دلخواه + بازپخشِ WAL تا آن لحظه.
|
|
||||||
|
|
||||||
```
|
|
||||||
۲۴۹ (اپ) ۲۴۷ (بکاپ)
|
|
||||||
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/ (هر روز ۰۴:۳۰، ۷ تا)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **۲۴۹ را نمیخواباند:** اپ منتظرِ ۲۴۷ نمیماند (تکثیرِ ناهمزمان). اگر ۲۴۷ قطع شود، اسلات تغییرات را نگه میدارد تا
|
|
||||||
وصل شود، ولی حداکثر ۱ گیگ (`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 از آخرین نسخهی سالم نگه داشته
|
|
||||||
میشود)، فقط بازیابی را کندتر میکند.
|
|
||||||
|
|
||||||
فایلها در `ops/pgbackup/` ریپو. روی ۲۴۷: یونیتها در `/etc/systemd/system/zareian-pg-*`، اسکریپتها در
|
|
||||||
`~/zareian-pgbackup/bin/`. هر دو با دست کپی میشوند (`deploy.sh` فقط ۲۴۹ را میشناسد)؛ بعد از تغییرِ
|
|
||||||
یکی از آنها: کپی، `sudo systemctl daemon-reload`، و `restart` همان یونیت.
|
|
||||||
|
|
||||||
### آزمونِ بازیابی (ماهی یک بار)
|
### آزمونِ بازیابی (ماهی یک بار)
|
||||||
|
|
||||||
روی ۲۴۷: `~/zareian-pgbackup/bin/restore-check.sh` (یا با یک لحظه: `… '2026-10-10 16:30:00+03:30'`).
|
`sudo -u postgres tadrisino-restore-check` روی ۲۱۶: آخرین بکاپ را در پوشهی موقت بالا میآورد و شمارشِ
|
||||||
آخرین نسخهی کامل + همهی 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`
|
- **دادهای اشتباهی پاک شد یا دیتابیس خراب شد:** اپ را stop، از وضعیتِ فعلی یک `pg_dump` بگیرید، و
|
||||||
تا آن لحظه آزمایش کنید؛ وقتی درست بود، با `KEEP=1` اجرا کنید تا Postgresِ موقت بماند، از آن با
|
`pgbackrest --stanza=zareian --type=time --target='<لحظه>' restore` (با `sudo -u postgres`). این مسیر با زمانِ دلخواه
|
||||||
`pg_dump -Fc -h <پوشه> -U postgres zareian_mathapp` خروجی بگیرید و روی ۲۴۹ با `pg_restore --clean --no-owner -d zareian_mathapp` برگردد (اپ را پیش از آن stop کنید و از
|
روی سرور آزموده نشده؛ با کمک بروید.
|
||||||
وضعیتِ فعلی هم یک `pg_dump` بگیرید). این مسیر روی سرور آزموده نشده؛ با کمک بروید.
|
- **کلِ ۲۱۶ از دست رفت:** روی ماشینِ تازه پوشهی `~/zareian-pgbackrest-216/repo/` را از ۲۴۷ از شبکهی داخلی کپی کنید،
|
||||||
- **کلِ ۲۴۹ رفت:** همان بازیابی تا انتهای WAL روی ماشینِ تازه، و سفارشهای چند ثانیهی آخر را با پنلِ زرینپال/بازار
|
`pgbackrest.conf` را از `ops/server216/` با کلید از RECOVERY-KEYS بسازید و `pgbackrest --stanza=zareian restore`.
|
||||||
تطبیق دهید.
|
بازیابیِ کامل از نسخهی رمزشدهی ۲۴۷ یک بار آزموده شده (دیتابیس و CSV). سفارشهای دقیقهی آخر را با پنلِ
|
||||||
|
زرینپال/بازار تطبیق دهید.
|
||||||
|
|
||||||
|
### آرشیوِ دورهی ۲۴۹ روی ۲۴۷
|
||||||
|
|
||||||
|
`~/zareian-pgbackup/` روی ۲۴۷ بکاپهای زمانی است که اپ روی ۲۴۹ بود (کپیِ زنده با `pg_receivewal` تا ۲۰۲۶-۱۰-۱۸). بعد
|
||||||
|
از سوئیچ هر فایل با age برای همان کلیدِ ۲۱۶ رمز و نسخهی خوانا `shred` شد؛ نامها و تاریخها همان است:
|
||||||
|
`base/<تاریخ>.tar.age` (هر کدام یک `pg_basebackup` با قالبِ plain)، `wal/*.age`، `payment-log/*.csv.age`،
|
||||||
|
`app-logs/*.log.gz.age`، و فهرستِ چکسامها `bin/manifest.txt.age`. یونیتهای `zareian-pg-*` و `zareian-applog-pull` روی
|
||||||
|
۲۴۷ disable شدهاند و اسکریپتهای `bin/` دیگر کاری نمیکنند.
|
||||||
|
|
||||||
|
خواندن فقط روی ۲۱۶ (که کلید را دارد) و از شبکهی داخلی، نه از لپتاپ:
|
||||||
|
`sshpass -f /home/zareian/.pw247 ssh administrator@192.168.200.167 'cat ~/zareian-pgbackup/base/<تاریخ>.tar.age' | sudo age -d -i /etc/tadrisino-backup/age-identity.txt | tar -x -C <پوشه>`.
|
||||||
|
درستیِ رمزنگاری با باز کردنِ دو نسخهی کامل و سه فایلِ نمونه روی ۲۱۶ سنجیده شد. پاک کردنِ این پوشه تصمیمِ
|
||||||
|
صاحبِ محصول است.
|
||||||
|
|
||||||
## متغیرهای محیطی
|
## متغیرهای محیطی
|
||||||
|
|
||||||
@@ -332,37 +308,63 @@ callbackِ زرینپال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
|
|||||||
|
|
||||||
## سرور
|
## سرور
|
||||||
|
|
||||||
`mathapi.tadrisino2.ir` روی یک **ماشینِ مشترکِ شرکت** اجرا میشود (`administrator@78.38.35.249`)، نه سروری که مالِ این
|
`math.tadrisino2.ir` و `mathapi.tadrisino2.ir` از **۲۰۲۶-۱۰-۱۸** روی ماشینِ مجازیِ اختصاصیِ `78.38.35.216` اجرا میشوند
|
||||||
پروژه باشد. روی همان ماشین Gitea، ایمیل، DNS، چند دیتابیس و اپهای تیمهای دیگر در حالِ کارند، و حسابِ ما `sudo`ِ
|
(ArvanCloud → 216:443). کلِ ماشین مالِ ماست. قواعدِ کار روی سرورها، با فهرستِ آنچه روی ۲۴۹ و ۲۴۷ مالِ ما نیست،
|
||||||
کامل دارد؛ تنها مانع میانِ یک فرمانِ بیدقت و دادهی کلِ شرکت، خودِ اجراکننده است. سرورِ بکاپ (`78.38.35.247`) هم
|
در `CLAUDE.md` «Working on the servers» است.
|
||||||
مشترک است.
|
|
||||||
|
|
||||||
مالِ ما روی ۲۴۹: پوشههای `~/mathapp-v2` (بکاند) و `~/mathapp_frontend_new` (فرانت)، سرویسِ `gunicorn-mathapp` با
|
| | |
|
||||||
نامِ دومِ **`zareian-mathapp`** (همیشه همین نام را بنویسید)، `zareian-redis`، clusterِ Postgresِ `14/zareian`، تایمرِ
|
|---|---|
|
||||||
`zareian-applog-archive`، و دو سایت و دو snippetِ nginx (بخشِ بعد). روی ۲۴۷ فقط `~/zareian-pgbackup/` و یونیتهای
|
| ماشین | VMware، Ubuntu 24.04، ۸ هسته (Xeon E5-2690 v3)، ۱۲ گیگ رم، دیسکِ ۹۷ گیگ؛ hostname `mathapp`، IPِ داخلی `192.168.200.163`، IPِ خروجی `78.38.35.228` |
|
||||||
`zareian-*`.
|
| ورود | دو حساب با sudo: `zareian` (صاحبِ محصول) و `mathapp-admin` (مشترکِ سینیور و صاحبِ شرکت)؛ هر دو با رمزِ خطِ `AdminUser-Password` فایلِ رمز. ورودِ root بسته، `AllowUsers zareian mathapp-admin`، forwarding بسته (`10-tadrisino.conf`)، `LoginGraceTime 2m` |
|
||||||
|
| اپ | کاربرِ بیsudoِ `mathapp`؛ `/home/mathapp/mathapp-v2` و `/home/mathapp/mathapp_frontend_new` |
|
||||||
|
| سرویسها | `zareian-mathapp` (= `gunicorn-mathapp`، با `RuntimeDirectory`)، `zareian-redis` (6390)، `postgresql@16-zareian` (5440)، `nginx`، تایمرهای `tadrisino-*` و `zareian-applog-archive` |
|
||||||
|
| فایلها | نسخهی اصلیِ همهی اسکریپتها و تنظیمهای این سرور در `ops/server216/` (بیکلید). تغییر: فایلِ ریپو، بعد کپی روی سرور |
|
||||||
|
| رمزها | رمزِ Postgres و Redis مالِ همین سرور است (`.pg_password`، `.redis_password` کنارِ `.env`) |
|
||||||
|
| فایروال | ufw فقط 22/80/443؛ فایروالِ شرکت هر سه را باز کرده |
|
||||||
|
| ضدِ ربات | ۱) `tadrisino-blocklist.timer`: هر روز FireHOL level1 (Spamhaus، DShield، …) در ipsetِ `tadrisino-block`، همهی پورتها؛ شبکهی داخلی، `78.38.35.0/24` و رنجهای آروان هرگز در آن نیستند. ۲) `ufw limit 22`: بیش از ۶ اتصال در ۳۰ ثانیه رد. ۳) fail2ban پلهای: ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه، هر تکرار دو برابر تا یک هفته؛ `recidive`: ۵ بار در روز → یک هفته همهی پورتها؛ `192.168.200.0/24` هرگز. آزاد کردن: `sudo fail2ban-client set sshd unbanip <IP>` |
|
||||||
|
| بستهها | apt از `mirror.arvancloud.ir`، pip از `mirror-pypi.runflare.com` (`~mathapp/.config/pip/pip.conf`)، npm از `mirror-npm.runflare.com`؛ `files.pythonhosted.org` و `registry.npmjs.org` از این سرور بستهاند |
|
||||||
|
| شبکهی داخلی | ۲۴۹ = `192.168.200.164`، ۲۴۷ = `.167`. انتقالِ فایلِ بزرگ بینِ سرورها از همین مسیر (اینترنتِ لپتاپ کند است) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl restart zareian-mathapp # مالِ ما
|
sudo systemctl restart zareian-mathapp # اپ
|
||||||
sudo systemctl restart gunicorn # مالِ ما نیست — اپِ تیمِ دیگر
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
قواعدی که قابلِ مذاکره نیستند (نسخهی کامل، با فهرستِ آنچه مالِ ما نیست و رویهی دیپلو، در `CLAUDE.md` «Working on
|
**سنجیده بعد از سوئیچ (۲۰۲۶-۱۰-۱۸):** ۴۴۳ از بیرون باز است و درخواستِ مستقیم به IP ۴۰۳ میگیرد؛ درخواستهای هر دو دامنه
|
||||||
the servers»):
|
از مسیرِ آروان به ۲۱۶ میرسند (در `access.log` با IPِ آروان)؛ `/api/health` با جزئیات از مسیرِ آروان ok و بیهشدار؛
|
||||||
|
دیتابیسِ ۲۴۹ با همان شمارشها منتقل شد؛ بکاپِ کاملِ تازه و آزمونِ بازیابی برابر. صاحبِ محصول از گوشی وارد شد و صفحهی
|
||||||
|
زرینپال باز شد. **نسنجیده:** پرداختِ کامل، خریدِ بازار، اپِ اندروید روی سرورِ تازه.
|
||||||
|
|
||||||
1. **اول فقط نگاه کنید**، بعد تغییر بدهید؛ هیچ فرمانِ مخربی بیرون از پوشههای ما، و مسیرها را کامل بنویسید.
|
### ۲۴۹ (کنار گذاشته، هنوز پاک نشده)
|
||||||
2. **`git clean` هرگز، و پروژه هرگز پاک و «از نو clone» نشود.** `.env`، `.django_secret_key`، لاگها و بکاپها در گیت
|
|
||||||
نیستند و همانجا زندگی میکنند؛ راهِ برگشت `git reset --hard origin/main` است.
|
از ۲۰۲۶-۱۰-۱۸ اپ روی ۲۴۹ stop و disable است، اسلاتِ `zareian_247` برداشته شده و یونیتهای بکاپِ آن روی ۲۴۷ خاموشاند.
|
||||||
3. **فقط `zareian-mathapp` ریاستارت شود**؛ nginx مشترک است: `nginx -t` و بعد `reload`، هرگز `restart`.
|
پوشهها، cluster ِ `14/zareian` (با دادهی خوانای لحظهی سوئیچ)، `zareian-redis` و سایتهای nginxِ ما آنجا ماندهاند.
|
||||||
4. **پیش از ویرایشِ هر چیز زیرِ `/etc` یا `.env` بکاپِ تاریخدار** در `~/mathapp-v2/.deploy-backups/`.
|
|
||||||
5. **`.env` فقط از راهِ Django خوانده شود**؛ `cat .env` نه، و مقادیرش در هیچ چتی paste نشود.
|
- **برگشت** (تا وقتی ۲۱۶ نوشتنِ واقعی مثلِ پرداخت و ثبتنام نگرفته): سینیور originِ آروان را به ۲۴۹ برمیگرداند و روی ۲۴۹
|
||||||
6. **`df -h /` پیش از هر کارِ پرنوشت.** پر شدنِ دیسک ایمیل و Gitea را هم پایین میکشد.
|
`sudo systemctl enable --now gunicorn-mathapp`. بعد از اولین نوشتنِ واقعی، اول dumpِ ۲۱۶ به ۲۴۹.
|
||||||
7. **از منابعِ تیمهای دیگر استفاده نشود** (Redis، Postgres، صف، سایتِ nginx)؛ نیاز به آنها درخواست از ادمینِ سرور است.
|
- **پاک کردن** (حدودِ ۲۰۲۶-۱۰-۲۵، با تأییدِ صاحبِ محصول): cluster و پوشههای ما روی ۲۴۹، و بعد حذفِ
|
||||||
|
`_slot_state`/`OFFSITE_WAL_SLOT` از `config/health.py` و `settings.py`.
|
||||||
|
|
||||||
|
### چه چیزی منتظرِ دیگران است
|
||||||
|
|
||||||
|
- **سینیور:** برگرداندنِ originِ `qdrant.tadrisino2.ir` در آروان (بخشِ Qdrant)؛ private کردنِ مخزن در Gitea؛ ۱۲ هسته و رمِ
|
||||||
|
بیشتر (اختیاری؛ سرور ریبوت را تحمل میکند، تست شده).
|
||||||
|
- **صاحبِ محصول:** آزمونِ اپِ اندروید و `pay.html` روی سرورِ تازه.
|
||||||
|
|
||||||
|
### کارهای باقیمانده
|
||||||
|
|
||||||
|
- توکنِ فقطخواندنیِ Gitea برای `deploy.sh` (بعد از private شدنِ مخزن `git fetch` بیرمز کار نمیکند).
|
||||||
|
- `CORS_ALLOWED_ORIGINS` در `.env`ِ سرور هنوز `localhost:5174` دارد؛ حذفش.
|
||||||
|
- رمز کردن و فرستادنِ آرشیوِ لاگِ اپ به ۲۴۷ (بخشِ «لاگِ برنامه»).
|
||||||
|
- ورود با کلیدِ SSH برای `zareian` (صاحبِ محصول خواسته فعلاً بماند)، بعد `PasswordAuthentication no` با
|
||||||
|
`Match Address 192.168.200.0/24` برای ورودِ با رمز از شبکهی داخلی.
|
||||||
|
- مرورِ کاملِ یک فهرستِ امنیتیِ استاندارد (OWASP Top 10 / API Top 10، CIS Benchmark، همراهِ
|
||||||
|
`docs/security-before-production.md`)، بعد لودتست روی ۲۱۶ (`tools/loadtest/README.md`).
|
||||||
|
|
||||||
## nginx (دو سایتِ ما)
|
## nginx (دو سایتِ ما)
|
||||||
|
|
||||||
نسخهی اصلی در ریپو است: `ops/nginx/`. روی ۲۴۹: `sites-enabled/mathapp` (API، فایلِ معمولی)،
|
نسخهی اصلی در ریپو است: `ops/nginx/`. روی ۲۱۶: `sites-enabled/mathapp` (API، فایلِ معمولی)،
|
||||||
`sites-available/mathapp-front` (فرانت)، و دو snippet در `/etc/nginx/snippets/`:
|
`sites-enabled/mathapp-front` (فرانت)، و دو snippet در `/etc/nginx/snippets/`:
|
||||||
|
|
||||||
| فایل | چه میکند |
|
| فایل | چه میکند |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -370,8 +372,8 @@ the servers»):
|
|||||||
| `zareian-front-headers.conf` | CSP، `X-Frame-Options`، `nosniff`، `Referrer-Policy`، `Permissions-Policy`، HSTS برای فرانت |
|
| `zareian-front-headers.conf` | CSP، `X-Frame-Options`، `nosniff`، `Referrer-Policy`، `Permissions-Policy`، HSTS برای فرانت |
|
||||||
| `mathapp` | سقفِ بدنه ۲۰ مگابایت، HSTS |
|
| `mathapp` | سقفِ بدنه ۲۰ مگابایت، HSTS |
|
||||||
|
|
||||||
تغییر: فایلِ ریپو را ویرایش و کامیت کنید؛ روی سرور فایلِ فعلی را در `.deploy-backups/` کپی کنید، `sudo install -m 644`،
|
تغییر: فایلِ ریپو را ویرایش و کامیت کنید؛ روی سرور فایلِ فعلی را در `/home/mathapp/mathapp-v2/.deploy-backups/` کپی کنید، `sudo install -m 644`،
|
||||||
`sudo nginx -t`، و فقط اگر موفق بود `sudo systemctl reload nginx` (نه restart: سایتهای دیگران روی همین nginxاند).
|
`sudo nginx -t`، و فقط اگر موفق بود `sudo systemctl reload nginx` (reload درخواستهای در جریان را نمیبُرد).
|
||||||
بعد از بیرون: هدرها با `curl -sI`، و درخواستِ مستقیم به IP باید ۴۰۳ بدهد.
|
بعد از بیرون: هدرها با `curl -sI`، و درخواستِ مستقیم به IP باید ۴۰۳ بدهد.
|
||||||
|
|
||||||
**وقتی دیگران به اینها بخورند:**
|
**وقتی دیگران به اینها بخورند:**
|
||||||
@@ -382,6 +384,37 @@ the servers»):
|
|||||||
- سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بیورود و با ورود، همهی تبها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید
|
- سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بیورود و با ورود، همهی تبها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید
|
||||||
(WebView همان CSP را میگیرد) و صفحهی `pay.html` تا درگاه.
|
(WebView همان CSP را میگیرد) و صفحهی `pay.html` تا درگاه.
|
||||||
|
|
||||||
|
## Qdrant
|
||||||
|
|
||||||
|
**وضعِ فعلی Qdrant:** بردارهای RAG روی `https://qdrant.tadrisino2.ir` هستند (پشت ArvanCloud؛ سرورِ اصلی از بیرون پیدا
|
||||||
|
نیست و در فهرستِ سرورهای ما نیست. `rag_catalog` آن را سرورِ تیمِ دیگر میداند). نسخهٔ 1.17.0، ۳۴ collection، حدود
|
||||||
|
۱۰۸۸۰ نقطه، همه با بُعد ۱۵۳۶ و Cosine.
|
||||||
|
|
||||||
|
**هدف:** همهٔ کتابهای ریاضیای که در Qdrant هست (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ پایههای سوم تا دوازدهم) روی یک
|
||||||
|
Qdrant اختصاصی ببریم و اپ را به آن وصل کنیم، بدون ingest دوباره، چون ingest هزینهٔ GapGPT دارد.
|
||||||
|
|
||||||
|
**کدام collectionها (۱۵ تا، حدود ۵۶۸۰ نقطه):**
|
||||||
|
- ریاضی سوم، چهارم، پنجم، ششم، هفتم، هشتم، نهم، دهم.
|
||||||
|
- حسابان یازدهم، دوازدهم.
|
||||||
|
- هندسه دهم، یازدهم، دوازدهم.
|
||||||
|
- آمار و احتمال یازدهم.
|
||||||
|
- گسسته دوازدهم.
|
||||||
|
|
||||||
|
ریاضیِ سوم، ششم و هفتم را تیمِ دیگر ingest کرده، و صاحبِ محصول خواسته هر سه منتقل شوند. بقیهٔ collectionها (علوم، فارسی،
|
||||||
|
مطالعات اجتماعی، هدیهٔ آسمان، پیامهای آسمان) در این برنامه نیستند.
|
||||||
|
|
||||||
|
**اصولِ انتقال:**
|
||||||
|
- از هر collection یک snapshot بگیرید، فایلِ آن را منتقل و روی Qdrant جدید بارگذاری کنید (بدون فراخوانیِ OpenAI).
|
||||||
|
- بعد از انتقال: تعدادِ نقاط، بُعد و Cosine یکی باشند؛ یک بردارِ ذخیرهشده را بهعنوانِ query بدهید و همان نقطه بالا بیاید.
|
||||||
|
- `/api/rag/ingest` هنوز روی Qdrant فعلی مینویسد. در بازهٔ کپی ingest متوقف شود، یا بعد از سوئیچ نقاطِ تازه دوباره همگام شوند.
|
||||||
|
- `QDRANT_URL` و `QDRANT_API_KEY` را فقط **بعد** از تأییدِ کپی عوض کنید؛ زودتر عوض شود، تیوتور جوابِ ناقص میدهد.
|
||||||
|
|
||||||
|
**خراب از سوئیچ (۲۰۲۶-۱۰-۱۸):** `qdrant.tadrisino2.ir` از مسیرِ آروان یک صفحهی `400 Bad Request`ِ Django برمیگرداند، نه
|
||||||
|
Qdrant (هدرهای `referrer-policy: same-origin` و `cross-origin-opener-policy`؛ از ۲۱۶ و از لپتاپ یکسان؛ درخواست به ۲۱۶ نمیرسد).
|
||||||
|
هر جستجوی دستیار شکست میخورد (`rag.retrieve: جستجوی Qdrant … شکست خورد` در `app.log`) و دانشآموز «سرویس در دسترس
|
||||||
|
نیست» میبیند. یعنی originِ این سابدامین در آروان همزمان با سوئیچ عوض شده؛ درستش با سینیور است. محلِ سرورِ اصلیِ
|
||||||
|
Qdrant را ما نمیدانیم. کد و `.env` عوض نشدهاند. وارسی بعد از درست شدن: یک سوال از پی، و نبودنِ این خطا در `app.log`.
|
||||||
|
|
||||||
## لاگها
|
## لاگها
|
||||||
|
|
||||||
هر درخواستِ `/api/` بهصورتِ یک شیءِ JSON در هر خط لاگ میشود
|
هر درخواستِ `/api/` بهصورتِ یک شیءِ JSON در هر خط لاگ میشود
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
| ۷ | پنلِ `/admin/` روی دامنهی عمومی | متوسط | nginx + کد |
|
| ۷ | پنلِ `/admin/` روی دامنهی عمومی | متوسط | nginx + کد |
|
||||||
| ۱۱ | کدِ OTP بیهش در دیتابیس و در فهرستِ پنل | کم | کد |
|
| ۱۱ | کدِ OTP بیهش در دیتابیس و در فهرستِ پنل | کم | کد |
|
||||||
| ۱۲ | هزینهی دستیارِ AI با ساختِ شمارهی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
|
| ۱۲ | هزینهی دستیارِ AI با ساختِ شمارهی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
|
||||||
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
|
| ۱۵ | دسترسیِ سرور: ورود با رمز از اینترنت، رمز در فایلِ متنی | متوسط | صاحبِ محصول |
|
||||||
| ۱۶ | فرانت توکن را در `localStorage` نگه میدارد | کم | فرانت |
|
| ۱۶ | فرانت توکن را در `localStorage` نگه میدارد | کم | فرانت |
|
||||||
| ۲۴ | سقفِ روزانهی پیامکِ کلِ سامانه (۲٬۰۰۰ روی سرور) را یک ناشناس پر میکند و ورود با پیامک برای همه بسته میشود | بالا (دسترسپذیری، هزینه) | تصمیمِ طراحی |
|
| ۲۴ | سقفِ روزانهی پیامکِ کلِ سامانه (۲٬۰۰۰ روی سرور) را یک ناشناس پر میکند و ورود با پیامک برای همه بسته میشود | بالا (دسترسپذیری، هزینه) | تصمیمِ طراحی |
|
||||||
| ۲۵ | با دانستنِ شمارهی کسی، ورودِ او تا ۲۴ ساعت بسته میشود | متوسط | تصمیمِ طراحی |
|
| ۲۵ | با دانستنِ شمارهی کسی، ورودِ او تا ۲۴ ساعت بسته میشود | متوسط | تصمیمِ طراحی |
|
||||||
@@ -62,15 +62,14 @@
|
|||||||
|
|
||||||
## ۱۵. دسترسیِ سرور
|
## ۱۵. دسترسیِ سرور
|
||||||
|
|
||||||
- **چیست:** ورود با رمز به حسابِ مشترکِ `administrator` با `sudo (ALL : ALL) ALL` و عضوِ
|
- **چیست:** اپ روی سرورِ اختصاصیِ `78.38.35.216` است و فقط دو حساب با sudo دارد (`zareian` و `mathapp-admin`)؛ اپ با کاربرِ
|
||||||
`docker` و `lxd`، روی ماشینی که Gitea و ایمیلِ شرکت هم رویش است. رمز یک بار در گفتگو با
|
بیsudoِ `mathapp` اجرا میشود و ورودِ root بسته است. ورود هنوز با رمز است و پورتِ ۲۲ از اینترنت باز (صاحبِ محصول از
|
||||||
agent گذاشته شده و روی دسکتاپِ صاحبِ پروژه در یک فایلِ متنی نگه داشته میشود.
|
بیرونِ شرکت هم وارد میشود)؛ رمزها روی دسکتاپِ صاحبِ پروژه در یک فایلِ متنیاند. ۲۴۹ و ۲۴۷ (مشترکِ شرکت، حسابِ
|
||||||
- **خطر:** هر نشتِ رمز یعنی دسترسیِ root به کلِ ماشینِ شرکت، نه فقط این پروژه.
|
`administrator` با sudoِ کامل) دیگر اپ را اجرا نمیکنند، ولی تا پاک شدنِ cluster ِ ۲۴۹ دادهی خوانای لحظهی سوئیچ آنجاست.
|
||||||
۲۰۲۶-۱۰-۰۴: `PasswordAuthentication yes` در `sshd_config` و پورتِ ۲۲ از اینترنت باز است؛ پس رمز
|
- **خطر:** نشتِ رمزِ `zareian` یعنی root روی سرورِ اپ. حدسِ رمز را FireHOL، `ufw limit` و fail2banِ پلهای کند میکنند
|
||||||
از هر جای اینترنت حدسزدنی است (fail2ban سنجیده نشد).
|
(روزِ اول ~۵٬۴۰۰ تلاشِ ورود دیده شد).
|
||||||
- **رفع (ادمینِ سرور):** کلیدِ SSH بهجای رمز، تعویضِ رمز، حسابِ جدا برای این پروژه با
|
- **رفع:** کلیدِ SSH برای `zareian` و `PasswordAuthentication no` جز از شبکهی داخلی (`docs/operations.md` «کارهای
|
||||||
دسترسیِ فقط به `~/mathapp-v2` و یک خطِ `NOPASSWD` فقط برای
|
باقیمانده»)؛ جای فایلِ رمز Keychain؛ پاک کردنِ cluster و پوشههای ما روی ۲۴۹ بعد از دورهی برگشت.
|
||||||
`systemctl restart zareian-mathapp`؛ پاک کردنِ فایلِ رمز از دسکتاپ (Keychain جایگزین است).
|
|
||||||
|
|
||||||
## ۱۶. نگهداریِ توکن در فرانت
|
## ۱۶. نگهداریِ توکن در فرانت
|
||||||
|
|
||||||
@@ -156,7 +155,7 @@
|
|||||||
| قلبِ صفر جوابِ سوال را میبندد؛ چکش داخلِ تراکنش است (بیجوابماندن و جزیرهی قفل جداگانه در ردیفِ بعدی بسته شدهاند) | `content/tests_hearts_gate.py` |
|
| قلبِ صفر جوابِ سوال را میبندد؛ چکش داخلِ تراکنش است (بیجوابماندن و جزیرهی قفل جداگانه در ردیفِ بعدی بسته شدهاند) | `content/tests_hearts_gate.py` |
|
||||||
| `select_for_update` مؤثر (قفلِ ردیفِ Postgres؛ روی SQLiteِ محلی `transaction_mode: IMMEDIATE`)؛ مقایسهی زمانثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
|
| `select_for_update` مؤثر (قفلِ ردیفِ Postgres؛ روی SQLiteِ محلی `transaction_mode: IMMEDIATE`)؛ مقایسهی زمانثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
|
||||||
| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` میگیرد (`pg_dump -Fc` روی Postgres) و اگر نشد migrate را اجرا نمیکند | مرحلهی «بکاپِ دیتابیس» در خروجیِ هر دیپلو |
|
| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` میگیرد (`pg_dump -Fc` روی Postgres) و اگر نشد migrate را اجرا نمیکند | مرحلهی «بکاپِ دیتابیس» در خروجیِ هر دیپلو |
|
||||||
| دیتابیس Postgresِ اختصاصی است و هر تغییرش لحظهای روی سرورِ دیگری (۲۴۷) کپی میشود، با نسخهی کاملِ روزانه و بازیابی تا هر لحظه از ۷ روزِ اخیر؛ قطعیِ ۲۴۷ اپ را نمیخواباند و بیش از ۱ گیگ روی دیسکِ ۲۴۹ جمع نمیشود (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: بازیابی روی ۲۴۷ با شمارشهای برابرِ ۲۴۹ و یک ردیفِ آزمایشی که ۲ ثانیه بعد آنجا بود |
|
| دیتابیس روی سرورِ اختصاصی (۲۱۶) با pgBackRest بکاپ میشود: آرشیوِ پیوستهی WAL (حداکثر ~۱ دقیقه عقب)، نسخهی کاملِ هفتگی و diffِ روزانه، **رمزشده روی خودِ ۲۱۶**؛ کپی هر دقیقه به ۲۴۷ با کلیدِ فقطنوشتنی، پس ۲۴۷ (مشترک) هیچ دادهی خوانایی ندارد. آرشیوِ دورهی ۲۴۹ روی ۲۴۷ هم رمز شد (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: `tadrisino-restore-check` بعد از سوئیچ با شمارشهای برابر، بازیابیِ کامل از نسخهی رمزشدهی ۲۴۷، و باز کردنِ نمونههای آرشیوِ ۲۴۹ با چکسامِ برابر |
|
||||||
| `.env`، کلیدِ امضا، لاگها و بکاپها فقط برای صاحبِ سرویس خواندنیاند (مرحلهی «مجوزِ فایلهای داده» در `deploy.sh`) | روی سرور `sudo -u nobody ls ~/mathapp-v2/.deploy-backups` باید خطای دسترسی بدهد |
|
| `.env`، کلیدِ امضا، لاگها و بکاپها فقط برای صاحبِ سرویس خواندنیاند (مرحلهی «مجوزِ فایلهای داده» در `deploy.sh`) | روی سرور `sudo -u nobody ls ~/mathapp-v2/.deploy-backups` باید خطای دسترسی بدهد |
|
||||||
| پانزده مسیرِ خواندنی و نوشتنیِ کمخطر سقفِ نرخ دارند (بانکِ سوال و intro بیورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلنها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ میگیرد | `content/tests_read_throttles.py` |
|
| پانزده مسیرِ خواندنی و نوشتنیِ کمخطر سقفِ نرخ دارند (بانکِ سوال و intro بیورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلنها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ میگیرد | `content/tests_read_throttles.py` |
|
||||||
| ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) |
|
| ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| پایههای سوم تا دوازدهم | **۱۰ پایه** |
|
| پایههای سوم تا دوازدهم | **۱۰ پایه** |
|
||||||
| کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** |
|
| کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** |
|
||||||
| سؤالِ چهارگزینهای | **۱۸٬۵۲۲** |
|
| سؤالِ چهارگزینهای | **۲۲٬۶۰۲** |
|
||||||
| عملیاتِ API، مستند در Swagger | **۶۱** |
|
| عملیاتِ API، مستند در Swagger | **۶۱** |
|
||||||
| تستِ خودکار، همه سبز | **۹۷۳** |
|
| تستِ خودکار، همه سبز | **۹۷۳** |
|
||||||
| اپِ جنگو، هرکدام یک مسئولیت | **۱۴** |
|
| اپِ جنگو، هرکدام یک مسئولیت | **۱۴** |
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
### واحدهای محتوا (این واژهها در کلِ سند تکرار میشوند)
|
### واحدهای محتوا (این واژهها در کلِ سند تکرار میشوند)
|
||||||
|
|
||||||
پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۱۸٬۵۲۲ ← گزینه Choice
|
پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۲۲٬۶۰۲ ← گزینه Choice
|
||||||
|
|
||||||
**«درس» در این پروژه یعنی یک «تیپمسئله»ی کتاب**، نه یک جلسهی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی میشود: آزمونِ هر جزیره از درسهایش سؤال برمیدارد.
|
**«درس» در این پروژه یعنی یک «تیپمسئله»ی کتاب**، نه یک جلسهی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی میشود: آزمونِ هر جزیره از درسهایش سؤال برمیدارد.
|
||||||
|
|
||||||
@@ -732,7 +732,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
|
|||||||
| فایل | محتوا |
|
| فایل | محتوا |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدمها و شکلِ درس |
|
| `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدمها و شکلِ درس |
|
||||||
| `content/seed/questions.json` | ۱۸٬۵۲۲ سؤالِ چهارگزینهای، بعضی با شکل |
|
| `content/seed/questions.json` | ۲۲٬۶۰۲ سؤالِ چهارگزینهای، بعضی با شکل |
|
||||||
|
|
||||||
- `seed_content` در هر انتشار اجرا میشود؛ **هیچوقت چیزی پاک نمیکند** و اجرای دوبارهاش بیخطر است.
|
- `seed_content` در هر انتشار اجرا میشود؛ **هیچوقت چیزی پاک نمیکند** و اجرای دوبارهاش بیخطر است.
|
||||||
- سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل میشود، نه با شناسه — چون شناسهها بینِ دو دیتابیس فرق دارند.
|
- سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل میشود، نه با شناسه — چون شناسهها بینِ دو دیتابیس فرق دارند.
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# روی ۲۴۹، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ میبرد و فشرده میکند.
|
# روی سرورِ اپ، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ میبرد و فشرده میکند.
|
||||||
# برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده میکند و پس از جابهجایی خودش app.log تازه
|
# برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده میکند و پس از جابهجایی خودش app.log تازه
|
||||||
# میسازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابهجاشده میماند، گم نمیشود.
|
# میسازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابهجاشده میماند، گم نمیشود.
|
||||||
# اینجا فقط ۳۰ روز میماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک میشود که ۲۴۷ گرفتنش را تأیید کرده
|
# اینجا فقط ۳۰ روز میماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک میشود که ۲۴۷ گرفتنش را تأیید کرده
|
||||||
# (`.shipped`)؛ نسخهی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs مینویسد.
|
# (`.shipped`)؛ نسخهی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs مینویسد.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
LOGS=/home/administrator/mathapp-v2/logs
|
# پوشهی پروژه از جای خودِ اسکریپت (ops/applogs/ → دو پوشه بالاتر)، تا روی هر سروری با هر کاربری درست باشد.
|
||||||
|
LOGS="$(cd "$(dirname "$0")/../.." && pwd)/logs"
|
||||||
ARCH="$LOGS/archive"
|
ARCH="$LOGS/archive"
|
||||||
mkdir -p "$ARCH"
|
mkdir -p "$ARCH"
|
||||||
chmod 750 "$ARCH"
|
chmod 750 "$ARCH"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ server {
|
|||||||
server_name math.tadrisino2.ir;
|
server_name math.tadrisino2.ir;
|
||||||
ssl_certificate /etc/ssl/certs/mathapp.crt;
|
ssl_certificate /etc/ssl/certs/mathapp.crt;
|
||||||
ssl_certificate_key /etc/ssl/private/mathapp.key;
|
ssl_certificate_key /etc/ssl/private/mathapp.key;
|
||||||
root /home/administrator/mathapp_frontend_new/dist;
|
root /home/mathapp/mathapp_frontend_new/dist;
|
||||||
|
|
||||||
include /etc/nginx/snippets/zareian-arvan-only.conf;
|
include /etc/nginx/snippets/zareian-arvan-only.conf;
|
||||||
include /etc/nginx/snippets/zareian-front-headers.conf;
|
include /etc/nginx/snippets/zareian-front-headers.conf;
|
||||||
|
|||||||
13
ops/server216/10-tadrisino.conf
Normal file
13
ops/server216/10-tadrisino.conf
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# تدریسینو: سختسازیِ SSH. فقط zareian (صاحبِ محصول) و mathapp-admin (حسابِ مشترکِ سینیور و صاحبِ شرکت) وارد میشوند.
|
||||||
|
# ورود با رمز هنوز باز است (تا رفتن به کلید SSH)؛ fail2ban جلوی حدس زدن را میگیرد.
|
||||||
|
PermitRootLogin no
|
||||||
|
AllowUsers zareian mathapp-admin
|
||||||
|
MaxAuthTries 4
|
||||||
|
MaxSessions 10
|
||||||
|
LoginGraceTime 2m
|
||||||
|
X11Forwarding no
|
||||||
|
AllowAgentForwarding no
|
||||||
|
AllowTcpForwarding no
|
||||||
|
PermitTunnel no
|
||||||
|
ClientAliveInterval 300
|
||||||
|
ClientAliveCountMax 2
|
||||||
18
ops/server216/80-tadrisino-hardening.conf
Normal file
18
ops/server216/80-tadrisino-hardening.conf
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# تدریسینو: تنظیماتِ شبکهی استاندارد (CIS). سرور مسیریاب نیست.
|
||||||
|
net.ipv4.tcp_syncookies = 1
|
||||||
|
net.ipv4.conf.all.rp_filter = 1
|
||||||
|
net.ipv4.conf.default.rp_filter = 1
|
||||||
|
net.ipv4.conf.all.accept_redirects = 0
|
||||||
|
net.ipv4.conf.default.accept_redirects = 0
|
||||||
|
net.ipv6.conf.all.accept_redirects = 0
|
||||||
|
net.ipv6.conf.default.accept_redirects = 0
|
||||||
|
net.ipv4.conf.all.send_redirects = 0
|
||||||
|
net.ipv4.conf.default.send_redirects = 0
|
||||||
|
net.ipv4.conf.all.accept_source_route = 0
|
||||||
|
net.ipv6.conf.all.accept_source_route = 0
|
||||||
|
net.ipv4.conf.all.log_martians = 1
|
||||||
|
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||||
|
kernel.kptr_restrict = 2
|
||||||
|
kernel.dmesg_restrict = 1
|
||||||
|
fs.protected_hardlinks = 1
|
||||||
|
fs.protected_symlinks = 1
|
||||||
4
ops/server216/backup.conf
Normal file
4
ops/server216/backup.conf
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# تدریسینو: آرشیوِ پیوستهی WAL با pgBackRest (رمزنگاریشده). archive_timeout یعنی حداکثر ۶۰ ثانیه تغییرِ آرشیونشده.
|
||||||
|
archive_mode = on
|
||||||
|
archive_command = 'pgbackrest --stanza=zareian archive-push %p'
|
||||||
|
archive_timeout = 60
|
||||||
20
ops/server216/gunicorn-mathapp.service
Normal file
20
ops/server216/gunicorn-mathapp.service
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Gunicorn MathApp (tadrisino API)
|
||||||
|
After=network.target postgresql@16-zareian.service zareian-redis.service
|
||||||
|
Wants=postgresql@16-zareian.service zareian-redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=mathapp
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/home/mathapp/mathapp-v2
|
||||||
|
EnvironmentFile=/home/mathapp/mathapp-v2/.env
|
||||||
|
# /run/gunicorn بعد از هر ریبوت ساخته میشود (روی ۲۴۹ دستی ساخته شده بود و بعد از ریبوت نبود).
|
||||||
|
RuntimeDirectory=gunicorn
|
||||||
|
RuntimeDirectoryMode=0750
|
||||||
|
ExecStart=/home/mathapp/mathapp-v2/venv/bin/gunicorn --bind unix:/run/gunicorn/mathapp.sock config.wsgi:application
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
Alias=zareian-mathapp.service
|
||||||
17
ops/server216/pgbackrest.conf
Normal file
17
ops/server216/pgbackrest.conf
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# روی ۲۱۶: /etc/pgbackrest.conf (root:postgres 640). کلیدِ واقعی فقط روی سرور و در /etc/tadrisino-backup/RECOVERY-KEYS است.
|
||||||
|
# تدریسینو: بکاپِ رمزنگاریشده. مخزنِ محلی /var/lib/pgbackrest؛ نسخهی بیرونی با tadrisino-offsite-sync به ۲۴۷ (فقط فایلِ رمزشده).
|
||||||
|
[global]
|
||||||
|
repo1-path=/var/lib/pgbackrest
|
||||||
|
repo1-cipher-type=aes-256-cbc
|
||||||
|
repo1-cipher-pass=<RECOVERY-KEYS، خطِ کلیدِ ۱>
|
||||||
|
repo1-retention-full=2
|
||||||
|
compress-type=zst
|
||||||
|
process-max=2
|
||||||
|
start-fast=y
|
||||||
|
log-level-console=info
|
||||||
|
log-level-file=detail
|
||||||
|
|
||||||
|
[zareian]
|
||||||
|
pg1-path=/var/lib/postgresql/16/zareian
|
||||||
|
pg1-port=5440
|
||||||
|
pg1-socket-path=/var/run/postgresql
|
||||||
44
ops/server216/tadrisino-blocklist
Executable file
44
ops/server216/tadrisino-blocklist
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# تدریسینو: IPهای شناختهشدهی مخرب را پیش از اولین تلاش روی همهی پورتها میبندد.
|
||||||
|
# فهرست: FireHOL level1 (Spamhaus DROP/EDROP، DShield، Feodo، bogons)، روزی یک بار (tadrisino-blocklist.timer).
|
||||||
|
# شبکههای داخلی، رنجِ IPِ شرکت و رنجهای ArvanCloud (از snippetِ nginx) هرگز بسته نمیشوند.
|
||||||
|
# اگر دانلود نشد، آخرین فهرستِ سالم (/var/lib/tadrisino-blocklist/list) دوباره بار میشود.
|
||||||
|
set -euo pipefail
|
||||||
|
URL=https://iplists.firehol.org/files/firehol_level1.netset
|
||||||
|
DIR=/var/lib/tadrisino-blocklist; SET=tadrisino-block
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
|
||||||
|
if curl -fsS -m 90 "$URL" -o "$tmp" && [ "$(grep -c '^[0-9]' "$tmp")" -gt 1000 ]; then
|
||||||
|
python3 - "$tmp" "$DIR/list" <<'PY'
|
||||||
|
import ipaddress, re, sys
|
||||||
|
src, dst = sys.argv[1], sys.argv[2]
|
||||||
|
keep = ['127.0.0.0/8','10.0.0.0/8','172.16.0.0/12','192.168.0.0/16','100.64.0.0/10','169.254.0.0/16','78.38.35.0/24']
|
||||||
|
try:
|
||||||
|
keep += re.findall(r'^allow\s+([0-9./]+);', open('/etc/nginx/snippets/zareian-arvan-only.conf').read(), re.M)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
keep = [ipaddress.ip_network(k) for k in keep if ':' not in k]
|
||||||
|
out, dropped = [], 0
|
||||||
|
for line in open(src):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line[0] == '#':
|
||||||
|
continue
|
||||||
|
n = ipaddress.ip_network(line, strict=False)
|
||||||
|
if any(n.overlaps(k) for k in keep):
|
||||||
|
dropped += 1
|
||||||
|
continue
|
||||||
|
out.append(str(n))
|
||||||
|
open(dst, 'w').write('\n'.join(out) + '\n')
|
||||||
|
print(f'{len(out)} networks, {dropped} skipped (internal/company/Arvan)')
|
||||||
|
PY
|
||||||
|
else
|
||||||
|
echo "download failed; reloading last good list" >&2
|
||||||
|
fi
|
||||||
|
[ -s "$DIR/list" ] || { echo "no list available" >&2; exit 1; }
|
||||||
|
ipset create "$SET" hash:net maxelem 262144 -exist
|
||||||
|
ipset create "$SET-new" hash:net maxelem 262144 -exist
|
||||||
|
ipset flush "$SET-new"
|
||||||
|
sed "s/^/add $SET-new /" "$DIR/list" | ipset restore -exist
|
||||||
|
ipset swap "$SET-new" "$SET"; ipset destroy "$SET-new"
|
||||||
|
iptables -C INPUT -m set --match-set "$SET" src -j DROP 2>/dev/null || iptables -I INPUT 1 -m set --match-set "$SET" src -j DROP
|
||||||
|
echo "loaded $(ipset list "$SET" -t | sed -n 's/^Number of entries: //p') entries into $SET"
|
||||||
11
ops/server216/tadrisino-blocklist.service
Normal file
11
ops/server216/tadrisino-blocklist.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: block known-malicious IPs (FireHOL level1)
|
||||||
|
After=network-online.target ufw.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/tadrisino-blocklist
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
10
ops/server216/tadrisino-blocklist.timer
Normal file
10
ops/server216/tadrisino-blocklist.timer
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: refresh malicious-IP blocklist daily
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 05:20:00
|
||||||
|
RandomizedDelaySec=30m
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
19
ops/server216/tadrisino-export-payment-events
Executable file
19
ops/server216/tadrisino-export-payment-events
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# تدریسینو: دفترِ خرید (payment_paymentevent)، سفارشها و تراکنشها به CSV، هر ۶ ساعت. هر فایل پیش از نوشتهشدن روی دیسک
|
||||||
|
# با age برای کلیدِ /etc/tadrisino-backup/age-recipient.txt رمز میشود؛ متنِ خوانا هیچجا ذخیره نمیشود.
|
||||||
|
# بازکردن: age -d -i age-identity.txt payment-events-….csv.age (کلید در RECOVERY-KEYS). ۱۴ فایلِ آخرِ هر نوع میماند.
|
||||||
|
set -euo pipefail
|
||||||
|
OUT=/var/lib/tadrisino-backup/payment-log
|
||||||
|
REC=/etc/tadrisino-backup/age-recipient.txt
|
||||||
|
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
q() {
|
||||||
|
psql -p 5440 -d zareian_mathapp -v ON_ERROR_STOP=1 -Atqc "\\copy ($1) to stdout csv header" | age -R "$REC" -o "$OUT/$2-$stamp.csv.age"
|
||||||
|
}
|
||||||
|
q "select id, created_at, order_id, user_id, provider, event, detail from payment_paymentevent order by id" payment-events
|
||||||
|
q "select id, created_at, user_id, plan_id, plan_title, amount, status, provider, gateway_authority, subscription_id from payment_order order by id" orders
|
||||||
|
q "select id, created_at, order_id, gateway, gateway_ref_id, status, paid_at from payment_transaction order by id" transactions
|
||||||
|
chmod 640 "$OUT"/*-"$stamp".csv.age
|
||||||
|
for kind in payment-events orders transactions; do
|
||||||
|
ls -1t "$OUT/$kind"-*.csv.age | tail -n +15 | xargs -r rm -f --
|
||||||
|
done
|
||||||
|
echo "wrote $OUT/*-$stamp.csv.age"
|
||||||
8
ops/server216/tadrisino-export-payment-events.service
Normal file
8
ops/server216/tadrisino-export-payment-events.service
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: encrypted payment CSVs every 6 hours
|
||||||
|
After=postgresql@16-zareian.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=postgres
|
||||||
|
ExecStart=/usr/local/sbin/tadrisino-export-payment-events
|
||||||
9
ops/server216/tadrisino-export-payment-events.timer
Normal file
9
ops/server216/tadrisino-export-payment-events.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: encrypted payment CSVs every 6 hours
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 01,07,13,19:15:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
11
ops/server216/tadrisino-offsite-sync
Executable file
11
ops/server216/tadrisino-offsite-sync
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# تدریسینو: نسخهی بیرونیِ بکاپ. فقط فایلهای رمزشده (مخزنِ pgBackRest با AES-256 و CSVهای age) به ۲۴۷ میروند.
|
||||||
|
# کلیدِ postgres روی ۲۴۷ با rrsync -wo محدود است: فقط نوشتن در ~/zareian-pgbackrest-216، نه خواندن، نه شِل.
|
||||||
|
# پس از موفقیتِ هر دو، زمانش در offsite-synced-at ثبت میشود و /api/health از روی آن عقبماندگی را میسنجد.
|
||||||
|
set -euo pipefail
|
||||||
|
R=administrator@192.168.200.167
|
||||||
|
SSH="ssh -i /var/lib/postgresql/.ssh/id_offsite -o BatchMode=yes -o ConnectTimeout=20"
|
||||||
|
rsync -a --delete -e "$SSH" /var/lib/pgbackrest/ "$R:repo/"
|
||||||
|
rsync -a --delete -e "$SSH" /var/lib/tadrisino-backup/payment-log/ "$R:payment-log/"
|
||||||
|
date -u +%s > /var/lib/tadrisino-backup/offsite-synced-at.tmp
|
||||||
|
mv /var/lib/tadrisino-backup/offsite-synced-at.tmp /var/lib/tadrisino-backup/offsite-synced-at
|
||||||
8
ops/server216/tadrisino-offsite-sync.service
Normal file
8
ops/server216/tadrisino-offsite-sync.service
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: copy encrypted backups to 247
|
||||||
|
After=postgresql@16-zareian.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=postgres
|
||||||
|
ExecStart=/usr/local/sbin/tadrisino-offsite-sync
|
||||||
9
ops/server216/tadrisino-offsite-sync.timer
Normal file
9
ops/server216/tadrisino-offsite-sync.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: copy encrypted backups to 247
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=minutely
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
8
ops/server216/tadrisino-pgbackrest-diff.service
Normal file
8
ops/server216/tadrisino-pgbackrest-diff.service
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: daily differential encrypted backup
|
||||||
|
After=postgresql@16-zareian.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=postgres
|
||||||
|
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=diff backup
|
||||||
9
ops/server216/tadrisino-pgbackrest-diff.timer
Normal file
9
ops/server216/tadrisino-pgbackrest-diff.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: daily differential encrypted backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:40:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
8
ops/server216/tadrisino-pgbackrest-full.service
Normal file
8
ops/server216/tadrisino-pgbackrest-full.service
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: weekly full encrypted backup
|
||||||
|
After=postgresql@16-zareian.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=postgres
|
||||||
|
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=full backup
|
||||||
9
ops/server216/tadrisino-pgbackrest-full.timer
Normal file
9
ops/server216/tadrisino-pgbackrest-full.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tadrisino: weekly full encrypted backup
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=Sun *-*-* 03:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
26
ops/server216/tadrisino-restore-check
Executable file
26
ops/server216/tadrisino-restore-check
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# تدریسینو: آزمونِ بازیابی از بکاپِ رمزشده (ماهی یک بار، دستی: sudo -u postgres tadrisino-restore-check).
|
||||||
|
# آخرین بکاپ + همهی WALِ آرشیوشده را در پوشهی موقت بازیابی میکند، Postgresِ موقت را روی سوکتِ همان پوشه (بیپورتِ شبکه،
|
||||||
|
# archive_mode=off تا چیزی به مخزن ننویسد) بالا میآورد، شمارشها را کنارِ دیتابیسِ اصلی چاپ میکند و همه را پاک میکند.
|
||||||
|
set -euo pipefail
|
||||||
|
BIN=/usr/lib/postgresql/16/bin
|
||||||
|
work=$(mktemp -d /var/lib/postgresql/restore-check.XXXXXX)
|
||||||
|
trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT
|
||||||
|
pgbackrest --stanza=zareian --pg1-path="$work/data" --type=default --archive-mode=off restore --log-level-console=warn
|
||||||
|
cat > "$work/data/postgresql.conf" <<CONF
|
||||||
|
listen_addresses = ''
|
||||||
|
port = 5499
|
||||||
|
unix_socket_directories = '$work'
|
||||||
|
archive_mode = off
|
||||||
|
shared_buffers = 128MB
|
||||||
|
CONF
|
||||||
|
echo 'local all all peer' > "$work/data/pg_hba.conf"
|
||||||
|
"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null
|
||||||
|
for _ in $(seq 120); do
|
||||||
|
[ "$(psql -h "$work" -p 5499 -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break; sleep 1
|
||||||
|
done
|
||||||
|
Q="select 'users', count(*) from accounts_user union all select 'questions', count(*) from content_question
|
||||||
|
union all select 'orders', count(*) from payment_order union all select 'payment_events', count(*) from payment_paymentevent
|
||||||
|
union all select 'login_events', count(*) from accounts_loginevent"
|
||||||
|
echo "restored | live"
|
||||||
|
paste -d'|' <(psql -h "$work" -p 5499 -d zareian_mathapp -Atc "$Q") <(psql -p 5440 -d zareian_mathapp -Atc "$Q" | cut -d'|' -f2)
|
||||||
25
ops/server216/tadrisino.local
Normal file
25
ops/server216/tadrisino.local
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# تدریسینو: SSH در برابرِ رباتهای حدسِ رمز، پلهای تا آدمِ واقعی (اشتباهِ تایپی، کیبوردِ فارسی) گیر نکند.
|
||||||
|
# ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه بسته؛ هر بارِ تکرار دو برابر (۲۰، ۴۰، ...، حداکثر یک هفته).
|
||||||
|
# شبکهی داخلیِ شرکت (ادمینها، ۲۴۷، ۲۴۹) هرگز بسته نمیشود.
|
||||||
|
[DEFAULT]
|
||||||
|
ignoreip = 127.0.0.1/8 ::1 192.168.200.0/24
|
||||||
|
bantime.increment = true
|
||||||
|
bantime.factor = 2
|
||||||
|
bantime.maxtime = 1w
|
||||||
|
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
|
backend = systemd
|
||||||
|
mode = aggressive
|
||||||
|
maxretry = 6
|
||||||
|
findtime = 10m
|
||||||
|
bantime = 10m
|
||||||
|
|
||||||
|
# فقط رباتِ سمج: پنج بار بستهشدن در یک روز → یک هفته روی همهی پورتها.
|
||||||
|
[recidive]
|
||||||
|
enabled = true
|
||||||
|
logpath = /var/log/fail2ban.log
|
||||||
|
banaction = iptables-allports
|
||||||
|
findtime = 1d
|
||||||
|
maxretry = 5
|
||||||
|
bantime = 1w
|
||||||
13
ops/server216/zareian-redis.service
Normal file
13
ops/server216/zareian-redis.service
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Redis for zareian-mathapp only
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=redis
|
||||||
|
Group=redis
|
||||||
|
ExecStart=/usr/bin/redis-server /etc/zareian-redis.conf
|
||||||
|
Restart=on-failure
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
5
ops/server216/zareian.conf
Normal file
5
ops/server216/zareian.conf
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# تدریسینو، سرور اختصاصی: فقط localhost. max_connections با workers × DB_POOL_MAX_SIZE + 6 در /api/health سنجیده میشود.
|
||||||
|
listen_addresses = '127.0.0.1'
|
||||||
|
max_connections = 60
|
||||||
|
shared_buffers = 1GB
|
||||||
|
effective_cache_size = 4GB
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
اصلاحِ سوالهای شکلدارِ از قبل منتشرشده (seed هیچوقت حذف یا بازنویسی نمیکند، پس اصلاح یک ابزارِ جدا میخواهد).
|
اصلاحِ محتوای از قبل منتشرشده. seed هیچوقت سوال را حذف یا بازنویسی نمیکند و مثال، گامها و شکلهای درس را
|
||||||
|
فقط وقتی خالیاند پر میکند؛ پس اصلاحِ چیزی که روی سرور هست این ابزارِ جدا را میخواهد.
|
||||||
|
|
||||||
python tools/figures/apply_fixups.py fixups.json # فقط فایلهای seed محلی
|
python tools/figures/apply_fixups.py fixups.json # فقط فایلهای seed محلی
|
||||||
venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump)
|
venv/bin/python tools/figures/apply_fixups.py --db fixups.json # دیتابیسِ همین ماشین (روی سرور، پس از pg_dump)
|
||||||
|
|
||||||
fixups.json: {"delete": [[path, prompt], ...], "blank_title": [[path, prompt], ...]}
|
fixups.json (هر کلید اختیاری است):
|
||||||
delete سوال را کامل پاک میکند (پاسخهای ثبتشدهاش هم میرود؛ فقط برای سوالِ تازهمنتشرشده)
|
"delete": [[path, prompt], ...] سوال را کامل پاک میکند. پاسخهای ثبتشدهاش هم پاک میشود (CASCADE)،
|
||||||
blank_title عنوانِ زیرِ شکل را خالی میکند (عنوان گاهی جوابِ سوال را لو میداد)
|
پس فقط برای سوالِ تکراری یا غلط، و پیش از کاربرِ واقعی.
|
||||||
سوال با (path, prompt) شناخته میشود؛ هر کدام پیدا نشود فهرست میشود، بیصدا رد نمیشود.
|
"blank_title": [[path, prompt], ...] عنوانِ زیرِ شکل را خالی میکند (عنوان گاهی جواب را لو میداد).
|
||||||
|
"lesson": [[path, {"intro"|"example"|"steps"|"figures": مقدار}], ...]
|
||||||
|
همان فیلدهای دادهشدهی درس را بازنویسی میکند.
|
||||||
|
"question_set": [[path, prompt, {"prompt"|"choices"|"correct"|"figure": مقدار}], ...]
|
||||||
|
سوال را در جای خودش اصلاح میکند. گزینهها به ترتیبِ id عوض میشوند و
|
||||||
|
ردیفشان میماند، چون پاسخهای ثبتشده به ردیفِ گزینه وصلاند؛ تعدادِ
|
||||||
|
گزینهها باید با سوالِ فعلی یکی باشد.
|
||||||
|
path برای سوال و درس [پایه، فصل، جزیره، درس] است. هر موردی پیدا نشود یا نخواند فهرست میشود، بیصدا رد نمیشود.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -16,64 +24,148 @@ import sys
|
|||||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
sys.path.insert(0, ROOT)
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
LESSON_FIELDS = {'intro': ('introText', 'intro_text'), 'example': ('example', 'example'),
|
||||||
|
'steps': ('steps', 'steps'), 'figures': ('figures', 'figures')}
|
||||||
|
QUESTION_FIELDS = {'prompt', 'choices', 'correct', 'figure'}
|
||||||
|
|
||||||
|
|
||||||
def key(path, prompt):
|
def key(path, prompt):
|
||||||
return (tuple(path), prompt.strip())
|
return (tuple(path), prompt.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _check_fields(fields, allowed, what):
|
||||||
|
bad = set(fields) - set(allowed)
|
||||||
|
if bad:
|
||||||
|
raise SystemExit(f'{what}: فیلدِ ناشناخته {sorted(bad)}')
|
||||||
|
|
||||||
|
|
||||||
def apply_seed(fx):
|
def apply_seed(fx):
|
||||||
p = os.path.join(ROOT, 'content', 'seed', 'questions.json')
|
p = os.path.join(ROOT, 'content', 'seed', 'questions.json')
|
||||||
data = json.load(open(p))
|
data = json.load(open(p))
|
||||||
dele = {key(*x) for x in fx.get('delete', [])}
|
dele = {key(*x) for x in fx.get('delete', [])}
|
||||||
blank = {key(*x) for x in fx.get('blank_title', [])}
|
blank = {key(*x) for x in fx.get('blank_title', [])}
|
||||||
seen_d, seen_b, out = set(), set(), []
|
qset = {key(path, prompt): f for path, prompt, f in fx.get('question_set', [])}
|
||||||
|
seen, out, problems = set(), [], []
|
||||||
for q in data['questions']:
|
for q in data['questions']:
|
||||||
k = key(q['path'], q['prompt'])
|
k = key(q['path'], q['prompt'])
|
||||||
if k in dele:
|
if k in dele:
|
||||||
seen_d.add(k)
|
seen.add(('delete', k))
|
||||||
continue
|
continue
|
||||||
if k in blank and q.get('figure'):
|
if k in blank and q.get('figure'):
|
||||||
q['figure']['title'] = ''
|
q['figure']['title'] = ''
|
||||||
seen_b.add(k)
|
seen.add(('blank_title', k))
|
||||||
|
if k in qset:
|
||||||
|
f = qset[k]
|
||||||
|
_check_fields(f, QUESTION_FIELDS, 'question_set')
|
||||||
|
if 'choices' in f and len(f['choices']) != len(q['choices']):
|
||||||
|
problems.append(('question_set: تعدادِ گزینه فرق دارد', k))
|
||||||
|
else:
|
||||||
|
q.update(f)
|
||||||
|
seen.add(('question_set', k))
|
||||||
out.append(q)
|
out.append(q)
|
||||||
data['questions'] = out
|
data['questions'] = out
|
||||||
json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1)
|
json.dump(data, open(p, 'w'), ensure_ascii=False, indent=1)
|
||||||
return dele - seen_d, blank - seen_b
|
|
||||||
|
cp = os.path.join(ROOT, 'content', 'seed', 'curriculum.json')
|
||||||
|
cur = json.load(open(cp))
|
||||||
|
lessons = {}
|
||||||
|
for g in cur['grades']:
|
||||||
|
for u in g['units']:
|
||||||
|
for n in u['nodes']:
|
||||||
|
for l in n['lessons']:
|
||||||
|
lessons[(g['title'], u['title'], n['title'], l['title'])] = l
|
||||||
|
for path, f in fx.get('lesson', []):
|
||||||
|
_check_fields(f, LESSON_FIELDS, 'lesson')
|
||||||
|
l = lessons.get(tuple(path))
|
||||||
|
if l is None:
|
||||||
|
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
|
||||||
|
continue
|
||||||
|
for name, value in f.items():
|
||||||
|
l[LESSON_FIELDS[name][0]] = value
|
||||||
|
seen.add(('lesson', tuple(path)))
|
||||||
|
json.dump(cur, open(cp, 'w'), ensure_ascii=False, indent=1)
|
||||||
|
return _missing(fx, seen) + problems
|
||||||
|
|
||||||
|
|
||||||
|
def _missing(fx, seen):
|
||||||
|
miss = []
|
||||||
|
for op in ('delete', 'blank_title'):
|
||||||
|
miss += [(f'{op}: پیدا نشد', key(*x)) for x in fx.get(op, []) if (op, key(*x)) not in seen]
|
||||||
|
miss += [('question_set: پیدا نشد', key(p, pr)) for p, pr, _ in fx.get('question_set', [])
|
||||||
|
if ('question_set', key(p, pr)) not in seen]
|
||||||
|
return miss
|
||||||
|
|
||||||
|
|
||||||
def apply_db(fx):
|
def apply_db(fx):
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
import django
|
import django
|
||||||
django.setup()
|
django.setup()
|
||||||
from content.models import Question
|
from django.db import transaction
|
||||||
|
from content.models import Lesson, Question
|
||||||
lessons = {}
|
lessons = {}
|
||||||
from content.models import Lesson
|
|
||||||
for l in Lesson.objects.select_related('node__unit__grade'):
|
for l in Lesson.objects.select_related('node__unit__grade'):
|
||||||
lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l.pk
|
lessons[(l.node.unit.grade.title, l.node.unit.title, l.node.title, l.title)] = l
|
||||||
miss_d, miss_b = set(), set()
|
problems = []
|
||||||
|
|
||||||
|
def find(path, prompt):
|
||||||
|
l = lessons.get(tuple(path))
|
||||||
|
return list(Question.objects.filter(lesson=l, prompt=prompt.strip())) if l else []
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
for path, prompt in fx.get('delete', []):
|
for path, prompt in fx.get('delete', []):
|
||||||
lid = lessons.get(tuple(path))
|
qs = find(path, prompt)
|
||||||
qs = Question.objects.filter(lesson_id=lid, prompt=prompt.strip()) if lid else Question.objects.none()
|
|
||||||
if not qs.exists():
|
|
||||||
miss_d.add(key(path, prompt))
|
|
||||||
else:
|
|
||||||
qs.delete()
|
|
||||||
for path, prompt in fx.get('blank_title', []):
|
|
||||||
lid = lessons.get(tuple(path))
|
|
||||||
qs = list(Question.objects.filter(lesson_id=lid, prompt=prompt.strip())) if lid else []
|
|
||||||
if not qs:
|
if not qs:
|
||||||
miss_b.add(key(path, prompt))
|
problems.append(('delete: پیدا نشد', key(path, prompt)))
|
||||||
|
for q in qs:
|
||||||
|
q.delete()
|
||||||
|
for path, prompt in fx.get('blank_title', []):
|
||||||
|
qs = find(path, prompt)
|
||||||
|
if not qs:
|
||||||
|
problems.append(('blank_title: پیدا نشد', key(path, prompt)))
|
||||||
for q in qs:
|
for q in qs:
|
||||||
if q.figure:
|
if q.figure:
|
||||||
q.figure = {**q.figure, 'title': ''}
|
q.figure = {**q.figure, 'title': ''}
|
||||||
q.save(update_fields=['figure'])
|
q.save(update_fields=['figure'])
|
||||||
return miss_d, miss_b
|
for path, prompt, f in fx.get('question_set', []):
|
||||||
|
_check_fields(f, QUESTION_FIELDS, 'question_set')
|
||||||
|
qs = find(path, prompt)
|
||||||
|
if not qs:
|
||||||
|
problems.append(('question_set: پیدا نشد', key(path, prompt)))
|
||||||
|
for q in qs:
|
||||||
|
choices = list(q.choices.order_by('id'))
|
||||||
|
if 'choices' in f and len(f['choices']) != len(choices):
|
||||||
|
problems.append(('question_set: تعدادِ گزینه فرق دارد', key(path, prompt)))
|
||||||
|
continue
|
||||||
|
correct = f.get('correct')
|
||||||
|
for i, c in enumerate(choices):
|
||||||
|
if 'choices' in f:
|
||||||
|
c.value = f['choices'][i]
|
||||||
|
if correct is not None:
|
||||||
|
c.is_correct = (i == correct)
|
||||||
|
c.save(update_fields=['value', 'is_correct'])
|
||||||
|
if 'prompt' in f:
|
||||||
|
q.prompt = f['prompt']
|
||||||
|
if 'figure' in f:
|
||||||
|
q.figure = f['figure']
|
||||||
|
q.save(update_fields=['prompt', 'figure'])
|
||||||
|
for path, f in fx.get('lesson', []):
|
||||||
|
_check_fields(f, LESSON_FIELDS, 'lesson')
|
||||||
|
l = lessons.get(tuple(path))
|
||||||
|
if l is None:
|
||||||
|
problems.append(('lesson: پیدا نشد', (tuple(path), '')))
|
||||||
|
continue
|
||||||
|
for name, value in f.items():
|
||||||
|
setattr(l, LESSON_FIELDS[name][1], value)
|
||||||
|
l.save(update_fields=[LESSON_FIELDS[n][1] for n in f])
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
args = [a for a in sys.argv[1:] if a != '--db']
|
args = [a for a in sys.argv[1:] if a != '--db']
|
||||||
fx = json.load(open(args[0]))
|
fx = json.load(open(args[0]))
|
||||||
miss_d, miss_b = (apply_db if '--db' in sys.argv else apply_seed)(fx)
|
problems = (apply_db if '--db' in sys.argv else apply_seed)(fx)
|
||||||
print(f'deleted/blanked ok; not found: delete={len(miss_d)} blank_title={len(miss_b)}')
|
counts = {k: len(v) for k, v in fx.items()}
|
||||||
for k in list(miss_d)[:5] + list(miss_b)[:5]:
|
print(f'applied {counts}; problems: {len(problems)}')
|
||||||
print(' missing:', k[1][:60])
|
for what, k in problems[:20]:
|
||||||
|
print(f' {what}: {" › ".join(k[0])} | {k[1][:60]}')
|
||||||
|
sys.exit(1 if problems else 0)
|
||||||
|
|||||||
1
tools/figures/server_fixups.json
Normal file
1
tools/figures/server_fixups.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user