79 lines
14 KiB
Markdown
79 lines
14 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Overview
|
||
|
||
DjangoAdmin Tornado+EleVue is a modular enterprise admin framework. This repo is the **backend only** — the Tornado REST API server. The Vue2.x + ElementUI frontend ("EleVue") is a separate project not in this repo (`static/` and `templates/` are mostly empty). Code comments are written in Chinese.
|
||
|
||
Key stack: Tornado 6.x, SQLAlchemy 2.0, Redis, JWT auth, **Pydantic v2** (settings + validation schemas), **bcrypt** (double-salt password hashing). Python ≥3.10, managed via Poetry. The code lives under `src/` (`src` is added to `sys.path` by the root `app.py` entry and by pytest's `pythonpath`).
|
||
|
||
**Multi-database support**: default driver is MySQL (`pymysql`, installed by default). PostgreSQL / SQL Server / SQLite / Oracle are supported via optional drivers — `poetry install -E postgresql` (psycopg2-binary) / `-E mssql` (pymssql) / `-E oracle` (oracledb); SQLite needs no extra install. Select via `DB_DRIVER` in `.env` (aliases: `postgres=postgresql`, `sqlserver=mssql`, `sqlite3=sqlite`); see `.env` for per-DB connection examples. When `DB_DRIVER=sqlite`, `DB_DATABASE` is the DB **file path** (host/port/user/password ignored); when `DB_DRIVER=oracle`, `DB_DATABASE` is the **SERVICE_NAME**. Engine/connection-pool options are dialect-aware in `src/core/database.py` (`_connect_args`, SQLite uses `NullPool` + `check_same_thread=False`). `document/djangoadmin.tornado.elevue.sql` is **MySQL-only** — for non-MySQL DBs create the schema via `alembic upgrade head` (`Base.metadata.create_all`) and seed via `scripts/seed.py`. The three join queries that used raw SQL+backticks (permission/menu/user services) were rewritten with SQLAlchemy ORM to stay dialect-agnostic.
|
||
|
||
> ⚠️ Two files named `app.py` exist intentionally: root `app.py` is the thin **entry script** (runs as `__main__`), `src/app.py` is the **app factory** (`create_app()`). Never `import app` from a context where the project root is `sys.path[0]` (e.g. pytest) — use `from src.app import create_app` there.
|
||
|
||
## Common Commands
|
||
|
||
```bash
|
||
poetry install # 安装依赖(含 dev)
|
||
poetry run python app.py --port=8041 # 启动服务(默认端口 8041)
|
||
poetry run pytest # 全部测试(集成测试无服务时自动跳过)
|
||
poetry run pytest tests/unit # 仅单元测试
|
||
poetry run flake8 src tests # 代码规范检查
|
||
poetry run mypy src # 类型检查
|
||
poetry run ruff check --fix src tests
|
||
poetry run alembic upgrade head # 数据库迁移
|
||
python scripts/seed_dict.py # 初始化业务字典(dict/dict_item,幂等)
|
||
python scripts/migrate_db.py --help # 跨库数据迁移(MySQL → PG/MSSQL/SQLite/Oracle,参数显式给出,不依赖 .env)
|
||
make install / dev / test / lint # Makefile 封装
|
||
```
|
||
|
||
Gotchas:
|
||
- Configuration comes from `.env` via Pydantic Settings (`src/config/settings.py`). Keys: `TORNADO_*`, `DB_*`, `REDIS_*`, `MAIL_*`, `JWT_*`, `CAPTCHA_*`, `UPLOAD_*`, `LOGIN_*`, `LOG_*`. `TORNADO_DEMO=True` blocks all write endpoints. **`JWT_SECRET` 必须配置**(≥32 字节强随机,缺失/过弱启动即 fail-close 中止;生成:`python -c "import secrets; print(secrets.token_urlsafe(48))"`)。上传白名单/上限:`UPLOAD_ALLOWED_EXTS`(逗号分隔带点号)、`UPLOAD_MAX_SIZE_MB`。登录防爆破:`LOGIN_FAIL_MAX` / `LOGIN_LOCK_SECONDS`。
|
||
- 业务枚举字典(性别/友链/通知来源等)统一由 `scripts/seed_dict.py` 写入 `dict`/`dict_item` 表,业务代码经 `common/utils/dict_util.py` 读取(Redis 缓存,字典维护时自动失效)。`config/constants.py` 不再承载业务枚举,仅保留 `SERIALIZE_EXCLUDE_FIELDS`。
|
||
- The database schema is created from `document/djangoadmin.tornado.elevue.sql` (import into MySQL). Alembic is wired up (`alembic/env.py` aggregates `src/modules.*.models`) and can autogenerate.
|
||
- `requirements.txt` is a plain UTF-8 fallback; Poetry (`pyproject.toml` + `poetry.lock`) is the source of truth.
|
||
|
||
## Architecture
|
||
|
||
Layered design. A request flows: Tornado handler (`src/modules/<name>/handlers.py`) → business logic (`service.py`) → repository (`repository.py`) → SQLAlchemy model (`models.py`). Input is validated with Pydantic schemas (`schemas.py`).
|
||
|
||
Startup: root `app.py` → `src/bootstrap.py:main()` (parses `--port`, installs SIGINT/SIGTERM graceful shutdown) → `src/app.py:create_app()` builds the `tornado.web.Application` with routes from `src/api/v1/router.py:build_routes()`.
|
||
|
||
### src/ packages
|
||
|
||
- **`src/config/`** — `settings.py` (Pydantic Settings, computes `DATABASE_URL`/`UPLOAD_DIR`/`SECRET_KEY`), `constants.py` (仅 `SERIALIZE_EXCLUDE_FIELDS`;业务枚举已迁至 dict 表,经 `common/utils/dict_util.py` 读取), `messages.py` (含 `PAGE_LIMIT`/`MAX_PAGE_LIMIT`), `logging.py` (JSON structured logs).
|
||
- **`src/core/`** — infrastructure: `response.py` (R.ok/R.failed/R.response → `{code, data, msg, ok}`; `Codes` class: SUCCESS=0/FAILED=1/UNAUTHORIZED=401/FORBIDDEN=403/…; 写完响应并把 body 存到 `self._api_result` 供日志装饰器解析,函数返回 `None`), `exceptions.py` (BaseAppError hierarchy + `write_error` 记录异常日志), `jwt.py` (JWT 签发/解析/黑名单/刷新), `database.py` (writer + optional reader engine, thread-local `scoped_session` `db`, `Base`), `redis.py`, `logger.py`, `password.py` (double-salt bcrypt), `context.py` (`current_user_id`/`current_username`/`current_realname`/`current_trace_id` contextvars), and base classes: `BaseHandler`(`on_finish` 统一关闭共享 DB 会话), `BaseRepository` (generic CRUD + soft delete + audit; see conventions), `BaseService` (template-method CRUD — see below).
|
||
- **`src/modules/`** — the ~20 business modules (auth, user, role, menu, dept, dict, config, level, position, city, notice, link, category, article, param, file_template, job, upload, operation_log, login_log). Each is a six-file package: `models.py` (SQLAlchemy, `core.base_model.BaseModel` gives `id`/`create_user`(String50)/`create_time`/`update_user`/`update_time`/`is_delete` + `to_dict()` which **excludes `password`/`salt`/`is_delete`** and is timezone-defensive), `schemas.py` (Pydantic `XxxSchema`/`XxxStatusSchema`, `extra="ignore"`), `repository.py` (extends `BaseRepository`, sets `model`), `service.py` (a `XxxService(BaseService)` subclass + `xxx_service` instance; only auth/upload keep fully bespoke functions — login, file upload), `handlers.py` (Tornado handlers extending `BaseHandler`, delegating to `xxx_service`), `__init__.py`. `dict` bundles Dict+DictItem, `config` bundles Config+ConfigItem+ConfigWeb, `role` bundles RoleMenu, `user` bundles UserRole and the profile/index handlers. `operation_log` / `login_log` are full audit-log modules: `@operation_log` records write ops (rich fields + param redaction + **IP 归属地**) and `@login_log` records login success/failure/logout; both expose list/detail/delete endpoints under `/api/v1/operationlog` and `/api/v1/loginlog`. Permission nodes (menu rows with `type=1`) are resolved by `src/common/permission.py:get_permissions_list`(Redis 缓存,角色/菜单变更按版本号失效).
|
||
- **`src/common/`** — `middleware/` (authentication/cors/operation_log/login_log/trace_id, plus `access_decorators.py` holding `@check_demo` + `@permission_required`), `utils/` (crypto bcrypt, captcha, file [`mkdir` + 魔术字节校验 `verify_file_magic`], rich_text [`save_file`/`get_file_url`/`save_content`,XSS 白名单清洗 + 路径穿越防护,图片/文件保存统一入口], ip2region [IP→省市区,日志归属地], dict_util [字典 Redis 缓存读取], helpers [`parse_id_list`/`detect_os`/`detect_browser`/`detect_source`/`redact_params`], validators), `permission.py` (跨模块权限解析).
|
||
- **`src/extensions/`** — re-exports core singletons (`sqlalchemy.py` → `Base`/`db`/`SessionLocal`/`engine`/`reader_engine`/`ReaderSession`; `redis.py` → `redis`) and placeholder `swagger.py`/`sentry.py`.
|
||
- **`src/api/v1/`** — one route module per resource (`auth.py`, `users.py`, `roles.py`, …), each exporting `routes`; `router.py:build_routes()` prefixes all with `/api/v1` and adds `/api/v1/health`.
|
||
|
||
### Request lifecycle / auth
|
||
|
||
- Every business handler subclasses `src/core/base_handler.py:BaseHandler`. Its `prepare()` is wrapped with `@login_required`, so **every request except `/api/v1/login`, `/api/v1/captcha`, and `OPTIONS` requires a valid JWT** in `Authorization: Bearer <token>`. The authenticated `userId` is stored in `current_user_id` (a contextvar) via `src/common/middleware/authentication.py`.
|
||
- Per-handler authorization uses `@permission_required("sys:user:list")` (`src/common/middleware/access_decorators.py`), which checks the user's permission nodes via `common.permission.get_permissions_list`(Redis 缓存,角色/菜单/用户角色变更时按版本号失效). **User id `1` (admin) bypasses all permission checks.**
|
||
- Handlers are thin: mutating endpoints are wrapped with `@check_demo` (`src/common/middleware/access_decorators.py`), which blocks writes with a unified message when `TORNADO_DEMO=True`; handlers then delegate to the module's service. Responses go through `R.ok`/`R.failed`.
|
||
- 登录/登出安全:登录做防爆破(同一 IP+用户名 连续失败 `LOGIN_FAIL_MAX` 次后锁定 `LOGIN_LOCK_SECONDS` 秒,Redis 计数);登出把当前 token 加入黑名单立即失效;`POST /api/v1/token/refresh` 可轮换签发新 token(旧 token 拉黑)。`/api/v1/dict/data/{code}` 提供字典下拉数据。
|
||
|
||
### BaseService (template-method CRUD)
|
||
|
||
Simple modules subclass `src/core/base_service.py:BaseService` and only **declare difference points** to get standard CRUD for free: `model`/`repo_cls`, Pydantic `create_schema`/`update_schema`/`status_schema`, `page_like_fields`/`page_eq_fields`/`page_order_by` (pagination filters & sort), `unique_fields` (`{field: message}` uniqueness check), `serialize_maps` (`{field: dict 字面映射或字典编码字符串}`;str 值优先按 `dict_util` 字典编码解析,回落 `config.constants` 常量名;取值时兼容 int/str key), `serialize_extra`/`detail_serialize` (plain callables `(item) -> dict` for extra fields), `options_filter` (dropdown-list equality filter). Hooks to override for bespoke logic: `_before_add`/`_before_update`/`_before_delete`, `_build_create_fields`/`_build_update_fields` (update excludes `None` values so optional fields aren't NULLed). Standard methods: `get_page`(`_parse_pagination` 限制 page≥1、limit∈[1,`MAX_PAGE_LIMIT`]), `get_detail`, `get_options`, `add`, `update`, `delete`, `update_status` (all take the Tornado handler, return `R` objects). Heavily-custom modules (`user`, `menu`) override `add`/`update`/`get_page`/`_serialize_detail`/`_build_page_query` to handle roles/city/permission-nodes; only auth/upload keep fully bespoke functions.
|
||
|
||
### Service / repository conventions
|
||
|
||
- Repositories (`src/core/base_repository.py`): soft-delete convention (`is_delete=0` = active). Convenience methods (`get_by_id` (ID-robust), `get_by_ids`, `get_one`, `all`, `count`, `exists`, `exists_by_field(field, value, exclude_id)`, `filter_by`, `paginate`, `create`, `update`, `soft_delete`, `batch_delete("1,2,3")`) auto-append the soft-delete filter; `filter(*criterion)` is the raw escape hatch (no soft filter) for complex conditions. `create`/`update` auto-fill audit fields (`create_user`/`update_user`) from `current_realname`(操作者姓名).
|
||
- `BaseService` standard methods wrap in `try/finally: repo.close()`; bespoke services call `db.close()` in a `finally` block.
|
||
- List handlers use `page`/`limit` query args (default `PAGE_LIMIT=10`); deletion endpoints accept comma-separated IDs; all deletes are soft (`is_delete=1`).
|
||
- Passwords are **always salted** via the **double-salt bcrypt** scheme in `src/core/password.py` (`generate_salt`/`encrypt_password`/`verify_password`); the salt is stored in the user `salt` column. `src/common/utils/crypto.py` is the facade: `set_password(password) -> (salt, hashed)` and `check_password(password, salt, hashed)` for setting/verifying, plus `verify_user_password`/`is_legacy_hash` for the login path. Legacy **no-salt bcrypt** hashes are accepted on login and **auto-upgraded to the salt scheme** (`modules/auth/service.py:_verify_login_password`). MD5 is intentionally not supported.
|
||
- `BaseModel.to_dict()` excludes `password`/`salt`/`is_delete` (`config.constants.SERIALIZE_EXCLUDE_FIELDS`) — never return raw model `to_dict()` output containing secrets to the API. Model instances do **not** expose `save()/delete()` — data access goes through repositories.
|
||
|
||
### API / URL conventions
|
||
|
||
- All routes are under `/api/v1`. Handler naming: `XxxListHandler`, `XxxDetailHandler`, `XxxAddHandler`, `XxxUpdateHandler`, `XxxDeleteHandler`, `XxxStatusHandler`, `XxxGetListHandler` (last returns an option list for dropdowns). Route conventions preserved from the original app: `/list?`, `/detail/(\d+)`, `/add`, `/update`, `/delete/(\d+)`, `/status`, `/getXxxList`.
|
||
- To add a new feature: copy an existing simple module (e.g. `link`) across the six files, register a route module in `src/api/v1/`, and import it in `router.py`.
|
||
|
||
## Tests
|
||
|
||
`tests/unit` covers core (jwt, response, crypto, BaseService, repository, serialization-security) and module schemas. `tests/integration` (health/captcha/DB/Redis) runs only when MySQL and Redis are reachable — verified via a real `SELECT 1`/`ping` in `tests/integration/conftest.py`, otherwise skipped. The root `conftest.py` was removed; pytest resolves `src` via `[tool.pytest.ini_options].pythonpath = ["src"]`, and integration tests import the factory as `from src.app import create_app`.
|