diff --git a/README.md b/README.md index 37970209..66b9ddff 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,10 @@ docker compose -f docker-compose.dev.yml up -d # PostgreSQL + Redis pnpm install:all # frontend + backend dependencies cp apps/api/backend/.env.example apps/api/backend/.env # defaults match the containers above pnpm db:init # create tables + seed data (first run) -pnpm dev # open http://localhost:8888 +pnpm dev # open http://127.0.0.1:8888 ``` -Sign in as `admin` / `123456`. Switching to SQL Server / MySQL, S3, skipping the captcha and running tests are covered in [CONTRIBUTING.md](./CONTRIBUTING.md#本地开发) (Chinese; the commands are language-independent). +Sign in as `admin` with the password you set during `pnpm db:init`. Switching to SQL Server / MySQL, S3, skipping the captcha and running tests are covered in [CONTRIBUTING.md](./CONTRIBUTING.md#本地开发) (Chinese; the commands are language-independent). ## Quality diff --git a/README.zh-CN.md b/README.zh-CN.md index 5d95e29d..37d6197a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -105,10 +105,10 @@ docker compose -f docker-compose.dev.yml up -d # PostgreSQL + Redis pnpm install:all # 前后端依赖 cp apps/api/backend/.env.example apps/api/backend/.env # 默认配置已对齐上面的容器 pnpm db:init # 建表 + 初始数据(首次) -pnpm dev # 打开 http://localhost:8888 +pnpm dev # 打开 http://127.0.0.1:8888 ``` -账号 `admin` / `123456`。换 SQL Server / MySQL、接 S3、跳过验证码、跑测试,见[本地开发指南](./CONTRIBUTING.md#本地开发)。 +账号 `admin`,密码是 `pnpm db:init` 时设的。换 SQL Server / MySQL、接 S3、跳过验证码、跑测试,见[本地开发指南](./CONTRIBUTING.md#本地开发)。 部署到服务器:`main` 每次通过 CI 都会构建好镜像,服务器上 `bash prod.sh pull && bash prod.sh up` 即可更新,见 [`deploy/README.md`](./deploy/README.md)。 diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index f7427681..b363a6d1 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -66,7 +66,8 @@ export default defineConfig({ // 主题和后台同一套:Inter / JetBrains Mono 自托管(不走 Google Fonts —— 国内打不开),品牌紫见 theme.css customCss: ['@fontsource-variable/inter', '@fontsource-variable/jetbrains-mono', './src/styles/theme.css'], // 首页 hero 换成登录页那套品牌面板 + 立体榫卯(src/components/Hero.astro) - components: { Hero: './src/components/Hero.astro' }, + // 顶栏的主题切换换成太阳 / 月亮两段式(src/components/ThemeSelect.astro) + components: { Hero: './src/components/Hero.astro', ThemeSelect: './src/components/ThemeSelect.astro' }, social: [{ icon: 'github', label: 'GitHub', href: REPO }], editLink: { baseUrl: `${REPO}/edit/main/apps/docs/` }, // 每页显示「最后更新」—— 读 git 历史,所以 CI 里 checkout 要 fetch-depth: 0, @@ -90,11 +91,21 @@ export default defineConfig({ translations: { en: 'Getting started' }, items: [{ autogenerate: { directory: 'getting-started' } }], }, + { + label: '指南', + translations: { en: 'Guides' }, + items: [{ autogenerate: { directory: 'guides' } }], + }, { label: '功能', translations: { en: 'Features' }, items: [{ autogenerate: { directory: 'features' } }], }, + { + label: '参考', + translations: { en: 'Reference' }, + items: [{ autogenerate: { directory: 'reference' } }], + }, { label: '链接', translations: { en: 'Links' }, diff --git a/apps/docs/src/components/ThemeSelect.astro b/apps/docs/src/components/ThemeSelect.astro new file mode 100644 index 00000000..f6659f61 --- /dev/null +++ b/apps/docs/src/components/ThemeSelect.astro @@ -0,0 +1,93 @@ +--- +/** + * 覆盖 Starlight 的 ThemeSelect:顶栏里那个「自动 ▾」下拉换成太阳 / 月亮两段式切换。 + * + * 状态沿用 Starlight 自己的约定(localStorage `starlight-theme`、``), + * 所以首屏防闪烁的那段内联脚本(ThemeProvider)照常生效。没选过时跟随系统(auto), + * 高亮的是系统当前那一侧;点一下就固定成浅色或深色。 + */ +const label = Astro.locals.t('themeSelect.accessibleLabel') +const light = Astro.locals.t('themeSelect.light') +const dark = Astro.locals.t('themeSelect.dark') +--- + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/src/content/docs/en/getting-started/deployment.md b/apps/docs/src/content/docs/en/getting-started/deployment.md index dd0b9da0..8cd4645b 100644 --- a/apps/docs/src/content/docs/en/getting-started/deployment.md +++ b/apps/docs/src/content/docs/en/getting-started/deployment.md @@ -43,6 +43,19 @@ bash prod.sh pull && bash prod.sh up Back up the database before the first deployment against an existing one. +## Checklist before going live + +- [ ] Every `CHANGE_ME` in `.env.server` is replaced. With `ENVIRONMENT='prod'` the backend checks length and randomness and refuses to start on weak values +- [ ] `TOKEN_SECRET_KEY` is freshly generated (`python -c "import secrets;print(secrets.token_urlsafe(32))"`), not copied from another environment +- [ ] `CORS_ALLOWED_ORIGINS` is your real address; behind nginx or a load balancer, `TRUSTED_PROXIES` is set — otherwise rate limits and IP allow-lists see the proxy's address +- [ ] If you use the AI assistant, object storage, group bots or the open platform, their encryption keys are set (`AI_ENCRYPT_SECRET_KEY`, `STORAGE_ENCRYPT_SECRET_KEY`, `NOTIFICATION_ENCRYPT_SECRET_KEY`, `OPEN_SECRET_KEY`) — without them saving those settings fails instead of storing plaintext +- [ ] `NOTIFICATION_WEB_BASE_URL` is your real address, or links in emails and group messages point at `localhost:8888` +- [ ] The admin password is the strong one set during initialization, not the demo's `123456` +- [ ] The database is backed up before the first deployment against it, and scheduled backups exist +- [ ] TLS is provided by the reverse proxy or load balancer, and HTTP redirects to HTTPS +- [ ] External monitoring probes `/api/health/status`, not `/healthz` +- [ ] You noted the current image tag (`sha-`) so you know what to roll back to + ## How do I update or roll back? ```bash diff --git a/apps/docs/src/content/docs/en/getting-started/quick-start.md b/apps/docs/src/content/docs/en/getting-started/quick-start.md index 4421a456..658d8555 100644 --- a/apps/docs/src/content/docs/en/getting-started/quick-start.md +++ b/apps/docs/src/content/docs/en/getting-started/quick-start.md @@ -1,11 +1,11 @@ --- title: Quick start -description: Run fastapi-react-admin locally — PostgreSQL and Redis in Docker, five commands, then sign in at localhost:8888 as admin / 123456. +description: Run fastapi-react-admin locally — PostgreSQL and Redis in Docker, five commands, set the admin password during init, then sign in at 127.0.0.1:8888. sidebar: order: 1 --- -Five commands bring up the whole stack locally: PostgreSQL and Redis in Docker, the FastAPI backend on port 8088, the React frontend on port 8888 and a Celery worker. Sign in as `admin` / `123456`. +Five commands bring up the whole stack locally: PostgreSQL and Redis in Docker, the FastAPI backend on port 8088, the React frontend on port 8888 and a Celery worker. The account is `admin`, with the password you set during `pnpm db:init`. ## What do I need installed? @@ -19,11 +19,32 @@ Five commands bring up the whole stack locally: PostgreSQL and Redis in Docker, docker compose -f docker-compose.dev.yml up -d # PostgreSQL 16 + Redis pnpm install:all # JS workspace, Python deps, git hooks, Playwright Chromium cp apps/api/backend/.env.example apps/api/backend/.env # defaults already match the containers above -pnpm db:init # create tables + seed data (first run only) +pnpm db:init # tables + seed data + a new admin password (first run only) pnpm dev # api :8088 · web :8888 · celery worker + # open http://127.0.0.1:8888 ``` -Open and sign in as `admin` / `123456`. +Open and sign in as `admin` with the password you set during `pnpm db:init`. + +:::note[Why not admin / 123456?] +The seed's `123456` is not allowed to survive initialization: `pnpm db:init` asks for a new admin password (non-interactively it reads `FBA_INIT_ADMIN_PASSWORD`) and disables the sample `test` account. `admin` / `123456` is only the [live demo](https://fra.wubunan.com/sign-in) account. +::: + +:::caution[Use 127.0.0.1, not localhost] +The frontend calls `http://127.0.0.1:8088` directly. Opened from `localhost:8888`, the browser treats that as cross-site and does not keep the refresh-token cookie: sign-in works, but **a page refresh sends you back to the sign-in page**. +::: + +### How do I know each step worked? + +| Step | Check | +|---|---| +| `docker compose … up -d` | `docker ps` shows `fba_postgres` and `fba_redis` as `healthy` | +| `pnpm install:all` | finishes without errors, and `apps/api/.venv` exists | +| `pnpm db:init` | the last line says the new database was stamped at the migration head; `pnpm db:current` shows a revision marked `(head)` | +| `pnpm dev` | the sign-in page shows a **captcha image** — the backend serves it, so seeing it means frontend and backend are connected | +| Sign in | you land on the dashboard, which starts with "you have N things waiting" | + +Stuck on a step? Search the error text in the [FAQ](/en/reference/faq/). :::caution `pnpm db:init` drops and recreates every table. Run it on the first setup or when you want a clean database; after that, schema changes go through `pnpm db:upgrade` (Alembic). @@ -48,7 +69,7 @@ Everything optional is in the same `docker-compose.dev.yml`; turn it on and chan | **S3-compatible storage** (RustFS) | `docker compose -f docker-compose.dev.yml --profile s3 up -d rustfs` | Set `STORAGE_ENCRYPT_SECRET_KEY` in `.env` (`openssl rand -hex 32`) before adding a storage under **System → Storage** | :::caution -Keep `DATABASE_PK_MODE='snowflake'` (the default). Seed data only exists for snowflake IDs; in autoincrement mode the seed step is skipped silently and you end up with tables but no `admin` account. +Keep `DATABASE_PK_MODE='snowflake'` as in `.env.example`. Seed data only exists for snowflake IDs; in autoincrement mode (or if `.env` lacks the line — the code default is `autoincrement`) initialization exits with an error saying the seed has no admin account. ::: See [Databases](/en/features/databases/) for how the three databases are supported and tested. diff --git a/apps/docs/src/content/docs/en/guides/first-module.md b/apps/docs/src/content/docs/en/guides/first-module.md new file mode 100644 index 00000000..79f270cf --- /dev/null +++ b/apps/docs/src/content/docs/en/guides/first-module.md @@ -0,0 +1,1508 @@ +--- +title: Add a business module from scratch +description: Build a real Customers module end to end, from model and migration through the three backend layers, menus and permissions, to a list page with data scopes. +sidebar: + order: 1 +--- + +This repository has no code generator. You write a new business module by hand, following how the existing modules are built. Each layer has a few rules. If you miss one, nothing reports an error and the feature still works, but permissions, data scopes or migrations end up wrong. + +This page walks the whole chain with a **Customers** module (客户管理). Code comments are kept in Chinese, as in the repository: + +``` +model → Alembic migration → schema / crud / service / api → register router + → menu and button permission codes → frontend list page → data scopes → tests +``` + +All the code below was actually run on a clean checkout: the migration was generated, upgraded and rolled back on PostgreSQL, the endpoints were called with curl, `pnpm typecheck --force`, `pnpm arch:check`, `pnpm lint` and `pnpm i18n:check` all passed, and the module tests plus the repository's own guard tests were green. Anything that was not verified is called out where it comes up. + +Before you start, get the services running (see [Quick start](/en/getting-started/quick-start/)) and read hard rules 1–6 in the root `CLAUDE.md`. + +## What does the module look like? + +| Field | Type | Notes | +|---|---|---| +| `name` | string (64) | Customer name, required | +| `phone` | string (20) | Contact phone | +| `level` | integer | 0 normal · 1 important · 2 VIP | +| `status` | integer | 0 disabled · 1 active | +| `dept_id` | snowflake ID | Owning department. Data scopes filter on this column | +| `owner_id` | snowflake ID | Responsible user | +| `created_by` | snowflake ID | Creator. The "own data only" rule filters on this column | +| `remark` | long text | Notes | + +The backend code goes into a new app, `apps/api/backend/app/crm/`, laid out like `app/task/`. You could make it a plugin instead (see `backend/plugin/dict/`), but plugins add `plugin.toml`, install and uninstall, which an ordinary business module does not need. + +``` +apps/api/backend/app/crm/ +├─ __init__.py +├─ model/ __init__.py · customer.py +├─ schema/ __init__.py · customer.py +├─ crud/ __init__.py · crud_customer.py +├─ service/ __init__.py · customer_service.py +├─ api/ __init__.py · router.py · v1/__init__.py · v1/customer.py +└─ tests/ __init__.py · test_customer.py +``` + +## Step 1: write the model + +`apps/api/backend/app/crm/model/customer.py`: + +```python +import sqlalchemy as sa + +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import Base, UniversalStr, UniversalText, id_key + + +class Customer(Base): + """客户表""" + + __tablename__ = 'crm_customer' + __table_args__ = {'comment': '客户表'} + + id: Mapped[id_key] = mapped_column(init=False) + name: Mapped[str] = mapped_column(UniversalStr(64), comment='客户名称') + phone: Mapped[str | None] = mapped_column(UniversalStr(20), default=None, comment='联系电话') + level: Mapped[int] = mapped_column(default=0, comment='客户等级(0:普通、1:重要、2:VIP)') + status: Mapped[int] = mapped_column(default=1, comment='状态(0:停用、1:正常)') + # 数据权限靠这两列落地:规则里的 `__dept_id__` / `__created_by__` 解析成同名列,表上没有,规则就落不下来 + dept_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='所属部门 ID') + owner_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, comment='负责人用户 ID') + created_by: Mapped[int] = mapped_column(sa.BigInteger, default=0, index=True, comment='创建者用户 ID') + remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注') +``` + +`apps/api/backend/app/crm/model/__init__.py`: + +```python +from backend.app.crm.model.customer import Customer as Customer +``` + +Notes: + +- Inheriting `Base` gives you `created_time` / `updated_time` / `deleted` / `deleted_time`. `deleted` holds "0 or this row's own id", not a boolean, so hand-written SQL needs `deleted = 0` +- The primary key is `Mapped[id_key]`, a snowflake ID +- Columns that store Chinese text use `UniversalStr` / `UniversalText`, not `sa.String`. On SQL Server Chinese text needs `NVARCHAR`, and `UniversalStr` uses it automatically +- The model must live under `backend/app//model/`. Data scopes find models by that path (`utils/dynamic_import.py: get_app_models`) + +**Check**: the model does not create its table by itself. If the migration in the next step sees `crm_customer`, the model was loaded. + +## Step 2: generate the migration + +After changing the model, generate a migration: + +```bash +pnpm db:revision '客户管理 crm_customer' +``` + +alembic writes a file under `apps/api/backend/alembic/versions/`. **Read it before you commit it.** Look at three things: + +```python +op.create_table('crm_customer', + sa.Column('id', sa.BigInteger(), autoincrement=False, nullable=False, comment='雪花算法主键 ID'), + sa.Column('name', backend.common.model.UniversalStr(length=64), nullable=False, comment='客户名称'), + # … + sa.Column('deleted', sa.BigInteger(), server_default='0', nullable=False, comment='是否已删除(0:否;id:是)'), + # … +) +op.create_index(op.f('ix_crm_customer_dept_id'), 'crm_customer', ['dept_id'], unique=False) +``` + +1. **`id` has `autoincrement=False`.** `id_key` declares it, and autogenerate renders it. In a hand-written create-table migration you have to add it yourself, otherwise SQL Server creates the snowflake key as IDENTITY and inserts in production fail with `Cannot insert explicit value for identity column` +2. **Only this one table is created.** If the file contains a list of `drop_table` calls, the `import backend.main` line in `alembic/env.py` has been removed. Do not commit it +3. No other table should change beyond what you changed in the model + +Then upgrade: + +```bash +pnpm db:upgrade +pnpm db:current +``` + +**Check**: `pnpm db:current` shows the new revision with `(head)`. To confirm the migration rolls back, run `cd apps/api/backend && uv run --no-sync alembic downgrade -1` once, then `pnpm db:upgrade` again. + +:::caution +Every model change needs a migration. `--reload` only re-imports the code; it does not change tables that already exist. The test `test_model_matches_migrations` compares the models with the database and fails if a migration is missing. +::: + +## Step 3: write the schemas + +`apps/api/backend/app/crm/schema/customer.py`: + +```python +from datetime import datetime + +from pydantic import ConfigDict, Field + +from backend.app.crm.model import Customer +from backend.common.enums import StatusType +from backend.common.schema import ColumnLengthChecked, SchemaBase + + +class CustomerSchemaBase(SchemaBase): + """客户基础模型""" + + name: str = Field(description='客户名称') + phone: str | None = Field(None, description='联系电话') + level: int = Field(0, ge=0, le=2, description='客户等级(0:普通、1:重要、2:VIP)') + status: StatusType = Field(StatusType.enable, description='状态(0:停用、1:正常)') + remark: str | None = Field(None, description='备注') + + +class CreateCustomerParam(ColumnLengthChecked, CustomerSchemaBase): + """创建客户参数""" + + __sa_model__ = Customer + + dept_id: int | None = Field(None, description='所属部门 ID(不传 = 创建人的部门)') + owner_id: int | None = Field(None, description='负责人用户 ID(不传 = 创建人)') + + +class UpdateCustomerParam(ColumnLengthChecked, CustomerSchemaBase): + """更新客户参数""" + + __sa_model__ = Customer + + dept_id: int | None = Field(None, description='所属部门 ID') + owner_id: int | None = Field(None, description='负责人用户 ID') + + +class DeleteCustomerParam(SchemaBase): + """删除客户参数""" + + pks: list[int] = Field(description='客户 ID 列表') + + +class GetCustomerDetail(CustomerSchemaBase): + """客户详情""" + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(description='客户 ID') + dept_id: int | None = Field(None, description='所属部门 ID') + owner_id: int | None = Field(None, description='负责人用户 ID') + created_by: int = Field(description='创建者用户 ID') + created_time: datetime = Field(description='创建时间') + updated_time: datetime | None = Field(None, description='更新时间') +``` + +Write parameter classes mix in `ColumnLengthChecked` and bind the model with `__sa_model__`. It reads the column lengths from the model to validate input, so you do not write `max_length` on each field. Without it, an over-long value goes all the way to the database and the endpoint returns 500. + +Declaring `id` as `int` is fine. On the way out, `stringify_unsafe_ints` turns any value beyond JavaScript's safe integer range into a string. + +**Check**: once the endpoints exist, submitting a 70-character `name` returns 422 with "name 最长 64 个字符,收到 70 个" (max 64 characters, got 70). + +## Step 4: write the crud layer (DAO) + +`apps/api/backend/app/crm/crud/crud_customer.py`: + +```python +from sqlalchemy import Select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.crm.model import Customer +from backend.common.security.data_scope import DataScopedCRUD +from backend.utils.timezone import timezone + + +class CRUDCustomer(DataScopedCRUD[Customer]): + """客户数据库操作类 + + 继承 `DataScopedCRUD` 且**不**写 `data_scope_enabled = False`:读操作自动套上当前用户的数据范围。 + """ + + async def get(self, db: AsyncSession, pk: int) -> Customer | None: + """获取客户详情(受数据范围约束:范围外的当作不存在)""" + return await self.select_model(db, pk, deleted=0) + + async def get_visible_ids(self, db: AsyncSession, pks: list[int]) -> list[int]: + """这批 ID 里当前用户看得见的那些(写操作前用它收窄)""" + rows = await self.select_models(db, id__in=pks, deleted=0) + return [row.id for row in rows] + + async def get_select(self, name: str | None, phone: str | None, level: int | None, status: int | None) -> Select: + """分页查询表达式(`select_order` → `select()`,数据范围已经在里面了)""" + filters = {'deleted': 0} + if name is not None: + filters['name__like'] = f'%{name}%' + if phone is not None: + filters['phone__like'] = f'%{phone}%' + if level is not None: + filters['level'] = level + if status is not None: + filters['status'] = status + return await self.select_order('id', 'desc', **filters) + + async def create(self, db: AsyncSession, data: dict) -> None: + """创建客户""" + db.add(self.model(**data)) + + async def update(self, db: AsyncSession, pk: int, data: dict) -> int: + """更新客户(⚠️ 写操作不过数据范围,调用前必须先 `get` 确认看得见)""" + return await self.update_model_by_column(db, data, id=pk, deleted=0) + + async def delete(self, db: AsyncSession, pks: list[int]) -> int: + """逻辑删除(⚠️ 同上,`pks` 要先经过 `get_visible_ids`)""" + return await self.delete_model_by_column( + db, + allow_multiple=True, + logical_deletion=True, + deleted_flag_column='deleted', + deleted_flag_value=self.model.id, + deleted_at_column='deleted_time', + deleted_at_factory=timezone.now(), + id__in=pks, + deleted=0, + ) + + +customer_dao: CRUDCustomer = CRUDCustomer(Customer) +``` + +Rules: + +- **Use `DataScopedCRUD` as the base class, not `CRUDPlus`.** Lists (`select`) and details (`select_model`) automatically get the current user's data-scope condition. If a table really should not be filtered by data scope, write `data_scope_enabled = False` in the class and explain why in a comment. The test `test_every_crud_class_declares_its_data_scope_stance` checks that every DAO makes this choice +- **Data scopes apply to reads, not writes.** `update_model_by_column` / `delete_model_by_column` build UPDATE / DELETE statements directly and never go through `select()`. Measured: in the context of a user who can see nothing, `get()` returned `None` while `update()` still changed 1 row. So write operations must first narrow the targets with a scoped read (see the next step) +- **Paginated queries need an order** (`select_order`). SQL Server's `OFFSET … FETCH` requires `ORDER BY` +- **The `Select` you hand to pagination must not join many-to-many tables**, or `total` counts rows after the join and pages shift. This table has no many-to-many relation, so it does not apply here + +## Step 5: write the service + +`apps/api/backend/app/crm/service/customer_service.py`: + +```python +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.crm.crud.crud_customer import customer_dao +from backend.app.crm.model import Customer +from backend.app.crm.schema.customer import CreateCustomerParam, DeleteCustomerParam, UpdateCustomerParam +from backend.common.exception import errors +from backend.common.i18n import t +from backend.common.pagination import paging_data + + +class CustomerService: + """客户服务类""" + + @staticmethod + async def get(*, db: AsyncSession, pk: int) -> Customer: + customer = await customer_dao.get(db, pk) + if not customer: + raise errors.NotFoundError(msg=t('error.customer.not_found')) + return customer + + @staticmethod + async def get_list( + *, db: AsyncSession, name: str | None, phone: str | None, level: int | None, status: int | None + ) -> dict[str, Any]: + customer_select = await customer_dao.get_select(name=name, phone=phone, level=level, status=status) + return await paging_data(db, customer_select) + + @staticmethod + async def create(*, db: AsyncSession, obj: CreateCustomerParam, user: Any) -> None: + data = obj.model_dump() + # 不传就归到创建人自己的部门 / 名下 —— 否则 dept_id 是空,「本部门」规则下谁都看不见它 + data['dept_id'] = data['dept_id'] or user.dept_id + data['owner_id'] = data['owner_id'] or user.id + data['created_by'] = user.id + await customer_dao.create(db, data) + + @staticmethod + async def update(*, db: AsyncSession, pk: int, obj: UpdateCustomerParam) -> int: + # 先过一次受范围约束的读:范围外的客户在这里就是 404,不会被改到 + if not await customer_dao.get(db, pk): + raise errors.NotFoundError(msg=t('error.customer.not_found')) + # exclude_unset:没传的字段不写。否则只改名字的请求会把 dept_id / owner_id 冲成 NULL + return await customer_dao.update(db, pk, obj.model_dump(exclude_unset=True)) + + @staticmethod + async def delete(*, db: AsyncSession, obj: DeleteCustomerParam) -> int: + visible = await customer_dao.get_visible_ids(db, obj.pks) + if not visible: + return 0 + return await customer_dao.delete(db, visible) + + +customer_service: CustomerService = CustomerService() +``` + +Error messages go through `t('error..')`, not Chinese string literals. Add the key to both locale files: + +```yaml +# apps/api/backend/locale/zh-CN.yml, under error:, alphabetically before data_rule + customer: + not_found: 客户不存在 +``` + +```yaml +# apps/api/backend/locale/en-US.yml + customer: + not_found: Customer does not exist +``` + +`backend/tests/test_i18n.py` checks that both locale files have the same keys and that every key referenced in code exists. + +## Step 6: write the API and register the router + +`apps/api/backend/app/crm/api/v1/customer.py`: + +```python +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query, Request + +from backend.app.crm.schema.customer import ( + CreateCustomerParam, + DeleteCustomerParam, + GetCustomerDetail, + UpdateCustomerParam, +) +from backend.app.crm.service.customer_service import customer_service +from backend.common.pagination import DependsPagination, PageData +from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base +from backend.common.security.permission import RequestPermission +from backend.common.security.rbac import DependsRBAC +from backend.database.db import CurrentSession, CurrentSessionTransaction + +router = APIRouter() + + +@router.get( + '/{pk}', + summary='获取客户详情', + dependencies=[Depends(RequestPermission('crm:customer:list')), DependsRBAC], +) +async def get_customer( + db: CurrentSession, pk: Annotated[int, Path(description='客户 ID')] +) -> ResponseSchemaModel[GetCustomerDetail]: + data = await customer_service.get(db=db, pk=pk) + return response_base.success(data=data) + + +@router.get( + '', + summary='分页获取客户', + dependencies=[Depends(RequestPermission('crm:customer:list')), DependsRBAC, DependsPagination], +) +async def get_customers_paginated( + db: CurrentSession, + name: Annotated[str | None, Query(description='客户名称')] = None, + phone: Annotated[str | None, Query(description='联系电话')] = None, + level: Annotated[int | None, Query(description='客户等级')] = None, + status: Annotated[int | None, Query(description='状态')] = None, +) -> ResponseSchemaModel[PageData[GetCustomerDetail]]: + page_data = await customer_service.get_list(db=db, name=name, phone=phone, level=level, status=status) + return response_base.success(data=page_data) + + +@router.post( + '', + summary='创建客户', + dependencies=[Depends(RequestPermission('crm:customer:add')), DependsRBAC], +) +async def create_customer(request: Request, db: CurrentSessionTransaction, obj: CreateCustomerParam) -> ResponseModel: + await customer_service.create(db=db, obj=obj, user=request.user) + return response_base.success() + + +@router.put( + '/{pk}', + summary='更新客户', + dependencies=[Depends(RequestPermission('crm:customer:edit')), DependsRBAC], +) +async def update_customer( + db: CurrentSessionTransaction, pk: Annotated[int, Path(description='客户 ID')], obj: UpdateCustomerParam +) -> ResponseModel: + await customer_service.update(db=db, pk=pk, obj=obj) + return response_base.success() + + +@router.delete( + '', + summary='批量删除客户', + dependencies=[Depends(RequestPermission('crm:customer:del')), DependsRBAC], +) +async def delete_customers(db: CurrentSessionTransaction, obj: DeleteCustomerParam) -> ResponseModel: + await customer_service.delete(db=db, obj=obj) + return response_base.success() +``` + +- Every endpoint gets `Depends(RequestPermission('…'))` + `DependsRBAC`, **reads included**. For a route that declares no permission code, `rbac_verify` lets the request through, so any signed-in user can call it +- `RequestPermission` goes before `DependsRBAC`, because dependencies run in order +- Reads use `CurrentSession`; writes use `CurrentSessionTransaction` (committed when the request finishes) + +`apps/api/backend/app/crm/api/v1/__init__.py`: + +```python +from fastapi import APIRouter + +from backend.app.crm.api.v1.customer import router as customer_router + +router = APIRouter(prefix='/crm') + +router.include_router(customer_router, prefix='/customers', tags=['客户管理']) +``` + +`apps/api/backend/app/crm/api/router.py`: + +```python +from fastapi import APIRouter + +from backend.app.crm.api.v1 import router as v1_router +from backend.core.conf import settings + +v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) + +v1.include_router(v1_router) +``` + +Finally, mount it in `apps/api/backend/app/router.py`: + +```python +from backend.app.crm.api.router import v1 as crm_v1 +# … +router.include_router(crm_v1) +``` + +**Check**: call the endpoints with a superuser token. + +```bash +curl -s "http://127.0.0.1:8088/api/v1/crm/customers?page=1&size=20" -H "Authorization: Bearer $TOKEN" +# {"code":200,"msg":"请求成功","data":{"items":[],"total":0,…}} + +curl -s -X POST http://127.0.0.1:8088/api/v1/crm/customers \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"name":"华东机电","phone":"021-5555","level":2}' +# {"code":200,"msg":"请求成功","data":null} +``` + +List again and the record is there. `id`, `dept_id` and `owner_id` all come back as strings, for example `"id":"2228321080542494720"`. + +## Step 7: add the menu and button permission codes + +The backend uses 4 permission codes, and the menu table needs a button row for each one. A non-superuser's permission codes come only from the menus attached to their roles. If a code is on no menu, that user always gets 403. + +There are two ways to add them: + +- **To try it in your own environment**: create them by hand in System → Menus. First a directory "客户关系" (CRM, path `/crm`), then a menu "客户管理" (Customers, path `/crm/customer`) under it, then 4 buttons under the menu with the codes `crm:customer:list` / `:add` / `:edit` / `:del`. Menus created this way exist only in that one database +- **To ship them with the code**: do what follows, adding them to both the seed SQL and a data migration + +### Why add them in both places? + +| How the database was created | Where its menus come from | +|---|---| +| New environment via `fba init` (create tables, load seed, `stamp head`) | **Seed SQL**. Data migrations are stamped as applied and never actually run | +| Existing database via `alembic upgrade head` | **Data migration**. The seed SQL never runs again | + +If you add them in only one place, one kind of environment ends up without the menus. + +**Seed SQL**: append a block to the end of each `apps/api/backend/sql/{postgresql,mysql,sqlserver}/init_snowflake_test_data.sql`. All three dialects use the same menu IDs. PostgreSQL / MySQL: + +```sql +insert into sys_menu (id, title, name, path, sort, icon, type, perms, status, display, link, remark, parent_id, created_time, updated_time) +values +(2049629108299000001, '客户关系', 'Crm', '/crm', 5, 'mdi:handshake-outline', 0, null, 1, 1, '', null, null, '2026-11-01 00:00:00', null), +(2049629108299000002, '客户管理', 'CrmCustomer', '/crm/customer', 1, 'mdi:account-tie-outline', 1, null, 1, 1, '', null, 2049629108299000001, '2026-11-01 00:00:00', null), +(2049629108299000003, '查询', 'QueryCrmCustomer', null, 0, null, 2, 'crm:customer:list', 1, 0, '', null, 2049629108299000002, '2026-11-01 00:00:00', null), +(2049629108299000004, '新增', 'AddCrmCustomer', null, 1, null, 2, 'crm:customer:add', 1, 0, '', null, 2049629108299000002, '2026-11-01 00:00:00', null), +(2049629108299000005, '修改', 'EditCrmCustomer', null, 2, null, 2, 'crm:customer:edit', 1, 0, '', null, 2049629108299000002, '2026-11-01 00:00:00', null), +(2049629108299000006, '删除', 'DeleteCrmCustomer', null, 3, null, 2, 'crm:customer:del', 1, 0, '', null, 2049629108299000002, '2026-11-01 00:00:00', null); +``` + +The SQL Server file has the same rows with different syntax: Chinese literals get an `N` prefix (`N'客户关系'`) and `null` becomes `NULL`. During verification only the PostgreSQL file was actually loaded into a database; the MySQL and SQL Server files only passed the static check `test_seed_dialects.py` (same tables, row counts and columns across the three). `type` is 0 for a directory, 1 for a menu and 2 for a button. Pick an unused range for the menu IDs and use the same values in all three dialects. + +**Data migration**: extend the migration generated in step 2 and add the menu rows with the helpers in `backend/utils/data_migration.py`: + +```python +from backend.utils.data_migration import add_menu, delete_by_key, seeded_pk_mode + +#: 和种子 SQL 里同一套 ID:升级上来的库和 `fba init` 新建的库收敛到同一份数据 +#: (menu_id, name, title, path, icon, menu_type, perms, sort, display, parent_name) +_MENUS = [ + (2049629108299000001, 'Crm', '客户关系', '/crm', 'mdi:handshake-outline', 0, None, 5, 1, None), + (2049629108299000002, 'CrmCustomer', '客户管理', '/crm/customer', 'mdi:account-tie-outline', 1, None, 1, 1, 'Crm'), + (2049629108299000003, 'QueryCrmCustomer', '查询', None, None, 2, 'crm:customer:list', 0, 0, 'CrmCustomer'), + (2049629108299000004, 'AddCrmCustomer', '新增', None, None, 2, 'crm:customer:add', 1, 0, 'CrmCustomer'), + (2049629108299000005, 'EditCrmCustomer', '修改', None, None, 2, 'crm:customer:edit', 2, 0, 'CrmCustomer'), + (2049629108299000006, 'DeleteCrmCustomer', '删除', None, None, 2, 'crm:customer:del', 3, 0, 'CrmCustomer'), +] + + +def upgrade() -> None: + # …autogenerate 生成的 create_table / create_index 保持不动… + + if not seeded_pk_mode(): + return # 自增主键模式没有基础种子,也就没有菜单要补 + conn = op.get_bind() + for menu_id, name, title, path, icon, menu_type, perms, sort, display, parent in _MENUS: + add_menu( + conn, + menu_id=menu_id, + name=name, + title=title, + path=path, + perms=perms, + icon=icon, + menu_type=menu_type, + sort=sort, + display=display, + parent_name=parent, + ) + + +def downgrade() -> None: + if seeded_pk_mode(): + conn = op.get_bind() + # 倒序删:先按钮后目录,否则子项变孤儿被提到侧边栏根上 + for menu in reversed(_MENUS): + delete_by_key(conn, 'sys_menu', name=menu[1]) + # …autogenerate 生成的 drop_index / drop_table… +``` + +`add_menu` uses `name` both to decide whether the menu already exists and to find the parent. Running it twice does not insert twice, and it does not matter that parent IDs differ between environments. + +After changing the seed SQL, update the seed manifest (a guard test compares the seed files' hashes): + +```bash +cd apps/api && pnpm seed:manifest --write +``` + +**Check**: + +- After `pnpm db:upgrade`, `select name, perms from sys_menu where name like '%Crm%'` returns 6 rows +- After `downgrade -1` those 6 rows and the `crm_customer` table are gone; after `upgrade` they are back +- `app/admin/tests/security/test_permission_codes.py` passes. It checks that the permission codes in backend `RequestPermission`, frontend `` and the seed menus agree + +## Step 8: write the frontend list page + +A page has three parts, and all three are required: + +| File | Role | +|---|---| +| `packages/platform/src/pages/customer/` | The page component; it receives search only through props | +| `apps/web/src/routes/_auth/crm/customer.tsx` | The route: declares only the search schema, guard and title; renders nothing | +| `apps/web/src/lib/page-registry.tsx` | Maps route IDs to page components; multi-tab uses it to render hidden tabs | + +Lay out the page folder like `pages/notice/`: + +``` +packages/platform/src/pages/customer/ +├─ api.ts types · query keys · queryOptions · mutations +├─ columns.tsx column definitions + row actions (buttons wrapped in ) +├─ form.tsx create / edit sheet (react-hook-form + zod) +├─ index.tsx the page: QueryBar + DataTable + listState +└─ table-features.ts features registered with TanStack Table v9 +``` + +### api.ts + +```ts +import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' + +import { api, type PageData } from '../../api-client/client' + +/** 雪花 ID 一律字符串,不要 `Number()`(硬纪律 6) */ +export type Customer = { + id: string + name: string + phone: string | null + /** 0 普通 · 1 重要 · 2 VIP */ + level: number + /** 0 停用 · 1 正常 */ + status: number + dept_id: string | null + owner_id: string | null + created_by: string + remark: string | null + created_time: string + updated_time: string | null +} + +export type CustomerBody = { + name: string + phone: string | null + level: number + status: number + remark: string | null +} + +export type CustomerListParams = { + page: number + size: number + name?: string + phone?: string + level?: number + status?: number +} + +/** 等级的中文即语言包 key,渲染处过 `t()` */ +export const CUSTOMER_LEVEL_LABEL: Record = { 0: '普通', 1: '重要', 2: 'VIP' } + +const BASE = '/api/v1/crm/customers' + +export const customerKeys = { + all: ['crm', 'customer'] as const, + list: (p: CustomerListParams) => [...customerKeys.all, 'list', p] as const, +} + +function qs(p: CustomerListParams): string { + const s = new URLSearchParams() + s.set('page', String(p.page)) + s.set('size', String(p.size)) + if (p.name) s.set('name', p.name) + if (p.phone) s.set('phone', p.phone) + if (p.level !== undefined) s.set('level', String(p.level)) + if (p.status !== undefined) s.set('status', String(p.status)) + return s.toString() +} + +export const customersQuery = (p: CustomerListParams) => + queryOptions({ + queryKey: customerKeys.list(p), + queryFn: () => api.GET>(`${BASE}?${qs(p)}`), + // 翻页时保留上一页,避免表格闪空 + placeholderData: (prev) => prev, + }) + +function useInvalidate() { + const qc = useQueryClient() + return () => qc.invalidateQueries({ queryKey: customerKeys.all }) +} + +export function useCreateCustomer() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (body: CustomerBody) => api.POST(BASE, { body }), + onSuccess: invalidate, + // 表单里内联显示后端的报错,全局 toast 再弹一遍只是噪音 + meta: { suppressErrorToast: true }, + }) +} + +export function useUpdateCustomer() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: CustomerBody }) => api.PUT(`${BASE}/${id}`, { body }), + onSuccess: invalidate, + meta: { suppressErrorToast: true }, + }) +} + +export function useDeleteCustomers() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (pks: string[]) => api.DELETE(BASE, { body: { pks } }), + onSuccess: invalidate, + }) +} +``` + +### table-features.ts + +```ts +import { columnVisibilityFeature, rowPaginationFeature, rowSelectionFeature, tableFeatures } from '@tanstack/react-table' + +/** 筛选 / 分页都在服务端;后端固定按 id 倒序、没有排序入参,所以不注册排序特性 */ +export const features = tableFeatures({ + columnVisibilityFeature, + rowPaginationFeature, + rowSelectionFeature, +}) +``` + +### columns.tsx + +```tsx +import { createColumnHelper } from '@tanstack/react-table' +import { IconDotsVertical, IconPencil, IconTrash } from '@tabler/icons-react' + +import { formatDateTime } from '@admin/i18n' +import { Button } from '@admin/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@admin/ui/components/dropdown-menu' + +import { Can } from '../../auth/can' +import { buildSelectColumn } from '../_shared/select-column' +import { StatusBadge, StatusPill } from '../_shared/status' +import { CUSTOMER_LEVEL_LABEL, type Customer } from './api' +import type { features } from './table-features' + +const col = createColumnHelper() + +type T = (k: string, vars?: Record) => string + +export function buildColumns(onEdit: (c: Customer) => void, onDelete: (c: Customer) => void, t: T) { + return [ + buildSelectColumn(col, {}, t), + col.accessor('name', { + header: t('客户名称'), + cell: ({ getValue }) => {getValue()}, + }), + col.accessor('phone', { + header: t('联系电话'), + cell: ({ getValue }) => {getValue() ?? '—'}, + }), + col.accessor('level', { + header: t('等级'), + cell: ({ getValue }) => ( + + {t(CUSTOMER_LEVEL_LABEL[getValue()] ?? '—')} + + ), + }), + col.accessor('status', { + header: t('状态'), + cell: ({ getValue }) => , + }), + col.accessor('created_time', { + header: t('创建时间'), + // 时间一律过 formatDateTime(接口下发的是带偏移的 ISO 串) + cell: ({ getValue }) => {formatDateTime(getValue())}, + }), + col.display({ + id: 'actions', + header: () => {t('操作')}, + cell: ({ row }) => ( + + } + > + + + + + onEdit(row.original)}> + + {t('编辑')} + + + + onDelete(row.original)}> + + {t('删除')} + + + + + ), + }), + ] +} + +export const COLUMN_LABELS: Record = { + name: '客户名称', + phone: '联系电话', + level: '等级', + status: '状态', + created_time: '创建时间', +} +``` + +### form.tsx + +```tsx +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { IconLoader2 } from '@tabler/icons-react' + +import { Button } from '@admin/ui/components/button' +import { Input } from '@admin/ui/components/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@admin/ui/components/select' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@admin/ui/components/sheet' +import { Textarea } from '@admin/ui/components/textarea' + +import { ApiError } from '../../api-client/errors' +import { useFieldError } from '../_shared/form-error' +import { FormField } from '../_shared/form-fields' +import { STATUS_FORM_ITEMS } from '../_shared/status' +import { CUSTOMER_LEVEL_LABEL, useCreateCustomer, useUpdateCustomer, type Customer } from './api' + +// 长度和后端列一致(name 64 / phone 20);后端的 ColumnLengthChecked 才是权威,这里只为当场报错 +const schema = z.object({ + name: z.string().trim().min(1, '请输入客户名称').max(64), + phone: z.string().max(20).optional(), + level: z.number().int().min(0).max(2), + status: z.number().int(), + remark: z.string().max(500).optional(), +}) +type Values = z.infer + +const EMPTY: Values = { name: '', phone: '', level: 0, status: 1, remark: '' } + +export function CustomerFormSheet({ + open, + onOpenChange, + editing, +}: { + open: boolean + onOpenChange: (o: boolean) => void + editing: Customer | null +}) { + const { t } = useTranslation() + const fe = useFieldError() + const create = useCreateCustomer() + const update = useUpdateCustomer() + const [err, setErr] = React.useState(null) + const form = useForm({ resolver: zodResolver(schema) as never, defaultValues: EMPTY }) + + React.useEffect(() => { + if (!open) return + setErr(null) + form.reset( + editing + ? { + name: editing.name, + phone: editing.phone ?? '', + level: editing.level, + status: editing.status, + remark: editing.remark ?? '', + } + : EMPTY + ) + }, [open, editing, form]) + + const levelItems = React.useMemo( + () => Object.fromEntries(Object.entries(CUSTOMER_LEVEL_LABEL).map(([v, l]) => [v, t(l)])), + [t] + ) + const statusItems = React.useMemo( + () => Object.fromEntries(Object.entries(STATUS_FORM_ITEMS).map(([v, l]) => [v, t(l)])), + [t] + ) + const errs = form.formState.errors + const pending = create.isPending || update.isPending + + return ( + + + + {editing ? t('编辑客户') : t('新增客户')} + {t('新建的客户归属到你所在的部门,负责人默认是你。')} + + { + setErr(null) + const body = { + name: v.name, + phone: v.phone?.trim() || null, + level: v.level, + status: v.status, + remark: v.remark?.trim() || null, + } + try { + if (editing) await update.mutateAsync({ id: editing.id, body }) + else await create.mutateAsync(body) + onOpenChange(false) + } catch (e) { + setErr(e instanceof ApiError ? e.message : t('保存失败')) + } + })} + className="flex flex-1 flex-col overflow-hidden" + > + + + + + + + + + + form.setValue('level', Number(v), { shouldValidate: true })} + > + + + + + {Object.entries(levelItems).map(([v, l]) => ( + + {l} + + ))} + + + + + form.setValue('status', Number(v), { shouldValidate: true })} + > + + + + + {Object.entries(statusItems).map(([v, l]) => ( + + {l} + + ))} + + + + + + + + {err && {err}} + + + + {pending && } + {editing ? t('保存修改') : t('创建客户')} + + }>{t('取消')} + + + + + ) +} +``` + +Converting `level` and `status` with `Number(v)` is fine here; they are small enums like 0/1/2. Never convert IDs this way. + +### index.tsx + +```tsx +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useTable, type ColumnVisibilityState, type RowSelectionState } from '@tanstack/react-table' +import { useQuery } from '@tanstack/react-query' +import { IconPlus } from '@tabler/icons-react' + +import { Button } from '@admin/ui/components/button' +import { DataTable, DataTableColumnVisibility } from '@admin/ui/components/data-table' +import { QueryBar, countActive, type FilterField } from '@admin/ui/components/query-bar' + +import { Can } from '../../auth/can' +import { ConfirmDialog } from '../../shell/confirm-dialog' +import { PageHeader } from '../../shell/page-header' +import { BulkBar, RefreshButton, ResetButton } from '../_shared/filters' +import { listState } from '../_shared/list-query' +import { DEFAULT_PAGE_SIZE } from '../_shared/pagination' +import { useQuerySearch } from '../_shared/use-query-search' +import { customerKeys, customersQuery, useDeleteCustomers, type Customer, type CustomerListParams } from './api' +import { COLUMN_LABELS, buildColumns } from './columns' +import { CustomerFormSheet } from './form' +import { features } from './table-features' + +/** 可筛字段。`key` 和接口入参同名,不用 `param` 映射 */ +const FIELDS: FilterField[] = [ + { key: 'name', label: '客户名称', type: 'text', group: '基本信息', defaultVisible: true, placeholder: '模糊匹配' }, + { key: 'phone', label: '联系电话', type: 'text', group: '基本信息', defaultVisible: true, placeholder: '模糊匹配' }, + { + key: 'level', + label: '等级', + type: 'select', + group: '基本信息', + defaultVisible: true, + options: [ + { value: 0, label: '普通' }, + { value: 1, label: '重要' }, + { value: 2, label: 'VIP' }, + ], + }, + { + key: 'status', + label: '状态', + type: 'select', + group: '状态', + defaultVisible: true, + options: [ + { value: 1, label: '正常' }, + { value: 0, label: '停用' }, + ], + }, +] + +export type CustomerPageSearch = { + page?: number + size?: number + /** 摆开但还没填值的格子(见 `_shared/use-query-search`) */ + f?: string + name?: string + phone?: string + level?: number + status?: number +} + +/** + * 客户管理。router-独立(硬纪律 1):search 只从 props 进、改动经 `onSearchChange` 出, + * 筛选与分页全进 URL(硬纪律 2)。 + */ +export function CustomerPage({ + search = {}, + onSearchChange, +}: { + search?: CustomerPageSearch + onSearchChange?: (next: CustomerPageSearch) => void +}) { + const { t } = useTranslation() + const page = search.page ?? 1 + const size = search.size ?? DEFAULT_PAGE_SIZE + + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = React.useState({}) + + // 换页 / 换筛选清掉选中:分页在服务端,留着的选中项已不在可见行里 + const patch = React.useCallback( + (next: Partial) => { + setRowSelection({}) + onSearchChange?.({ ...search, ...next }) + }, + [onSearchChange, search] + ) + + const q = useQuerySearch({ fields: FIELDS, search, onSearchChange, refreshKey: customerKeys.all }) + const submitQuery = React.useCallback( + (v: Parameters[0]) => { + setRowSelection({}) + q.submit(v) + }, + [q] + ) + const clearFilters = React.useCallback(() => { + setRowSelection({}) + q.reset() + }, [q]) + + const listQuery = useQuery(customersQuery({ page, size, ...q.params } as CustomerListParams)) + const { data, isFetching } = listQuery + // loading / busy / error / onRetry 一次摊开 —— 接口挂了显示错误块,不是「暂无数据」(硬纪律 9) + const list = listState(listQuery, { onBeforeRefetch: () => setRowSelection({}) }) + const rows = data?.items ?? [] + const total = data?.total ?? 0 + + const [sheetOpen, setSheetOpen] = React.useState(false) + const [editing, setEditing] = React.useState(null) + const [pendingDelete, setPendingDelete] = React.useState(null) + const [bulkOpen, setBulkOpen] = React.useState(false) + const del = useDeleteCustomers() + + const columns = React.useMemo( + () => + buildColumns( + (c) => { + setEditing(c) + setSheetOpen(true) + }, + setPendingDelete, + t + ), + [t] + ) + + const table = useTable({ + features, + data: rows, + columns: columns as never, + state: { columnVisibility, rowSelection }, + getRowId: (row) => row.id, + manualPagination: true, + rowCount: total, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + }) + + const selectedIds = Object.keys(rowSelection).filter((k) => rowSelection[k]) + const hasFilter = countActive(q.applied, FIELDS) > 0 + + return ( + + + + + + + + { + setEditing(null) + setSheetOpen(true) + }} + > + + {t('新增客户')} + + + + + + setBulkOpen(true)} /> + + > + } + /> + + + + ) : undefined + } + {...list} + pagination={{ + pageIndex: page - 1, + pageCount: data?.total_pages ?? 1, + pageSize: size, + selectedCount: selectedIds.length, + totalCount: total, + onPageChange: (i) => patch({ page: i === 0 ? undefined : i + 1 }), + onPageSizeChange: (s) => patch({ size: s, page: undefined }), + }} + /> + + + + + + + !o && setPendingDelete(null)} + title={t('删除客户')} + description={pendingDelete ? t('确定删除「{{name}}」吗?', { name: pendingDelete.name }) : ''} + confirmText={t('删除')} + destructive + pending={del.isPending} + onConfirm={async () => { + if (!pendingDelete) return + try { + await del.mutateAsync([pendingDelete.id]) + } finally { + setPendingDelete(null) + } + }} + /> + !o && setBulkOpen(false)} + title={t('批量删除客户')} + description={t('确定删除选中的 {{n}} 个客户吗?', { n: selectedIds.length })} + confirmText={t('删除')} + destructive + pending={del.isPending} + onConfirm={async () => { + try { + await del.mutateAsync(selectedIds) + setRowSelection({}) + } finally { + setBulkOpen(false) + } + }} + /> + + ) +} +``` + +Three things in the page must stay as they are: + +- The page component gets `search` / `onSearchChange` **only** through props and never calls `Route.useSearch()` / `useNavigate()`. Multi-tab keeps every open tab mounted at once, and hidden tabs have no route match context +- Fetch state comes from `listState()` in one go. If you destructure only `isPending` / `isFetching` and leave out `error`, a failed request shows "no data", which looks exactly like a genuinely empty list +- Clear `rowSelection` on search, reset and refresh. Otherwise bulk delete hits rows the user can no longer see + +### The route file + +`apps/web/src/routes/_auth/crm/customer.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router" +import { z } from "zod" + +import { requirePerm } from "@admin/platform/auth/guards" + +const searchSchema = z.object({ + page: z.coerce.number().int().min(1).optional(), + size: z.coerce.number().int().min(1).optional(), + /** 摆开但还没填值的格子 */ + f: z.string().optional(), + name: z.string().optional(), + phone: z.string().optional(), + level: z.coerce.number().int().optional(), + status: z.coerce.number().int().optional(), +}) + +/** 页面由 TabOutlet 挂载;这里只声明 search schema / staticData / 权限(硬纪律 3) */ +export const Route = createFileRoute("/_auth/crm/customer")({ + validateSearch: searchSchema, + beforeLoad: requirePerm("crm:customer:list"), + staticData: { title: "客户管理" }, + component: () => null, +}) +``` + +The route path must match the `path` on the menu (`/crm/customer`). Otherwise Menus marks the entry as a dead link and the sidebar does not show it. + +If you later put a snowflake ID in search (such as `?owner=…`), declare it as `z.coerce.string()`, not `z.coerce.number()`. + +`apps/web/src/routeTree.gen.ts` is generated by the TanStack Router plugin. After adding a route file, start the frontend dev server once (`pnpm --filter web dev`) and it updates the file. Until then, `createFileRoute("/_auth/crm/customer")` is a type error. + +### Register the page + +`apps/web/src/lib/page-registry.tsx`: + +```tsx +import { CustomerPage } from "@admin/platform/pages/customer" + +export const pageRegistry: PageRegistry = { + // … + "/_auth/crm/customer": CustomerPage, +} +``` + +`@admin/platform/pages/*` is already in the `exports` of `packages/platform/package.json`, and `apps/web` already depends on `@admin/platform`, so no `package.json` change is needed here. + +### Strings + +Every `t('…')` key in the page needs an entry in both `packages/i18n/src/locales/zh-CN.json` and `en-US.json`. In the Chinese file the key and the value are the same; the English file holds the translation. Add the menu title too: `"menu:/crm/customer": "客户管理"` (`"Customers"` in English). Then: + +```bash +pnpm i18n:fix # sort keys +pnpm i18n:check # must have no missing-keys +pnpm i18n:jsx # must have no bare Chinese JSX text +``` + +**Check**: + +```bash +pnpm typecheck --force # --force: a turbo cache hit prints the previous run's log +pnpm arch:check # dependency direction, multi-tab rules, registry +pnpm lint +``` + +Once all of these pass, sign in as the superuser: the sidebar shows 客户关系 → 客户管理 (CRM → Customers), and the list shows the customer created in step 6. + +:::note +All the commands above were actually run and passed. What it looks like in the browser (sidebar, form interaction) was not confirmed with screenshots during verification, so click through it yourself the first time. +::: + +## Step 9: make data scopes take effect + +Data scopes need no code: they were wired in when the DAO inherited `DataScopedCRUD` in step 4. This step configures them in the UI and confirms they actually work. For how they work, see [Data permissions](/en/features/data-permissions/). + +1. **System → Roles**: create a role (say "Sales"). Under Permissions, tick 客户管理 (Customers) and its 4 buttons. Under Data scope, choose the seeded scope 本部门数据权限 (own department), whose rule is `__dept_id__ == ${dept_id}` on the `__ALL__` model +2. **System → Users**: create a user in the department 财务人事中心 (Finance & HR) with the role "Sales". Then open that user's Permissions & security and turn on Admin sign-in; otherwise every write they make is rejected (see Common snags) +3. Sign in as that user + +**Check** (measured results): + +| Action | Superuser | "Sales" (Finance & HR) | +|---|---|---| +| `GET /crm/customers` | Sees both rows | Sees only the row whose `dept_id` is Finance & HR | +| `GET /crm/customers/{id of the HQ row}` | 200 | **404** | +| `PUT` on the HQ row | 200 | **404**; the row is unchanged | +| `DELETE` on the HQ row | — | 200, but **no row is deleted** (`get_visible_ids` filtered it out) | +| `POST` without `dept_id` | — | The new customer belongs to Finance & HR; `owner_id` and `created_by` are this user | + +What about the other seeded demo roles? + +- `VIEWER`'s "own data only" rule is `__created_by__ == ${user_id}`. The table has a `created_by` column, so this rule applies to customers +- Both of `MANAGER`'s rules are on the `Dept` model, which a customer query does not involve, so `MANAGER` sees every customer. A rule applies only to the model it is written for + +These two points are inferred from the code of `filter_data_permission_for_user`; neither role was signed in and tested during verification. + +:::caution +When an `__ALL__` rule meets a table without the matching column, that rule is skipped. If every rule of a role is skipped, the result is **allow everything**. So if customers should be filtered by department, the table must have a `dept_id` column and new rows must fill it in. +::: + +## Step 10: write tests + +First add the new app's tests to `testpaths` in `apps/api/pyproject.toml`. Otherwise `pnpm test` does not collect them, and nothing tells you: + +```toml +testpaths = [ + "backend/app/admin/tests", + "backend/app/task/tests", + "backend/app/crm/tests", + "backend/plugin", + "backend/tests" +] +``` + +`apps/api/backend/app/crm/tests/test_customer.py` (with an empty `__init__.py` next to it): + +```python +"""客户管理:接口走一遍 CRUD,再验受限用户碰不到范围外的客户""" + +import asyncio + +from types import SimpleNamespace + +import pytest + +from starlette.testclient import TestClient + +from backend.app.crm.schema.customer import DeleteCustomerParam, UpdateCustomerParam +from backend.app.crm.service.customer_service import customer_service +from backend.common.exception import errors +from backend.common.security.data_scope import set_current_user +from backend.database.db import create_database_async_engine, create_database_async_session, get_database_url + +BASE = '/crm/customers' + + +def _restricted() -> SimpleNamespace: + """开了范围过滤、一条规则都没有 —— fail-closed,什么都看不见""" + scope = SimpleNamespace(status=1, rules=[]) + return SimpleNamespace( + is_superuser=False, id=1, dept_id=1, roles=[SimpleNamespace(status=1, is_filter_scopes=True, scopes=[scope])] + ) + + +def _as_restricted(fn): # noqa: ANN001, ANN202 + async def go(): # noqa: ANN202 + engine = create_database_async_engine(get_database_url(unittest=True)) + try: + async with create_database_async_session(engine).begin() as db: + set_current_user(_restricted()) + try: + return await fn(db) + finally: + set_current_user(None) + finally: + await engine.dispose() + + return asyncio.run(go()) + + +@pytest.fixture +def customer(client: TestClient, token_headers: dict[str, str]): # noqa: ANN201 + name = 'pytest-客户' + res = client.post(BASE, headers=token_headers, json={'name': name, 'level': 1}) + assert res.json()['code'] == 200, res.text + items = client.get(BASE, headers=token_headers, params={'name': name}).json()['data']['items'] + assert len(items) == 1 + yield items[0] + client.request('DELETE', BASE, headers=token_headers, json={'pks': [int(items[0]['id'])]}) + + +def test_crud(client: TestClient, token_headers: dict[str, str], customer: dict) -> None: + pk = customer['id'] + assert isinstance(pk, str) # 雪花 ID 下发成字符串(硬纪律 6) + assert customer['created_by'] == customer['owner_id'] # 不传负责人 = 创建人 + + res = client.put(f'{BASE}/{pk}', headers=token_headers, json={'name': 'pytest-客户-改', 'level': 2}) + assert res.json()['code'] == 200, res.text + detail = client.get(f'{BASE}/{pk}', headers=token_headers).json()['data'] + assert detail['level'] == 2 + assert detail['dept_id'] == customer['dept_id'] # 没传的字段不能被冲成 NULL + + res = client.request('DELETE', BASE, headers=token_headers, json={'pks': [int(pk)]}) + assert res.json()['code'] == 200 + assert client.get(f'{BASE}/{pk}', headers=token_headers).status_code == 404 + + +def test_restricted_user_cannot_touch_rows_outside_scope( + client: TestClient, token_headers: dict[str, str], customer: dict +) -> None: + pk = int(customer['id']) + + with pytest.raises(errors.NotFoundError): + _as_restricted(lambda db: customer_service.update(db=db, pk=pk, obj=UpdateCustomerParam(name='越权'))) + assert _as_restricted(lambda db: customer_service.delete(db=db, obj=DeleteCustomerParam(pks=[pk]))) == 0 + + # 超管视角:那一行还在、名字没被改 + assert client.get(f'{BASE}/{pk}', headers=token_headers).json()['data']['name'] == 'pytest-客户' +``` + +Tests run against a separate database, `_test` (`conftest.py` overrides `get_db`). Rebuild it first; this creates the tables from the current models, loads the seed and stamps it at head: + +```bash +pnpm --filter api test:db +cd apps/api && uv run --no-sync pytest backend/app/crm/tests -rA +``` + +**Check**: `2 passed`. Both tests were checked in reverse: removing `get_visible_ids` from the service makes the second test fail with `assert 1 == 0`, and removing `exclude_unset=True` makes the first one fail because `dept_id` becomes `None`. + +Finally, run the guard tests related to this change: + +```bash +cd apps/api && uv run --no-sync pytest \ + backend/app/task/tests/test_migrations.py \ + backend/app/admin/tests/security/test_permission_codes.py \ + backend/app/admin/tests/security/test_data_scope_coverage.py \ + backend/app/admin/tests/api_v1/test_column_length_gate.py \ + backend/tests/test_seed_dialects.py backend/tests/test_i18n.py +``` + +## Common snags + +| Symptom | Cause and fix | +|---|---| +| A non-superuser can list and view, but every create, update and delete returns 403 with "用户已被禁止后台管理操作" (admin operations disabled) | Requests other than `GET` / `OPTIONS` also require `is_staff`. Turn on Admin sign-in under Users → Permissions & security. The switch's description says it controls signing in, but with it off the user can still sign in and read; only writes are blocked | +| A non-superuser clicks a button and nothing happens; the endpoint returns 403 | The permission code is not on any menu of the user's roles. Add the button row to the menu and tick it under the role's Permissions. `test_permission_codes.py` catches "the backend uses this code but no seed menu has it" | +| A user cannot see a record but can update or delete it by ID | Data scopes only apply to reads; `update_model_by_column` / `delete_model_by_column` are not filtered. Narrow the targets with `get()` / `get_visible_ids()` before writing | +| The role has "own department", but the user still sees every customer | The table has no `dept_id` column, so the `__ALL__` rule is skipped and everything is allowed. Or the rule is on another model (such as `Dept`) and does not apply to this table | +| New customers are invisible to everyone except the superuser | `dept_id` was not filled in on create, and `dept_id = ${dept_id}` never matches NULL. Give it a default in the service | +| After renaming a customer, its department is empty | The `PUT` used `model_dump()`, which also writes fields that were not sent, as `None`. Use `model_dump(exclude_unset=True)` | +| The endpoint returns 500 and the log shows a database error such as string truncation | The write parameters do not mix in `ColumnLengthChecked`, so an over-long value reached the database. Add the mixin and set `__sa_model__` | +| After a model change, the database says the table or column does not exist | No migration was generated, or it was not applied with `pnpm db:upgrade`. `--reload` does not change tables | +| On SQL Server, inserts into the new table fail with `Cannot insert explicit value for identity column` | The migration's `id` column lacks `autoincrement=False` and was created as IDENTITY. Add it yourself in hand-written create-table migrations | +| autogenerate produced a list of `drop_table` calls | `import backend.main` was removed from `alembic/env.py`, so the metadata is empty. Do not commit that migration | +| New environments (`fba init`) have no Customers menu, or upgraded ones don't | The menus were added only to the data migration, or only to the seed SQL. Add them to both, then run `seed:manifest --write` | +| After adding a column to an existing table, initializing a new environment fails | That table's seed SQL is an INSERT with explicit column names; update `init_snowflake_test_data.sql` for all three dialects | +| IDs in the list do not match the database; editing A changes B | The frontend ran `Number()` on a snowflake ID and lost precision. Treat IDs as strings everywhere and use `z.coerce.string()` in search params | +| Switching away from a tab and back loses the filters, or a hidden tab throws | The page component calls `Route.useSearch()` / `useNavigate()`. Take them through props instead; `pnpm arch:check` checks this | +| The list shows "no data" when the request failed | `listState()` was not used and `error` was dropped. Spread `{...list}` into `DataTable` | +| Menus marks the entry as a dead link | The menu's `path` does not match the frontend route path. The route file and `page-registry.tsx` also have to come in pairs; `pnpm arch:check` checks the registry | +| `pnpm i18n:check` reports `missing-keys` | New strings were not added to both locale files | +| You wrote tests and `pnpm test` is green, but they never ran | The new app's `tests/` directory is not in `testpaths` in `pyproject.toml` | +| After splitting the module into a new workspace package, the production image build fails with `Cannot find module` | Only an alias / tsconfig path was added; the package was not declared in the dependent's `package.json`. `pnpm arch:check` checks this | + +## Related source + +- [`backend/common/security/data_scope.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/security/data_scope.py): `DataScopedCRUD` and which read methods it overrides +- [`backend/common/security/permission.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/security/permission.py): how rules compile into a `WHERE` condition +- [`backend/common/security/rbac.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/security/rbac.py): `rbac_verify` +- [`backend/common/model.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/model.py): `Base`, `id_key`, `UniversalStr` +- [`backend/common/schema.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/schema.py): `ColumnLengthChecked` +- [`backend/utils/data_migration.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/utils/data_migration.py): `add_menu` / `grant_menu` +- [`backend/plugin/dict/`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/plugin/dict/): a complete, simple three-layer module +- [`backend/app/router.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/app/router.py): where app routers are mounted +- [`backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md): rules for migrations and seed data +- [`packages/platform/src/pages/notice/`](https://github.com/yilecoding/fastapi-react-admin/blob/main/packages/platform/src/pages/notice/): the reference list page +- [`packages/platform/src/pages/_shared/list-query.ts`](https://github.com/yilecoding/fastapi-react-admin/blob/main/packages/platform/src/pages/_shared/list-query.ts): `listState()` +- [`packages/platform/src/pages/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/packages/platform/src/pages/AGENTS.md): rules for list pages +- [`apps/web/src/lib/page-registry.tsx`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/web/src/lib/page-registry.tsx): the page registry diff --git a/apps/docs/src/content/docs/en/reference/faq.md b/apps/docs/src/content/docs/en/reference/faq.md new file mode 100644 index 00000000..02f2868c --- /dev/null +++ b/apps/docs/src/content/docs/en/reference/faq.md @@ -0,0 +1,286 @@ +--- +title: FAQ and troubleshooting +description: Troubleshooting indexed by the exact error text — 25 common problems with local setup, sign-in, database migrations, frontend work and production deploys. +sidebar: + order: 1 +--- + +Copy the error text from your terminal or browser and find it on this page with Ctrl+F or the site search. Each entry gives the cause, the fix, and the source note in the repository it comes from. + +## Installation and local setup + +### `Port 8888 is already in use` + +**Cause**: the frontend port is fixed at 8888 with `strictPort: true` in `apps/web/vite.config.ts`. When the port is taken, Vite exits instead of moving to 8889. It does this on purpose: the backend CORS allow-list, the OAuth2 redirect URIs and the desktop dev script all have 8888 hard-coded. + +**Fix**: find the process holding 8888 (usually a `pnpm dev` that didn't shut down cleanly), stop it, and start again. If you really need another port, change four places together: `vite.config.ts`, `CORS_ALLOWED_ORIGINS` in `backend/core/conf.py`, the two `OAUTH2_FRONTEND_*_REDIRECT_URI` entries in `backend/plugin/oauth2/plugin.toml`, and the `DEV_SERVER_URL` default in `apps/desktop/scripts/dev.mjs`. + +```bash +lsof -i :8888 # who is holding the port +``` + +Source: [`CLAUDE.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CLAUDE.md), section 「本地起服务」 (running locally) + +### `Can't open lib 'ODBC Driver 18 for SQL Server'` + +**Cause**: with SQL Server, the backend connects through `aioodbc` using the ODBC driver **on the host**. The driver inside the SQL Server container doesn't count, so the container runs fine and the backend fails on its first connection. + +**Fix**: install [Microsoft ODBC Driver 18](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server) on the machine that runs the backend. `DATABASE_DRIVER` in `.env` (default `ODBC Driver 18 for SQL Server`) must match a name printed by the command below exactly. The production image `apps/api/Dockerfile.prod` already installs `msodbcsql18`, and the worker and beat use the same image. + +```bash +odbcinst -q -d +``` + +Source: [`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md), [`apps/api/Dockerfile.prod`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/Dockerfile.prod) + +### `Login failed for user 'sa'` (SQL Server error 18456) + +**Cause**: `DATABASE_PASSWORD` in `.env` doesn't match the SQL Server instance's real password. The usual trigger is changing `MSSQL_SA_PASSWORD` in `docker-compose.dev.yml`: SQL Server keeps its data in a persistent volume, and an instance that has already been initialized keeps its old password. + +**Fix**: set `DATABASE_PASSWORD` to the instance's current sa password. If you really want the new password, delete the data volume so the instance initializes again. When `pnpm db:init` hits this error it prints 「数据库登录失败:…(SQL Server 错误 18456)」. + +Source: [`apps/api/backend/cli.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/cli.py) (`_format_database_connection_error`) + +### `ModuleNotFoundError` from `pnpm test` or `pnpm db:upgrade` after pulling, although `pnpm dev` works + +**Cause**: `uv` manages the Python dependencies, so `pnpm install` doesn't install them. `dev`, `db:init` and `db:reset` use a bare `uv run`, which syncs dependencies automatically. `test`, `test:db`, `db:upgrade`, `e2e:server` and `celery:*` pass `--no-sync`, so they fail as soon as a package is missing. A working `pnpm dev` therefore doesn't prove the dependencies are complete. + +**Fix**: sync once whenever `pyproject.toml` has changed. + +```bash +pnpm --filter api exec uv sync # or pnpm install:all +``` + +Source: [`apps/api/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/AGENTS.md) + +### Worker log shows `Received unregistered task of type '…'` and your own tasks sometimes vanish + +**Cause**: the Celery broker defaults to Redis database 1 (`CELERY_BROKER_REDIS_DATABASE=1`). If another project's Celery on the same machine uses database 1 of the same Redis, both sets of workers consume one queue. Their tasks land on your worker and show up as "unregistered"; your tasks land on theirs and disappear without a trace. + +**Fix**: point `CELERY_BROKER_REDIS_DATABASE` in your local `.env` at an empty database and restart the worker. Check first that nobody uses it: + +```bash +docker exec fba_redis redis-cli -n 7 DBSIZE # must return 0 +``` + +Source: [`apps/api/backend/app/task/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/app/task/AGENTS.md) + +## Sign-in and sessions + +### Signing in at `http://localhost:8888` works, but reloading the page sends you back to the sign-in page + +**Cause**: the frontend calls `http://127.0.0.1:8088` directly by default (`API_BASE` in `packages/platform/src/api-client/client.ts`). When the page is opened from `localhost`, that is a cross-site request, and the browser doesn't store the refresh-token cookie. Sign-in itself succeeds, but after a full reload `/auth/refresh` returns 401. + +**Fix**: open for local development, not `localhost:8888`. + +Source: [`apps/docs/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/docs/AGENTS.md) + +### `admin` / `123456` doesn't work after `pnpm db:init` + +**Cause**: after loading the seed data, `fba init` forces the seeded admin password `123456` to be replaced. In a terminal it asks you for a new password. In a non-interactive run (`--auto`, CI, no TTY) it reads the `FBA_INIT_ADMIN_PASSWORD` environment variable and fails if that is missing. It also disables the seeded demo account `test` and gives it a random password. + +**Fix**: sign in with the password you set during initialization. For a non-interactive run: + +```bash +FBA_INIT_ADMIN_PASSWORD='' pnpm db:reset +``` + +`123456` only works in the `fba_test` database used by pytest and E2E, which `pnpm --filter api test:db` builds separately. + +Source: [`apps/api/backend/cli.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/cli.py) (`_set_admin_password`) + +### `Too many requests, please try again later` (HTTP 429) on sign-in + +The Chinese UI shows 「请求过于频繁,请稍后重试」. + +**Cause**: the sign-in endpoints are rate-limited per IP and path to 5 requests a minute; the captcha endpoint `/auth/captcha` allows 5 every 30 seconds. A few wrong passwords in a row, or a script that signs in to several accounts, hits the limit quickly. The `Retry-After` response header says how many seconds to wait. + +**Fix**: wait and try again. A rate-limited attempt doesn't consume the captcha, so the page keeps the current captcha image and you can reuse it. When you seed a lot of data locally, you can start the backend with `REQUEST_LIMITER_ENABLED=false` for a while (production refuses this setting). + +Source: [`apps/docs/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/docs/AGENTS.md), [`apps/api/backend/app/admin/api/v1/auth/auth.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/app/admin/api/v1/auth/auth.py) + +### `LOGIN_CAPTCHA_ENABLED=false` is set in `.env`, but sign-in still asks for a captcha + +**Cause**: at runtime this switch is read from the `sys_config` table. The seed data includes a `LOGIN_CAPTCHA_ENABLED` row, and the backend loads it over whatever `.env` says. + +**Fix**: change `LOGIN_CAPTCHA_ENABLED` to `false` under System → Parameters (「系统管理 → 参数配置」); it takes effect immediately. To keep the captcha and still sign in locally, note the `uuid` from the `GET /api/v1/auth/captcha` response and read the answer from Redis: + +```bash +docker exec fba_redis redis-cli --raw GET "fba:login:captcha:" +``` + +Source: [`apps/web/e2e/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/web/e2e/AGENTS.md), [`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md) + +## Database and migrations + +### All existing data is gone after `pnpm db:init` + +**Cause**: `pnpm db:init` (`fba init`) runs `drop_all` and then `create_all`, loads the seed data and stamps Alembic at head. It is meant for a fresh environment and asks for confirmation first (the default answer is `n`). + +**Fix**: run it only for a first setup or when you really want to wipe the data. To update the schema of an existing database, migrate: + +```bash +pnpm db:upgrade +``` + +Source: [`apps/api/backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md), [`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md) + +### `种子数据里没有 admin 账号,初始化流程可能已经变了` (no admin account in the seed data) + +**Cause**: `DATABASE_PK_MODE` isn't `'snowflake'`. The seed SQL for all three databases only exists in the snowflake-ID form (`init_snowflake_test_data.sql`). In autoincrement mode no seed file is found and the step is skipped without a message. The database ends up with no admin account, and init then fails when it tries to set the admin password. The default for this setting in `core/conf.py` is `'autoincrement'`, so an `.env` without this line hits the same problem. + +**Fix**: put `DATABASE_PK_MODE='snowflake'` in `apps/api/backend/.env` (`.env.example` already has it) and run `pnpm db:init` again. + +Source: [`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md), [`apps/api/backend/cli.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/cli.py) + +### Changed a model and the backend reloaded, but the table didn't change + +**Cause**: `--reload` in `pnpm dev` only restarts the process and re-imports the models. It never alters existing tables. + +**Fix**: after changing a model, generate a migration, read the generated file, then upgrade: + +```bash +pnpm db:revision 'add xxx column' +pnpm db:upgrade +``` + +If you changed columns, also update `backend/sql/*/init_snowflake_test_data.sql`. Those INSERTs list their columns explicitly, and a stale one breaks initialization of new environments. + +Source: [`CLAUDE.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CLAUDE.md), [`apps/api/backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md) + +### `Cannot insert explicit value for identity column in table '…' when IDENTITY_INSERT is set to OFF. (544)` + +**Cause**: this only happens on SQL Server, and only on tables **created by a migration**. Alembic autogenerate can't render the model's Python-side `default=snowflake.generate`. The id column it writes gets created as `IDENTITY` on SQL Server, and every INSERT fails after that. Tables built by `create_all` don't have this problem, so development and test databases never show it. + +**Fix**: in any migration that creates a table, declare the snowflake primary key with `autoincrement=False`. The guard test `test_created_tables_declare_snowflake_pk_as_non_autoincrement` checks the `create_table` calls in `alembic/versions/` for it. + +Source: [`apps/api/backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md) + +### The first pytest or E2E run fails with a database connection error + +**Cause**: both test suites run against a separate `fba_test` database, not the development database `fba`. It has to be created once by hand; until then the only symptom is a SQLAlchemy connection error that says nothing about the cause. + +**Fix**: create an empty `fba_test` database first (the script won't, because `CREATE DATABASE` has to run from another database), then let the script build the tables and load the seed data: + +```bash +docker exec fba_postgres createdb -U postgres fba_test # default PostgreSQL; once only +pnpm --filter api test:db # rebuild tables from the models, seed, stamp head +pnpm test +``` + +If tests later fail with errors like `Invalid column name …` after a model change, run `test:db` again. + +Source: [`apps/api/backend/tests/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/tests/AGENTS.md), [`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md) + +## Frontend development + +### ID `2049629108245233664` became `2049629108245233700` and an update or delete hit the wrong record + +**Cause**: snowflake IDs are around 2^61, larger than JavaScript's `Number.MAX_SAFE_INTEGER` (2^53-1). Parsing one as a number loses precision, and consecutive IDs can collapse into the same value, so sending it back targets a different record. Route parameters have the same problem, because TanStack Router runs `JSON.parse` on search values by default. + +**Fix**: treat every ID as a string and never call `Number()` on it. The backend already sends IDs as strings (`backend/utils/serializers.py`), and `apps/web/src/lib/search-params.ts` keeps out-of-range integers in the URL as strings. Before adding a search parameter that carries an ID, check that this custom parser is still wired into `router.ts`. + +Source: [`CLAUDE.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CLAUDE.md), hard rule 6 + +### `pnpm e2e -- tests/xxx.spec.ts` runs the whole suite instead of one file + +**Cause**: pnpm passes the `--` through to Playwright, which then ignores the file name and the arguments after it. The full suite runs (more than ten minutes), and it looks as if that one file is just slow. + +**Fix**: drop the `--` and run it from `apps/web`: + +```bash +cd apps/web && pnpm e2e tests/dept-crud.spec.ts +``` + +Source: [`apps/web/e2e/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/web/e2e/AGENTS.md) + +### Docs build fails with `Node.js v22.23.3 is not supported by Astro!` + +**Cause**: the docs site (`apps/docs`) is locked to Astro 7.3.x. `astro@7.4.4` needs Node ≥ 22.23.4, while the repository's `engines` field says ≥ 22.13. If the dependency was bumped to 7.4 (for example by `pnpm update`), older Node 22 releases fail with this message. `pnpm build` builds the docs site through turbo, so the whole-repo build fails as well. + +**Fix**: go back to the version in the lockfile (`pnpm install --frozen-lockfile`) or upgrade Node to 22.23.4 or later. Upgrading Astro for real means deciding on the `engines` field in the root `package.json` and the Node version in CI first. + +Source: [`apps/docs/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/docs/AGENTS.md) + +## Deployment and operations + +### `ENVIRONMENT=prod,但有 N 项配置不合格,拒绝启动` (production config check failed) + +**Cause**: with `ENVIRONMENT=prod`, the backend validates its configuration before starting and refuses to start if anything fails. The checks cover the strength of `TOKEN_SECRET_KEY`, `DATABASE_PASSWORD` and `REDIS_PASSWORD`; `LOGIN_CAPTCHA_ENABLED` and `REQUEST_LIMITER_ENABLED` must be true; `CORS_ALLOWED_ORIGINS` must not contain `*`, `localhost` or `127.0.0.1`; and `DATABASE_USER` must not be a superuser such as `sa`, `root` or `postgres`. + +**Fix**: fix `.env.server` item by item, following the numbered list in the error. To generate a signing key: + +```bash +python -c "import secrets;print(secrets.token_urlsafe(32))" +``` + +Source: [`apps/api/backend/core/conf.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/core/conf.py) + +### `ENVIRONMENT=prod,数据库检查未通过,拒绝启动` (production database check failed) + +**Cause**: in production the backend never creates tables itself. At startup it checks the database, and three things stop it: + +- `数据库里没有 alembic_version 表` (no `alembic_version` table): the database was built by `create_all` but never stamped. +- `数据库迁移版本 … 不等于 head` (migration version is not head): the migrations weren't run before deploying the new version. +- `这些账号仍在使用种子数据里的默认密码(123456)` (accounts still use the seeded password 123456): the seed SQL was loaded directly instead of through `fba init`. + +**Fix**: respectively, build the database with `fba init` (it stamps head automatically) or run `alembic stamp ` on an existing one; run `alembic upgrade head` before starting; change the passwords of the listed accounts. + +Source: [`apps/api/backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md), [`apps/api/backend/core/registrar.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/core/registrar.py) + +### `PermissionError: [Errno 13] Permission denied` while the container loads its config + +**Cause**: the image runs as user `fba` (uid 1000), and the deploy check requires `.env.server` to be exactly mode `600`. If root owns the file, the permissions look fine on the host, but the process in the container can't read it. The error is raised deep inside pydantic-settings, so it is easy to mistake for a typo in the configuration. + +**Fix**: + +```bash +chown 1000:1000 .env.server +chmod 600 .env.server +``` + +Source: [`deploy/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/AGENTS.md) + +### The API container is up, but its health check can never reach the database + +**Cause**: the database or Redis runs on the host (as with the ones bundled with the BT/aaPanel control panel), and `.env.server` says `127.0.0.1`. On the container's bridge network, `127.0.0.1` is the container itself. + +**Fix**: set `DATABASE_HOST` / `REDIS_HOST` to the host's address on the docker bridge (usually `172.17.0.1`; check with `ip addr show docker0`), or add `extra_hosts: ["host.docker.internal:host-gateway"]` to the service and use `host.docker.internal`. + +Source: [`deploy/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/AGENTS.md) + +### `docker compose pull` hangs on a server in mainland China + +**Cause**: GHCR is often throttled to a few dozen KB/s from mainland China. Nothing errors out; it looks stuck, but one layer takes hours. + +**Fix**: set `FBA_PULL_MIRROR` (a mirror domain) in the `.env` next to the compose file. `prod.sh pull` then pulls from the mirror and retags the images back to `ghcr.io/…`. Don't put the mirror into the image names in the compose file. If the layer progress bars don't move for a few minutes, retry with another mirror. + +Source: [`deploy/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/AGENTS.md) + +### `/healthz` stays green while the API is down + +**Cause**: `/healthz` is answered by the nginx inside the web container with a plain `return 200`; it only proves nginx is alive. Probing a path that doesn't exist through the domain is no better: the frontend's history fallback answers it with 200 and `index.html`. + +**Fix**: point external monitoring only at `https:///api/health/status`. It reports api, database, Redis, scheduler and worker one by one, returning 200 when all are healthy and 503 when any one is not. Look at the response body, not just the status code. + +Source: [`deploy/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/AGENTS.md), [`deploy/README.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/README.md) + +### `Failed to fetch dynamically imported module` after a release + +**Cause**: this isn't a server error but a version mismatch. The open browser tab is still running the old frontend, and the code chunk it wants to load no longer exists in the new deployment. + +**Fix**: reload the page. The frontend recognizes this error and shows "A new version is available" with a reload button instead of a 500 page. + +Source: [`apps/web/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/web/AGENTS.md) + +### `Multiple artifacts named "github-pages" were unexpectedly found for this workflow run` + +**Cause**: re-running a failed job in GitHub Actions runs another attempt under the same run ID. `upload-pages-artifact` creates a new `github-pages` artifact every time instead of replacing the previous attempt's, and `deploy-pages` exits with this error when it finds two with the same name. + +**Fix**: delete this run's existing `github-pages` artifacts before uploading. The repository's CI `pages` job does exactly that (it needs the `actions: write` permission); copy that step if you deploy the docs site from a fork. + +Source: [`.github/workflows/ci.yml`](https://github.com/yilecoding/fastapi-react-admin/blob/main/.github/workflows/ci.yml) (`pages` job) + +## Didn't find your problem? + +Make sure you went through every step of the [quick start](/en/getting-started/quick-start/) and [deployment](/en/getting-started/deployment/) guides. If it still fails, open a [GitHub issue](https://github.com/yilecoding/fastapi-react-admin/issues) and paste the exact error text. diff --git a/apps/docs/src/content/docs/en/reference/upgrade.md b/apps/docs/src/content/docs/en/reference/upgrade.md new file mode 100644 index 00000000..30468d93 --- /dev/null +++ b/apps/docs/src/content/docs/en/reference/upgrade.md @@ -0,0 +1,110 @@ +--- +title: Upgrading +description: How to upgrade — image deployments pull new images and migrations run automatically; forks merge the upstream tag and run migrations. With compatibility promises and a checklist. +sidebar: + order: 2 +--- + +How you upgrade depends on how you use the project. If you **deploy the published images without changing the code**, pull the new images and restart; migrations run automatically. If you **forked it and built your own features on top**, merge the new upstream version into your repository, then run migrations and tests. Every release on [GitHub Releases](https://github.com/yilecoding/fastapi-react-admin/releases) has an "升级" (Upgrade) section saying whether there are database migrations or config changes. + +## How are versions numbered? + +Semver: a big feature bumps the minor version (0.11 → 0.12), fixes only bump the patch (0.9.0 → 0.9.1). It is still 0.x, but the compatibility rules below are already in force. + +## What is promised not to break? + +From "1.0 起的兼容约定" in the root [`CLAUDE.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CLAUDE.md). Breaking any of these requires a major version. + +| Surface | Promise | +|---|---| +| Open API `/api/open/v1/*` and the three SDKs | Paths, parameters, response fields, error codes and the signing algorithm are not removed or changed in meaning; only optional parameters and new fields are added. A real break gets `/api/open/v2/*`, and v1 stays | +| Webhook events | Event names, payload fields and signing stay; new events and fields may be added | +| Database migration chain | Any released version can `pnpm db:upgrade` to the latest; migrations that shipped in a tag are never edited — fixes are new migrations | +| Plugin interface | `plugin.toml` fields, the install flow and extension points keep working for third-party plugins | +| Config keys | `.env` keys and settings keys are not renamed; if one must be, old and new work side by side for one minor version | +| Internal API `/api/v1/*` | Not a public API, but installed desktop and mobile clients are respected: removals and path changes overlap old and new for one minor version | + +Anything not in the table (pages, components, internal exports of `packages/*`) can change between versions. If you edited those directly, expect merge conflicts there. + +The migration promise was tested: on 2026-10-31, a MySQL 8.4 database created by v0.0.1 was upgraded through the whole chain to the then-latest version with zero structural differences from the models. + +## How do I upgrade an image deployment? + +```bash +bash prod.sh status # which commit the server runs and how far behind main it is +bash prod.sh pull # pull the new images +bash prod.sh up # runs migrations first (one-shot migrate container), then starts the services +``` + +**Check**: `bash prod.sh status` shows the version you upgraded to, and `https:///api/health/status` returns 200. + +Back up the database first. If the new version misbehaves, roll back to an older image by commit tag: + +```bash +TAG=sha-abc1234 bash prod.sh pull +TAG=sha-abc1234 bash prod.sh up +``` + +:::caution +Rolling back the image **does not roll back the database**. If the new version brought migrations, make sure the old code runs on the new schema, or restore the backup first. Each release's "Upgrade" section says whether it has migrations. +::: + +All commands are in [Deploying to a server](/en/getting-started/deployment/). + +## How do I upgrade a fork with my own features? + +Add upstream as a remote and **merge a tag** (not `main`, which may contain unreleased changes): + +```bash +git remote add upstream https://github.com/yilecoding/fastapi-react-admin.git # once +git fetch upstream --tags +git merge v0.12.0 # the version you are upgrading to +``` + +After resolving conflicts: + +```bash +pnpm install:all # dependencies may have changed +pnpm db:upgrade # run upstream's new migrations +pnpm typecheck --force # don't trust the turbo cache here +pnpm test # backend tests (needs a local fba_test database, see CONTRIBUTING.md) +``` + +**Check**: `pnpm db:current` is at head, `pnpm typecheck --force` and the backend tests pass, and after `pnpm dev` you can sign in and open a few of your own pages. + +### What if the merge leaves two migration heads? + +If you wrote your own migrations and upstream added some in this version, the chain forks into two heads and `pnpm db:upgrade` reports multiple heads. Create a merge migration with Alembic: + +```bash +cd apps/api/backend +uv run alembic heads # confirm there are two +uv run alembic merge heads -m "merge upstream v0.12.0" +cd ../../.. && pnpm db:upgrade +``` + +The repository's guard test `test_single_head` also turns red when there are two heads. + +:::caution +**Don't edit upstream's migration files**, even when a conflict lands in one. Databases that already ran an old migration never run it again, so an edit only makes new installs differ from old ones — silently. Fix mistakes with a new migration. +::: + +### Where do conflicts usually happen? + +In files both you and upstream changed — typically the menu and permission seed data (`backend/sql/*/init_snowflake_test_data.sql`), the frontend page registry (`apps/web/src/lib/page-registry.tsx`), the sidebar and route files. Keeping your features in new files, directories or plugins instead of editing the foundation's files keeps future upgrades quiet. + +## How do the desktop and mobile apps upgrade? + +- Desktop: download the new installer from [Releases](https://github.com/yilecoding/fastapi-react-admin/releases) and install over the old one. In-app auto-update only runs when your pages call the update check — see [Desktop and mobile clients](/en/features/clients/) +- Mobile: rebuild and redistribute the app + +Old clients call the new backend with their old frontend, which is why internal API removals keep a one-minor-version overlap. + +## Checklist before upgrading + +- [ ] Read the "Upgrade" section of the target release (and of every release you skip) +- [ ] Back up the database +- [ ] Know whether this version has migrations or config changes +- [ ] Upgrade a test environment first and smoke-test it (sign in, a few core pages, one approval) +- [ ] Have the rollback image tag ready (`sha-`) +- [ ] After upgrading production, check that `/api/health/status` returns 200 diff --git a/apps/docs/src/content/docs/getting-started/deployment.md b/apps/docs/src/content/docs/getting-started/deployment.md index 3a61eb71..32394e50 100644 --- a/apps/docs/src/content/docs/getting-started/deployment.md +++ b/apps/docs/src/content/docs/getting-started/deployment.md @@ -46,6 +46,19 @@ bash prod.sh pull && bash prod.sh up 如果连的是已有数据库,第一次部署前先备份。 +## 上线前检查清单 + +- [ ] `.env.server` 里的 `CHANGE_ME` 全部换掉。`ENVIRONMENT='prod'` 时后端会逐项校验长度和随机性,弱值直接拒绝启动 +- [ ] `TOKEN_SECRET_KEY` 用 `python -c "import secrets;print(secrets.token_urlsafe(32))"` 新生成,不要从别的环境复制 +- [ ] `CORS_ALLOWED_ORIGINS` 写成你真实的访问地址;前面有 nginx 或负载均衡时配好 `TRUSTED_PROXIES`,否则限流和 IP 白名单看到的都是代理的地址 +- [ ] 用到 AI 助手、对象存储、群机器人、开放平台的,配好各自的加密密钥(`AI_ENCRYPT_SECRET_KEY`、`STORAGE_ENCRYPT_SECRET_KEY`、`NOTIFICATION_ENCRYPT_SECRET_KEY`、`OPEN_SECRET_KEY`)—— 没配时保存相应配置会直接报错,不会存明文 +- [ ] `NOTIFICATION_WEB_BASE_URL` 改成真实地址,否则邮件和群消息里的链接指向 `localhost:8888` +- [ ] 管理员密码是初始化时新设的强口令,没有沿用演示站的 `123456` +- [ ] 第一次部署到已有数据库前做了备份;设好定时备份 +- [ ] TLS 由前面的反代或负载均衡提供,HTTP 会跳转到 HTTPS +- [ ] 外部监控探的是 `/api/health/status`,不是 `/healthz` +- [ ] 记下当前镜像标签(`sha-<提交>`),出问题时知道回滚到哪 + ## 怎么更新、怎么回滚 ```bash diff --git a/apps/docs/src/content/docs/getting-started/quick-start.md b/apps/docs/src/content/docs/getting-started/quick-start.md index 237cffdd..cb892525 100644 --- a/apps/docs/src/content/docs/getting-started/quick-start.md +++ b/apps/docs/src/content/docs/getting-started/quick-start.md @@ -1,11 +1,11 @@ --- title: 快速开始 -description: 在本地跑起 fastapi-react-admin:Docker 起 PostgreSQL 和 Redis,五条命令,打开 localhost:8888 用 admin / 123456 登录。 +description: 在本地跑起 fastapi-react-admin:Docker 起 PostgreSQL 和 Redis,五条命令,初始化时给 admin 设密码,打开 127.0.0.1:8888 登录。 sidebar: order: 1 --- -五条命令在本地起全套:Docker 里的 PostgreSQL 和 Redis、8088 端口的 FastAPI 后端、8888 端口的 React 前端,外加一个 Celery worker。账号 `admin` / `123456`。 +五条命令在本地起全套:Docker 里的 PostgreSQL 和 Redis、8088 端口的 FastAPI 后端、8888 端口的 React 前端,外加一个 Celery worker。账号 `admin`,密码是 `pnpm db:init` 时你设的那个。 ## 需要装什么 @@ -19,11 +19,32 @@ sidebar: docker compose -f docker-compose.dev.yml up -d # PostgreSQL 16 + Redis pnpm install:all # JS 依赖、Python 依赖、git 钩子、Playwright Chromium cp apps/api/backend/.env.example apps/api/backend/.env # 默认值已经和上面的容器对齐 -pnpm db:init # 建表 + 灌种子数据(只在首次跑) +pnpm db:init # 建表 + 灌种子数据 + 给 admin 设新密码(只在首次跑) pnpm dev # api :8088 · web :8888 · celery worker + # 打开 http://127.0.0.1:8888 ``` -打开 ,用 `admin` / `123456` 登录。 +打开 ,用 `admin` 和你在 `pnpm db:init` 时设的密码登录。 + +:::note[为什么不是 admin / 123456] +种子数据里的 `123456` 不允许留到初始化完成:`pnpm db:init` 会让你给 admin 设一个新密码(非交互环境读环境变量 `FBA_INIT_ADMIN_PASSWORD`),同时停用示例账号 `test`。`admin` / `123456` 只是[在线演示](https://fra.wubunan.com/sign-in)的账号。 +::: + +:::caution[用 127.0.0.1,不要用 localhost] +前端默认直接请求 `http://127.0.0.1:8088`。从 `localhost:8888` 打开时,浏览器把这当成跨站请求,不保存刷新令牌的 cookie:登录能成功,**一刷新页面就回到登录页**。 +::: + +### 每一步怎么算成功 + +| 步骤 | 验收 | +|---|---| +| `docker compose … up -d` | `docker ps` 里 `fba_postgres` 和 `fba_redis` 都是 `healthy` | +| `pnpm install:all` | 没有报错地跑完,`apps/api/.venv` 目录已经生成 | +| `pnpm db:init` | 最后一行是「已把新库标记到迁移 head」;`pnpm db:current` 显示的版本带 `(head)` | +| `pnpm dev` | 打开登录页能看到**验证码图片** —— 验证码是后端发的,看到它才说明前后端通了 | +| 登录 | 进入工作台,顶部写着「你有 N 件事等着处理」 | + +卡在哪一步,先去[常见问题](/reference/faq/)里按报错原文搜。 :::caution `pnpm db:init` 会 DROP 并重建所有表,只在首次搭环境或想清空数据时跑;之后的表结构变化一律走 `pnpm db:upgrade`(Alembic)。 @@ -48,7 +69,7 @@ docker exec fba_redis redis-cli --raw GET "fba:login:captcha:" | **S3 兼容存储**(RustFS) | `docker compose -f docker-compose.dev.yml --profile s3 up -d rustfs` | 在「系统管理 → 存储管理」里新建存储前,`.env` 里要先配 `STORAGE_ENCRYPT_SECRET_KEY`(`openssl rand -hex 32`) | :::caution -`DATABASE_PK_MODE` 保持默认的 `'snowflake'`。种子数据只有雪花 ID 版,改成自增模式后初始化会**静默跳过**种子——表建好了,却没有 admin 账号。 +`DATABASE_PK_MODE` 保持 `.env.example` 里的 `'snowflake'`。种子数据只有雪花 ID 版,改成自增模式(或者 `.env` 里漏了这一行 —— 代码里的默认值是 `autoincrement`)时,初始化会报错退出:`种子数据里没有 admin 账号,初始化流程可能已经变了`。 ::: 三种数据库各自怎么支持、怎么测,见[数据库](/features/databases/)。 diff --git a/apps/docs/src/content/docs/guides/first-module.md b/apps/docs/src/content/docs/guides/first-module.md new file mode 100644 index 00000000..cc3afa12 --- /dev/null +++ b/apps/docs/src/content/docs/guides/first-module.md @@ -0,0 +1,1508 @@ +--- +title: 从零加一个业务模块 +description: 以「客户管理」为例,从模型、迁移、三层接口、菜单权限到列表页和数据权限,走通一个真实业务模块。 +sidebar: + order: 1 +--- + +这个仓库没有代码生成器。新业务模块照着现有模块的写法手写,每一层都有几条规则:漏了不报错,功能照样能跑,但权限、数据范围或迁移会出问题。 + +这一页用一个**客户管理**模块走完整条链: + +``` +模型 → Alembic 迁移 → schema / crud / service / api → 注册路由 + → 菜单与按钮权限码 → 前端列表页 → 数据权限 → 测试 +``` + +下面的代码都在一个干净检出上实际跑过:迁移在 PostgreSQL 上生成、升级、回滚过,接口用 curl 打过,`pnpm typecheck --force`、`pnpm arch:check`、`pnpm lint`、`pnpm i18n:check` 都通过,模块测试和仓库自带的守卫测试也都是绿的。没验证过的地方会单独说明。 + +开始前先把服务跑起来(见[快速开始](/getting-started/quick-start/)),并读一遍根目录 `CLAUDE.md` 的硬纪律 1–6。 + +## 模块长什么样? + +| 字段 | 类型 | 说明 | +|---|---|---| +| `name` | 字符串(64) | 客户名称,必填 | +| `phone` | 字符串(20) | 联系电话 | +| `level` | 整数 | 0 普通 · 1 重要 · 2 VIP | +| `status` | 整数 | 0 停用 · 1 正常 | +| `dept_id` | 雪花 ID | 所属部门。数据权限靠这一列落地 | +| `owner_id` | 雪花 ID | 负责人用户 | +| `created_by` | 雪花 ID | 创建人。「仅本人」规则靠这一列落地 | +| `remark` | 长文本 | 备注 | + +后端代码放在一个新 app 里:`apps/api/backend/app/crm/`,结构和 `app/task/` 一样。也可以做成插件(参考 `backend/plugin/dict/`),但插件多了 `plugin.toml`、安装和卸载这一套,普通业务模块用不上。 + +``` +apps/api/backend/app/crm/ +├─ __init__.py +├─ model/ __init__.py · customer.py +├─ schema/ __init__.py · customer.py +├─ crud/ __init__.py · crud_customer.py +├─ service/ __init__.py · customer_service.py +├─ api/ __init__.py · router.py · v1/__init__.py · v1/customer.py +└─ tests/ __init__.py · test_customer.py +``` + +## 第 1 步:写模型 + +`apps/api/backend/app/crm/model/customer.py`: + +```python +import sqlalchemy as sa + +from sqlalchemy.orm import Mapped, mapped_column + +from backend.common.model import Base, UniversalStr, UniversalText, id_key + + +class Customer(Base): + """客户表""" + + __tablename__ = 'crm_customer' + __table_args__ = {'comment': '客户表'} + + id: Mapped[id_key] = mapped_column(init=False) + name: Mapped[str] = mapped_column(UniversalStr(64), comment='客户名称') + phone: Mapped[str | None] = mapped_column(UniversalStr(20), default=None, comment='联系电话') + level: Mapped[int] = mapped_column(default=0, comment='客户等级(0:普通、1:重要、2:VIP)') + status: Mapped[int] = mapped_column(default=1, comment='状态(0:停用、1:正常)') + # 数据权限靠这两列落地:规则里的 `__dept_id__` / `__created_by__` 解析成同名列,表上没有,规则就落不下来 + dept_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, index=True, comment='所属部门 ID') + owner_id: Mapped[int | None] = mapped_column(sa.BigInteger, default=None, comment='负责人用户 ID') + created_by: Mapped[int] = mapped_column(sa.BigInteger, default=0, index=True, comment='创建者用户 ID') + remark: Mapped[str | None] = mapped_column(UniversalText, default=None, comment='备注') +``` + +`apps/api/backend/app/crm/model/__init__.py`: + +```python +from backend.app.crm.model.customer import Customer as Customer +``` + +要点: + +- 继承 `Base`,`created_time` / `updated_time` / `deleted` / `deleted_time` 就都有了。`deleted` 存的是「0 或这一行自己的 id」,不是布尔值,手写 SQL 时要带 `deleted = 0` +- 主键写 `Mapped[id_key]`,是雪花 ID +- 会存中文的列用 `UniversalStr` / `UniversalText`,不要用 `sa.String`。SQL Server 上存中文需要 `NVARCHAR`,`UniversalStr` 会自动用它 +- 模型必须放在 `backend/app//model/` 下面。数据权限按这个路径找模型(`utils/dynamic_import.py: get_app_models`) + +**验收**:模型还不会自己建表。下一步的迁移能看到 `crm_customer`,就说明它被加载了。 + +## 第 2 步:生成迁移 + +改完模型,生成一条迁移: + +```bash +pnpm db:revision '客户管理 crm_customer' +``` + +alembic 会在 `apps/api/backend/alembic/versions/` 下生成一个文件,**提交前把它读一遍**,重点看三处: + +```python +op.create_table('crm_customer', + sa.Column('id', sa.BigInteger(), autoincrement=False, nullable=False, comment='雪花算法主键 ID'), + sa.Column('name', backend.common.model.UniversalStr(length=64), nullable=False, comment='客户名称'), + # … + sa.Column('deleted', sa.BigInteger(), server_default='0', nullable=False, comment='是否已删除(0:否;id:是)'), + # … +) +op.create_index(op.f('ix_crm_customer_dept_id'), 'crm_customer', ['dept_id'], unique=False) +``` + +1. **`id` 上有 `autoincrement=False`。** `id_key` 已经声明了它,autogenerate 会照着渲染。手写建表迁移时要自己写上,不然 SQL Server 会把雪花主键建成 IDENTITY,生产环境插入时报 `Cannot insert explicit value for identity column` +2. **只建这一张表。** 如果生成的文件里出现了一串 `drop_table`,说明 `alembic/env.py` 里的 `import backend.main` 被删了。不要提交 +3. 你的模型改动之外,不应该出现别的表的变化 + +然后升级: + +```bash +pnpm db:upgrade +pnpm db:current +``` + +**验收**:`pnpm db:current` 显示新生成的 revision 带着 `(head)`。想确认迁移能回滚,可以跑一次 `cd apps/api/backend && uv run --no-sync alembic downgrade -1`,再 `pnpm db:upgrade` 升回来。 + +:::caution +改了模型就要生成迁移。`--reload` 只会重新 import 代码,不会改已经建好的表。测试 `test_model_matches_migrations` 会比对模型和数据库,漏了迁移会报红。 +::: + +## 第 3 步:写 schema + +`apps/api/backend/app/crm/schema/customer.py`: + +```python +from datetime import datetime + +from pydantic import ConfigDict, Field + +from backend.app.crm.model import Customer +from backend.common.enums import StatusType +from backend.common.schema import ColumnLengthChecked, SchemaBase + + +class CustomerSchemaBase(SchemaBase): + """客户基础模型""" + + name: str = Field(description='客户名称') + phone: str | None = Field(None, description='联系电话') + level: int = Field(0, ge=0, le=2, description='客户等级(0:普通、1:重要、2:VIP)') + status: StatusType = Field(StatusType.enable, description='状态(0:停用、1:正常)') + remark: str | None = Field(None, description='备注') + + +class CreateCustomerParam(ColumnLengthChecked, CustomerSchemaBase): + """创建客户参数""" + + __sa_model__ = Customer + + dept_id: int | None = Field(None, description='所属部门 ID(不传 = 创建人的部门)') + owner_id: int | None = Field(None, description='负责人用户 ID(不传 = 创建人)') + + +class UpdateCustomerParam(ColumnLengthChecked, CustomerSchemaBase): + """更新客户参数""" + + __sa_model__ = Customer + + dept_id: int | None = Field(None, description='所属部门 ID') + owner_id: int | None = Field(None, description='负责人用户 ID') + + +class DeleteCustomerParam(SchemaBase): + """删除客户参数""" + + pks: list[int] = Field(description='客户 ID 列表') + + +class GetCustomerDetail(CustomerSchemaBase): + """客户详情""" + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(description='客户 ID') + dept_id: int | None = Field(None, description='所属部门 ID') + owner_id: int | None = Field(None, description='负责人用户 ID') + created_by: int = Field(description='创建者用户 ID') + created_time: datetime = Field(description='创建时间') + updated_time: datetime | None = Field(None, description='更新时间') +``` + +写入用的参数类要混入 `ColumnLengthChecked`,并用 `__sa_model__` 绑定模型。它会读模型上的列长度来校验入参,不用逐个字段写 `max_length`。不加的话,超长的值会一路到数据库,接口直接返回 500。 + +`id` 声明成 `int` 就可以。下发时 `stringify_unsafe_ints` 会把超过 JS 安全整数范围的值统一转成字符串。 + +**验收**:等接口写好以后,提交一个 70 个字的 `name`,返回 422,提示「name 最长 64 个字符,收到 70 个」。 + +## 第 4 步:写 crud(DAO) + +`apps/api/backend/app/crm/crud/crud_customer.py`: + +```python +from sqlalchemy import Select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.crm.model import Customer +from backend.common.security.data_scope import DataScopedCRUD +from backend.utils.timezone import timezone + + +class CRUDCustomer(DataScopedCRUD[Customer]): + """客户数据库操作类 + + 继承 `DataScopedCRUD` 且**不**写 `data_scope_enabled = False`:读操作自动套上当前用户的数据范围。 + """ + + async def get(self, db: AsyncSession, pk: int) -> Customer | None: + """获取客户详情(受数据范围约束:范围外的当作不存在)""" + return await self.select_model(db, pk, deleted=0) + + async def get_visible_ids(self, db: AsyncSession, pks: list[int]) -> list[int]: + """这批 ID 里当前用户看得见的那些(写操作前用它收窄)""" + rows = await self.select_models(db, id__in=pks, deleted=0) + return [row.id for row in rows] + + async def get_select(self, name: str | None, phone: str | None, level: int | None, status: int | None) -> Select: + """分页查询表达式(`select_order` → `select()`,数据范围已经在里面了)""" + filters = {'deleted': 0} + if name is not None: + filters['name__like'] = f'%{name}%' + if phone is not None: + filters['phone__like'] = f'%{phone}%' + if level is not None: + filters['level'] = level + if status is not None: + filters['status'] = status + return await self.select_order('id', 'desc', **filters) + + async def create(self, db: AsyncSession, data: dict) -> None: + """创建客户""" + db.add(self.model(**data)) + + async def update(self, db: AsyncSession, pk: int, data: dict) -> int: + """更新客户(⚠️ 写操作不过数据范围,调用前必须先 `get` 确认看得见)""" + return await self.update_model_by_column(db, data, id=pk, deleted=0) + + async def delete(self, db: AsyncSession, pks: list[int]) -> int: + """逻辑删除(⚠️ 同上,`pks` 要先经过 `get_visible_ids`)""" + return await self.delete_model_by_column( + db, + allow_multiple=True, + logical_deletion=True, + deleted_flag_column='deleted', + deleted_flag_value=self.model.id, + deleted_at_column='deleted_time', + deleted_at_factory=timezone.now(), + id__in=pks, + deleted=0, + ) + + +customer_dao: CRUDCustomer = CRUDCustomer(Customer) +``` + +几条规则: + +- **基类用 `DataScopedCRUD`,不要用 `CRUDPlus`。** 列表(`select`)和详情(`select_model`)会自动加上当前用户的数据范围条件。如果某张表确实不该按数据范围过滤,就在类里显式写 `data_scope_enabled = False`,并在注释里说明原因。测试 `test_every_crud_class_declares_its_data_scope_stance` 会检查每个 DAO 有没有表态 +- **数据范围只管读,不管写。** `update_model_by_column` / `delete_model_by_column` 直接生成 UPDATE / DELETE 语句,不经过 `select()`。实测:在「什么都看不见」的用户上下文里,`get()` 返回 `None`,`update()` 照样改到了 1 行。所以写操作要先用受范围约束的读收窄一遍(见下一步) +- **分页必须带排序**(`select_order`)。SQL Server 的 `OFFSET … FETCH` 要求有 `ORDER BY` +- **交给分页的那个 `Select` 里不能 join 多对多表**,否则 `total` 会按 join 之后的行数算,翻页也会错位。这张表没有多对多关系,不涉及 + +## 第 5 步:写 service + +`apps/api/backend/app/crm/service/customer_service.py`: + +```python +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.crm.crud.crud_customer import customer_dao +from backend.app.crm.model import Customer +from backend.app.crm.schema.customer import CreateCustomerParam, DeleteCustomerParam, UpdateCustomerParam +from backend.common.exception import errors +from backend.common.i18n import t +from backend.common.pagination import paging_data + + +class CustomerService: + """客户服务类""" + + @staticmethod + async def get(*, db: AsyncSession, pk: int) -> Customer: + customer = await customer_dao.get(db, pk) + if not customer: + raise errors.NotFoundError(msg=t('error.customer.not_found')) + return customer + + @staticmethod + async def get_list( + *, db: AsyncSession, name: str | None, phone: str | None, level: int | None, status: int | None + ) -> dict[str, Any]: + customer_select = await customer_dao.get_select(name=name, phone=phone, level=level, status=status) + return await paging_data(db, customer_select) + + @staticmethod + async def create(*, db: AsyncSession, obj: CreateCustomerParam, user: Any) -> None: + data = obj.model_dump() + # 不传就归到创建人自己的部门 / 名下 —— 否则 dept_id 是空,「本部门」规则下谁都看不见它 + data['dept_id'] = data['dept_id'] or user.dept_id + data['owner_id'] = data['owner_id'] or user.id + data['created_by'] = user.id + await customer_dao.create(db, data) + + @staticmethod + async def update(*, db: AsyncSession, pk: int, obj: UpdateCustomerParam) -> int: + # 先过一次受范围约束的读:范围外的客户在这里就是 404,不会被改到 + if not await customer_dao.get(db, pk): + raise errors.NotFoundError(msg=t('error.customer.not_found')) + # exclude_unset:没传的字段不写。否则只改名字的请求会把 dept_id / owner_id 冲成 NULL + return await customer_dao.update(db, pk, obj.model_dump(exclude_unset=True)) + + @staticmethod + async def delete(*, db: AsyncSession, obj: DeleteCustomerParam) -> int: + visible = await customer_dao.get_visible_ids(db, obj.pks) + if not visible: + return 0 + return await customer_dao.delete(db, visible) + + +customer_service: CustomerService = CustomerService() +``` + +报错文案走 `t('error.模块.键')`,不要直接写中文字面量。两个语言包里都要加上这个键: + +```yaml +# apps/api/backend/locale/zh-CN.yml,放在 error: 下面,按字母序插在 data_rule 前 + customer: + not_found: 客户不存在 +``` + +```yaml +# apps/api/backend/locale/en-US.yml + customer: + not_found: Customer does not exist +``` + +`backend/tests/test_i18n.py` 会检查两个语言包的键是否一致,以及代码里引用的键是否都存在。 + +## 第 6 步:写 api 并注册路由 + +`apps/api/backend/app/crm/api/v1/customer.py`: + +```python +from typing import Annotated + +from fastapi import APIRouter, Depends, Path, Query, Request + +from backend.app.crm.schema.customer import ( + CreateCustomerParam, + DeleteCustomerParam, + GetCustomerDetail, + UpdateCustomerParam, +) +from backend.app.crm.service.customer_service import customer_service +from backend.common.pagination import DependsPagination, PageData +from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base +from backend.common.security.permission import RequestPermission +from backend.common.security.rbac import DependsRBAC +from backend.database.db import CurrentSession, CurrentSessionTransaction + +router = APIRouter() + + +@router.get( + '/{pk}', + summary='获取客户详情', + dependencies=[Depends(RequestPermission('crm:customer:list')), DependsRBAC], +) +async def get_customer( + db: CurrentSession, pk: Annotated[int, Path(description='客户 ID')] +) -> ResponseSchemaModel[GetCustomerDetail]: + data = await customer_service.get(db=db, pk=pk) + return response_base.success(data=data) + + +@router.get( + '', + summary='分页获取客户', + dependencies=[Depends(RequestPermission('crm:customer:list')), DependsRBAC, DependsPagination], +) +async def get_customers_paginated( + db: CurrentSession, + name: Annotated[str | None, Query(description='客户名称')] = None, + phone: Annotated[str | None, Query(description='联系电话')] = None, + level: Annotated[int | None, Query(description='客户等级')] = None, + status: Annotated[int | None, Query(description='状态')] = None, +) -> ResponseSchemaModel[PageData[GetCustomerDetail]]: + page_data = await customer_service.get_list(db=db, name=name, phone=phone, level=level, status=status) + return response_base.success(data=page_data) + + +@router.post( + '', + summary='创建客户', + dependencies=[Depends(RequestPermission('crm:customer:add')), DependsRBAC], +) +async def create_customer(request: Request, db: CurrentSessionTransaction, obj: CreateCustomerParam) -> ResponseModel: + await customer_service.create(db=db, obj=obj, user=request.user) + return response_base.success() + + +@router.put( + '/{pk}', + summary='更新客户', + dependencies=[Depends(RequestPermission('crm:customer:edit')), DependsRBAC], +) +async def update_customer( + db: CurrentSessionTransaction, pk: Annotated[int, Path(description='客户 ID')], obj: UpdateCustomerParam +) -> ResponseModel: + await customer_service.update(db=db, pk=pk, obj=obj) + return response_base.success() + + +@router.delete( + '', + summary='批量删除客户', + dependencies=[Depends(RequestPermission('crm:customer:del')), DependsRBAC], +) +async def delete_customers(db: CurrentSessionTransaction, obj: DeleteCustomerParam) -> ResponseModel: + await customer_service.delete(db=db, obj=obj) + return response_base.success() +``` + +- 每个接口都要挂 `Depends(RequestPermission('…'))` + `DependsRBAC`,**读接口也一样**。没声明权限码的路由,`rbac_verify` 会直接放行,任何登录用户都能调 +- `RequestPermission` 要写在 `DependsRBAC` 前面,依赖是按顺序执行的 +- 读用 `CurrentSession`,写用 `CurrentSessionTransaction`(请求结束时自动提交) + +`apps/api/backend/app/crm/api/v1/__init__.py`: + +```python +from fastapi import APIRouter + +from backend.app.crm.api.v1.customer import router as customer_router + +router = APIRouter(prefix='/crm') + +router.include_router(customer_router, prefix='/customers', tags=['客户管理']) +``` + +`apps/api/backend/app/crm/api/router.py`: + +```python +from fastapi import APIRouter + +from backend.app.crm.api.v1 import router as v1_router +from backend.core.conf import settings + +v1 = APIRouter(prefix=settings.FASTAPI_API_V1_PATH) + +v1.include_router(v1_router) +``` + +最后在 `apps/api/backend/app/router.py` 里挂上: + +```python +from backend.app.crm.api.router import v1 as crm_v1 +# … +router.include_router(crm_v1) +``` + +**验收**:用超管的 token 调接口。 + +```bash +curl -s "http://127.0.0.1:8088/api/v1/crm/customers?page=1&size=20" -H "Authorization: Bearer $TOKEN" +# {"code":200,"msg":"请求成功","data":{"items":[],"total":0,…}} + +curl -s -X POST http://127.0.0.1:8088/api/v1/crm/customers \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"name":"华东机电","phone":"021-5555","level":2}' +# {"code":200,"msg":"请求成功","data":null} +``` + +再查一次列表,能看到这条记录。`id`、`dept_id`、`owner_id` 下发的都是字符串,比如 `"id":"2228321080542494720"`。 + +## 第 7 步:加菜单和按钮权限码 + +后端用到了 4 个权限码,菜单表里要有对应的按钮行。非超管用户的权限码都来自自己角色挂的菜单,权限码不在任何菜单上,他就会一直收到 403。 + +有两种加法: + +- **只在自己的环境试一下**:去「系统管理 → 菜单管理」手动新建。先建一个目录「客户关系」(路由 `/crm`),再在下面建菜单「客户管理」(路由 `/crm/customer`),最后在菜单下建 4 个按钮,权限码分别填 `crm:customer:list` / `:add` / `:edit` / `:del`。这样建的菜单只存在于当前这个库里 +- **要随代码一起交付**:按下面的做法,种子 SQL 和数据迁移两边都要加 + +### 为什么两边都要加? + +| 库是怎么来的 | 菜单从哪来 | +|---|---| +| 全新环境 `fba init`(建表、灌种子、`stamp head`) | **种子 SQL**。数据迁移会被 stamp 标记成已执行,实际不会跑 | +| 已经在跑的库 `alembic upgrade head` | **数据迁移**。种子 SQL 不会重新执行 | + +只加一边,总有一类环境缺菜单。 + +**种子 SQL**:在 `apps/api/backend/sql/{postgresql,mysql,sqlserver}/init_snowflake_test_data.sql` 末尾各追加一段。三个方言用同一套菜单 ID。PostgreSQL / MySQL 写法如下: + +```sql +insert into sys_menu (id, title, name, path, sort, icon, type, perms, status, display, link, remark, parent_id, created_time, updated_time) +values +(2049629108299000001, '客户关系', 'Crm', '/crm', 5, 'mdi:handshake-outline', 0, null, 1, 1, '', null, null, '2026-11-01 00:00:00', null), +(2049629108299000002, '客户管理', 'CrmCustomer', '/crm/customer', 1, 'mdi:account-tie-outline', 1, null, 1, 1, '', null, 2049629108299000001, '2026-11-01 00:00:00', null), +(2049629108299000003, '查询', 'QueryCrmCustomer', null, 0, null, 2, 'crm:customer:list', 1, 0, '', null, 2049629108299000002, '2026-11-01 00:00:00', null), +(2049629108299000004, '新增', 'AddCrmCustomer', null, 1, null, 2, 'crm:customer:add', 1, 0, '', null, 2049629108299000002, '2026-11-01 00:00:00', null), +(2049629108299000005, '修改', 'EditCrmCustomer', null, 2, null, 2, 'crm:customer:edit', 1, 0, '', null, 2049629108299000002, '2026-11-01 00:00:00', null), +(2049629108299000006, '删除', 'DeleteCrmCustomer', null, 3, null, 2, 'crm:customer:del', 1, 0, '', null, 2049629108299000002, '2026-11-01 00:00:00', null); +``` + +SQL Server 那份内容相同,只是写法不同:中文字面量前加 `N`(`N'客户关系'`),`null` 写成 `NULL`。验证时只有 PostgreSQL 这份真的灌进过库;MySQL 和 SQL Server 两份只过了静态检查 `test_seed_dialects.py`(三份的表、行数、列一致)。`type` 的取值:0 是目录,1 是菜单,2 是按钮。菜单 ID 自己挑一段没被占用的号,三个方言里要一致。 + +**数据迁移**:接在第 2 步生成的那个迁移文件后面,用 `backend/utils/data_migration.py` 里的辅助函数补菜单行: + +```python +from backend.utils.data_migration import add_menu, delete_by_key, seeded_pk_mode + +#: 和种子 SQL 里同一套 ID:升级上来的库和 `fba init` 新建的库收敛到同一份数据 +#: (menu_id, name, title, path, icon, menu_type, perms, sort, display, parent_name) +_MENUS = [ + (2049629108299000001, 'Crm', '客户关系', '/crm', 'mdi:handshake-outline', 0, None, 5, 1, None), + (2049629108299000002, 'CrmCustomer', '客户管理', '/crm/customer', 'mdi:account-tie-outline', 1, None, 1, 1, 'Crm'), + (2049629108299000003, 'QueryCrmCustomer', '查询', None, None, 2, 'crm:customer:list', 0, 0, 'CrmCustomer'), + (2049629108299000004, 'AddCrmCustomer', '新增', None, None, 2, 'crm:customer:add', 1, 0, 'CrmCustomer'), + (2049629108299000005, 'EditCrmCustomer', '修改', None, None, 2, 'crm:customer:edit', 2, 0, 'CrmCustomer'), + (2049629108299000006, 'DeleteCrmCustomer', '删除', None, None, 2, 'crm:customer:del', 3, 0, 'CrmCustomer'), +] + + +def upgrade() -> None: + # …autogenerate 生成的 create_table / create_index 保持不动… + + if not seeded_pk_mode(): + return # 自增主键模式没有基础种子,也就没有菜单要补 + conn = op.get_bind() + for menu_id, name, title, path, icon, menu_type, perms, sort, display, parent in _MENUS: + add_menu( + conn, + menu_id=menu_id, + name=name, + title=title, + path=path, + perms=perms, + icon=icon, + menu_type=menu_type, + sort=sort, + display=display, + parent_name=parent, + ) + + +def downgrade() -> None: + if seeded_pk_mode(): + conn = op.get_bind() + # 倒序删:先按钮后目录,否则子项变孤儿被提到侧边栏根上 + for menu in reversed(_MENUS): + delete_by_key(conn, 'sys_menu', name=menu[1]) + # …autogenerate 生成的 drop_index / drop_table… +``` + +`add_menu` 按 `name` 判断菜单是否已经存在,也按 `name` 去找父菜单。重复执行不会插入两遍,各个环境的父菜单 ID 不一样也不影响。 + +改了种子 SQL 之后,要更新种子清单(守卫测试会比对种子文件的哈希): + +```bash +cd apps/api && pnpm seed:manifest --write +``` + +**验收**: + +- `pnpm db:upgrade` 之后,`select name, perms from sys_menu where name like '%Crm%'` 能查到 6 行 +- `downgrade -1` 后这 6 行和 `crm_customer` 表都消失,`upgrade` 后又回来 +- `app/admin/tests/security/test_permission_codes.py` 通过。它会核对后端 `RequestPermission`、前端 `` 和种子菜单这三边的权限码是否一致 + +## 第 8 步:写前端列表页 + +一个页面由三部分组成,缺一个都不行: + +| 文件 | 作用 | +|---|---| +| `packages/platform/src/pages/customer/` | 页面组件,只通过 props 接收 search | +| `apps/web/src/routes/_auth/crm/customer.tsx` | 路由:只声明 search schema、守卫和标题,不渲染页面 | +| `apps/web/src/lib/page-registry.tsx` | 路由 ID 和页面组件的对应表,多页签靠它渲染隐藏的页签 | + +页面目录的结构照 `pages/notice/` 写: + +``` +packages/platform/src/pages/customer/ +├─ api.ts 类型 · query key · queryOptions · mutation +├─ columns.tsx 列定义 + 行操作(按钮用 包住) +├─ form.tsx 新增 / 编辑抽屉(react-hook-form + zod) +├─ index.tsx 页面本体:QueryBar + DataTable + listState +└─ table-features.ts TanStack Table v9 要注册的特性 +``` + +### api.ts + +```ts +import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' + +import { api, type PageData } from '../../api-client/client' + +/** 雪花 ID 一律字符串,不要 `Number()`(硬纪律 6) */ +export type Customer = { + id: string + name: string + phone: string | null + /** 0 普通 · 1 重要 · 2 VIP */ + level: number + /** 0 停用 · 1 正常 */ + status: number + dept_id: string | null + owner_id: string | null + created_by: string + remark: string | null + created_time: string + updated_time: string | null +} + +export type CustomerBody = { + name: string + phone: string | null + level: number + status: number + remark: string | null +} + +export type CustomerListParams = { + page: number + size: number + name?: string + phone?: string + level?: number + status?: number +} + +/** 等级的中文即语言包 key,渲染处过 `t()` */ +export const CUSTOMER_LEVEL_LABEL: Record = { 0: '普通', 1: '重要', 2: 'VIP' } + +const BASE = '/api/v1/crm/customers' + +export const customerKeys = { + all: ['crm', 'customer'] as const, + list: (p: CustomerListParams) => [...customerKeys.all, 'list', p] as const, +} + +function qs(p: CustomerListParams): string { + const s = new URLSearchParams() + s.set('page', String(p.page)) + s.set('size', String(p.size)) + if (p.name) s.set('name', p.name) + if (p.phone) s.set('phone', p.phone) + if (p.level !== undefined) s.set('level', String(p.level)) + if (p.status !== undefined) s.set('status', String(p.status)) + return s.toString() +} + +export const customersQuery = (p: CustomerListParams) => + queryOptions({ + queryKey: customerKeys.list(p), + queryFn: () => api.GET>(`${BASE}?${qs(p)}`), + // 翻页时保留上一页,避免表格闪空 + placeholderData: (prev) => prev, + }) + +function useInvalidate() { + const qc = useQueryClient() + return () => qc.invalidateQueries({ queryKey: customerKeys.all }) +} + +export function useCreateCustomer() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (body: CustomerBody) => api.POST(BASE, { body }), + onSuccess: invalidate, + // 表单里内联显示后端的报错,全局 toast 再弹一遍只是噪音 + meta: { suppressErrorToast: true }, + }) +} + +export function useUpdateCustomer() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: CustomerBody }) => api.PUT(`${BASE}/${id}`, { body }), + onSuccess: invalidate, + meta: { suppressErrorToast: true }, + }) +} + +export function useDeleteCustomers() { + const invalidate = useInvalidate() + return useMutation({ + mutationFn: (pks: string[]) => api.DELETE(BASE, { body: { pks } }), + onSuccess: invalidate, + }) +} +``` + +### table-features.ts + +```ts +import { columnVisibilityFeature, rowPaginationFeature, rowSelectionFeature, tableFeatures } from '@tanstack/react-table' + +/** 筛选 / 分页都在服务端;后端固定按 id 倒序、没有排序入参,所以不注册排序特性 */ +export const features = tableFeatures({ + columnVisibilityFeature, + rowPaginationFeature, + rowSelectionFeature, +}) +``` + +### columns.tsx + +```tsx +import { createColumnHelper } from '@tanstack/react-table' +import { IconDotsVertical, IconPencil, IconTrash } from '@tabler/icons-react' + +import { formatDateTime } from '@admin/i18n' +import { Button } from '@admin/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@admin/ui/components/dropdown-menu' + +import { Can } from '../../auth/can' +import { buildSelectColumn } from '../_shared/select-column' +import { StatusBadge, StatusPill } from '../_shared/status' +import { CUSTOMER_LEVEL_LABEL, type Customer } from './api' +import type { features } from './table-features' + +const col = createColumnHelper() + +type T = (k: string, vars?: Record) => string + +export function buildColumns(onEdit: (c: Customer) => void, onDelete: (c: Customer) => void, t: T) { + return [ + buildSelectColumn(col, {}, t), + col.accessor('name', { + header: t('客户名称'), + cell: ({ getValue }) => {getValue()}, + }), + col.accessor('phone', { + header: t('联系电话'), + cell: ({ getValue }) => {getValue() ?? '—'}, + }), + col.accessor('level', { + header: t('等级'), + cell: ({ getValue }) => ( + + {t(CUSTOMER_LEVEL_LABEL[getValue()] ?? '—')} + + ), + }), + col.accessor('status', { + header: t('状态'), + cell: ({ getValue }) => , + }), + col.accessor('created_time', { + header: t('创建时间'), + // 时间一律过 formatDateTime(接口下发的是带偏移的 ISO 串) + cell: ({ getValue }) => {formatDateTime(getValue())}, + }), + col.display({ + id: 'actions', + header: () => {t('操作')}, + cell: ({ row }) => ( + + } + > + + + + + onEdit(row.original)}> + + {t('编辑')} + + + + onDelete(row.original)}> + + {t('删除')} + + + + + ), + }), + ] +} + +export const COLUMN_LABELS: Record = { + name: '客户名称', + phone: '联系电话', + level: '等级', + status: '状态', + created_time: '创建时间', +} +``` + +### form.tsx + +```tsx +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { IconLoader2 } from '@tabler/icons-react' + +import { Button } from '@admin/ui/components/button' +import { Input } from '@admin/ui/components/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@admin/ui/components/select' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@admin/ui/components/sheet' +import { Textarea } from '@admin/ui/components/textarea' + +import { ApiError } from '../../api-client/errors' +import { useFieldError } from '../_shared/form-error' +import { FormField } from '../_shared/form-fields' +import { STATUS_FORM_ITEMS } from '../_shared/status' +import { CUSTOMER_LEVEL_LABEL, useCreateCustomer, useUpdateCustomer, type Customer } from './api' + +// 长度和后端列一致(name 64 / phone 20);后端的 ColumnLengthChecked 才是权威,这里只为当场报错 +const schema = z.object({ + name: z.string().trim().min(1, '请输入客户名称').max(64), + phone: z.string().max(20).optional(), + level: z.number().int().min(0).max(2), + status: z.number().int(), + remark: z.string().max(500).optional(), +}) +type Values = z.infer + +const EMPTY: Values = { name: '', phone: '', level: 0, status: 1, remark: '' } + +export function CustomerFormSheet({ + open, + onOpenChange, + editing, +}: { + open: boolean + onOpenChange: (o: boolean) => void + editing: Customer | null +}) { + const { t } = useTranslation() + const fe = useFieldError() + const create = useCreateCustomer() + const update = useUpdateCustomer() + const [err, setErr] = React.useState(null) + const form = useForm({ resolver: zodResolver(schema) as never, defaultValues: EMPTY }) + + React.useEffect(() => { + if (!open) return + setErr(null) + form.reset( + editing + ? { + name: editing.name, + phone: editing.phone ?? '', + level: editing.level, + status: editing.status, + remark: editing.remark ?? '', + } + : EMPTY + ) + }, [open, editing, form]) + + const levelItems = React.useMemo( + () => Object.fromEntries(Object.entries(CUSTOMER_LEVEL_LABEL).map(([v, l]) => [v, t(l)])), + [t] + ) + const statusItems = React.useMemo( + () => Object.fromEntries(Object.entries(STATUS_FORM_ITEMS).map(([v, l]) => [v, t(l)])), + [t] + ) + const errs = form.formState.errors + const pending = create.isPending || update.isPending + + return ( + + + + {editing ? t('编辑客户') : t('新增客户')} + {t('新建的客户归属到你所在的部门,负责人默认是你。')} + + { + setErr(null) + const body = { + name: v.name, + phone: v.phone?.trim() || null, + level: v.level, + status: v.status, + remark: v.remark?.trim() || null, + } + try { + if (editing) await update.mutateAsync({ id: editing.id, body }) + else await create.mutateAsync(body) + onOpenChange(false) + } catch (e) { + setErr(e instanceof ApiError ? e.message : t('保存失败')) + } + })} + className="flex flex-1 flex-col overflow-hidden" + > + + + + + + + + + + form.setValue('level', Number(v), { shouldValidate: true })} + > + + + + + {Object.entries(levelItems).map(([v, l]) => ( + + {l} + + ))} + + + + + form.setValue('status', Number(v), { shouldValidate: true })} + > + + + + + {Object.entries(statusItems).map(([v, l]) => ( + + {l} + + ))} + + + + + + + + {err && {err}} + + + + {pending && } + {editing ? t('保存修改') : t('创建客户')} + + }>{t('取消')} + + + + + ) +} +``` + +`level` 和 `status` 在这里用 `Number(v)` 转换没问题,它们是 0/1/2 这样的小枚举。ID 不能这样转。 + +### index.tsx + +```tsx +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useTable, type ColumnVisibilityState, type RowSelectionState } from '@tanstack/react-table' +import { useQuery } from '@tanstack/react-query' +import { IconPlus } from '@tabler/icons-react' + +import { Button } from '@admin/ui/components/button' +import { DataTable, DataTableColumnVisibility } from '@admin/ui/components/data-table' +import { QueryBar, countActive, type FilterField } from '@admin/ui/components/query-bar' + +import { Can } from '../../auth/can' +import { ConfirmDialog } from '../../shell/confirm-dialog' +import { PageHeader } from '../../shell/page-header' +import { BulkBar, RefreshButton, ResetButton } from '../_shared/filters' +import { listState } from '../_shared/list-query' +import { DEFAULT_PAGE_SIZE } from '../_shared/pagination' +import { useQuerySearch } from '../_shared/use-query-search' +import { customerKeys, customersQuery, useDeleteCustomers, type Customer, type CustomerListParams } from './api' +import { COLUMN_LABELS, buildColumns } from './columns' +import { CustomerFormSheet } from './form' +import { features } from './table-features' + +/** 可筛字段。`key` 和接口入参同名,不用 `param` 映射 */ +const FIELDS: FilterField[] = [ + { key: 'name', label: '客户名称', type: 'text', group: '基本信息', defaultVisible: true, placeholder: '模糊匹配' }, + { key: 'phone', label: '联系电话', type: 'text', group: '基本信息', defaultVisible: true, placeholder: '模糊匹配' }, + { + key: 'level', + label: '等级', + type: 'select', + group: '基本信息', + defaultVisible: true, + options: [ + { value: 0, label: '普通' }, + { value: 1, label: '重要' }, + { value: 2, label: 'VIP' }, + ], + }, + { + key: 'status', + label: '状态', + type: 'select', + group: '状态', + defaultVisible: true, + options: [ + { value: 1, label: '正常' }, + { value: 0, label: '停用' }, + ], + }, +] + +export type CustomerPageSearch = { + page?: number + size?: number + /** 摆开但还没填值的格子(见 `_shared/use-query-search`) */ + f?: string + name?: string + phone?: string + level?: number + status?: number +} + +/** + * 客户管理。router-独立(硬纪律 1):search 只从 props 进、改动经 `onSearchChange` 出, + * 筛选与分页全进 URL(硬纪律 2)。 + */ +export function CustomerPage({ + search = {}, + onSearchChange, +}: { + search?: CustomerPageSearch + onSearchChange?: (next: CustomerPageSearch) => void +}) { + const { t } = useTranslation() + const page = search.page ?? 1 + const size = search.size ?? DEFAULT_PAGE_SIZE + + const [rowSelection, setRowSelection] = React.useState({}) + const [columnVisibility, setColumnVisibility] = React.useState({}) + + // 换页 / 换筛选清掉选中:分页在服务端,留着的选中项已不在可见行里 + const patch = React.useCallback( + (next: Partial) => { + setRowSelection({}) + onSearchChange?.({ ...search, ...next }) + }, + [onSearchChange, search] + ) + + const q = useQuerySearch({ fields: FIELDS, search, onSearchChange, refreshKey: customerKeys.all }) + const submitQuery = React.useCallback( + (v: Parameters[0]) => { + setRowSelection({}) + q.submit(v) + }, + [q] + ) + const clearFilters = React.useCallback(() => { + setRowSelection({}) + q.reset() + }, [q]) + + const listQuery = useQuery(customersQuery({ page, size, ...q.params } as CustomerListParams)) + const { data, isFetching } = listQuery + // loading / busy / error / onRetry 一次摊开 —— 接口挂了显示错误块,不是「暂无数据」(硬纪律 9) + const list = listState(listQuery, { onBeforeRefetch: () => setRowSelection({}) }) + const rows = data?.items ?? [] + const total = data?.total ?? 0 + + const [sheetOpen, setSheetOpen] = React.useState(false) + const [editing, setEditing] = React.useState(null) + const [pendingDelete, setPendingDelete] = React.useState(null) + const [bulkOpen, setBulkOpen] = React.useState(false) + const del = useDeleteCustomers() + + const columns = React.useMemo( + () => + buildColumns( + (c) => { + setEditing(c) + setSheetOpen(true) + }, + setPendingDelete, + t + ), + [t] + ) + + const table = useTable({ + features, + data: rows, + columns: columns as never, + state: { columnVisibility, rowSelection }, + getRowId: (row) => row.id, + manualPagination: true, + rowCount: total, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + }) + + const selectedIds = Object.keys(rowSelection).filter((k) => rowSelection[k]) + const hasFilter = countActive(q.applied, FIELDS) > 0 + + return ( + + + + + + + + { + setEditing(null) + setSheetOpen(true) + }} + > + + {t('新增客户')} + + + + + + setBulkOpen(true)} /> + + > + } + /> + + + + ) : undefined + } + {...list} + pagination={{ + pageIndex: page - 1, + pageCount: data?.total_pages ?? 1, + pageSize: size, + selectedCount: selectedIds.length, + totalCount: total, + onPageChange: (i) => patch({ page: i === 0 ? undefined : i + 1 }), + onPageSizeChange: (s) => patch({ size: s, page: undefined }), + }} + /> + + + + + + + !o && setPendingDelete(null)} + title={t('删除客户')} + description={pendingDelete ? t('确定删除「{{name}}」吗?', { name: pendingDelete.name }) : ''} + confirmText={t('删除')} + destructive + pending={del.isPending} + onConfirm={async () => { + if (!pendingDelete) return + try { + await del.mutateAsync([pendingDelete.id]) + } finally { + setPendingDelete(null) + } + }} + /> + !o && setBulkOpen(false)} + title={t('批量删除客户')} + description={t('确定删除选中的 {{n}} 个客户吗?', { n: selectedIds.length })} + confirmText={t('删除')} + destructive + pending={del.isPending} + onConfirm={async () => { + try { + await del.mutateAsync(selectedIds) + setRowSelection({}) + } finally { + setBulkOpen(false) + } + }} + /> + + ) +} +``` + +页面里有三处不能改: + +- 页面组件**只能**通过 props 拿 `search` / `onSearchChange`,不能调 `Route.useSearch()` / `useNavigate()`。多页签会同时挂载所有打开的页签,隐藏页签拿不到路由的匹配上下文 +- 取数状态用 `listState()` 一次拿全。如果自己只解构 `isPending` / `isFetching`,漏掉 `error`,接口失败时页面会显示「暂无数据」,和真的没数据分不出来 +- 查询、重置、刷新的时候都要清掉 `rowSelection`。不清的话,批量删除会删到用户已经看不见的行 + +### 路由文件 + +`apps/web/src/routes/_auth/crm/customer.tsx`: + +```tsx +import { createFileRoute } from "@tanstack/react-router" +import { z } from "zod" + +import { requirePerm } from "@admin/platform/auth/guards" + +const searchSchema = z.object({ + page: z.coerce.number().int().min(1).optional(), + size: z.coerce.number().int().min(1).optional(), + /** 摆开但还没填值的格子 */ + f: z.string().optional(), + name: z.string().optional(), + phone: z.string().optional(), + level: z.coerce.number().int().optional(), + status: z.coerce.number().int().optional(), +}) + +/** 页面由 TabOutlet 挂载;这里只声明 search schema / staticData / 权限(硬纪律 3) */ +export const Route = createFileRoute("/_auth/crm/customer")({ + validateSearch: searchSchema, + beforeLoad: requirePerm("crm:customer:list"), + staticData: { title: "客户管理" }, + component: () => null, +}) +``` + +路由的路径要和菜单里填的 `path`(`/crm/customer`)一致,否则菜单管理会把这条菜单标成死链,侧边栏也不会显示它。 + +如果以后要往 search 里放雪花 ID(比如 `?owner=…`),schema 里写成 `z.coerce.string()`,不要写 `z.coerce.number()`。 + +`apps/web/src/routeTree.gen.ts` 是 TanStack Router 插件生成的。新增路由文件后启动一次前端开发服务器(`pnpm --filter web dev`),它会自动更新。不更新的话,`createFileRoute("/_auth/crm/customer")` 会报类型错误。 + +### 注册页面 + +`apps/web/src/lib/page-registry.tsx`: + +```tsx +import { CustomerPage } from "@admin/platform/pages/customer" + +export const pageRegistry: PageRegistry = { + // … + "/_auth/crm/customer": CustomerPage, +} +``` + +`@admin/platform/pages/*` 已经在 `packages/platform/package.json` 的 `exports` 里声明过,`apps/web` 也已经依赖了 `@admin/platform`,所以这里不用改 `package.json`。 + +### 文案 + +页面里所有 `t('…')` 的 key 都要在 `packages/i18n/src/locales/zh-CN.json` 和 `en-US.json` 里有对应条目。中文语言包的 key 和值相同,英文语言包写译文。菜单标题也要加一条:`"menu:/crm/customer": "客户管理"`(英文写 `"Customers"`)。加完之后: + +```bash +pnpm i18n:fix # 排序 +pnpm i18n:check # 不能有 missing-keys +pnpm i18n:jsx # 不能有裸露的 JSX 中文 +``` + +**验收**: + +```bash +pnpm typecheck --force # 用 --force,turbo 的缓存会打印上一次的结果 +pnpm arch:check # 依赖方向、多页签纪律、registry +pnpm lint +``` + +以上全部通过后,用超管登录,侧边栏会出现「客户关系 → 客户管理」,列表里能看到第 6 步建的那条客户。 + +:::note +上面这几条命令都实际跑过并通过了。浏览器里的效果(侧边栏、表单交互)没有在验证时截图确认,第一次跑的时候请自己点一遍。 +::: + +## 第 9 步:让数据权限生效 + +数据权限本身不用写代码,第 4 步继承 `DataScopedCRUD` 时就已经接上了。这一步是用页面把它配出来,确认它真的在起作用。机制见[数据权限](/features/data-permissions/)。 + +1. **系统管理 → 角色管理**:新建一个角色(比如「销售」),在「功能权限」里勾上「客户管理」和下面的 4 个按钮。在「数据范围」里选种子数据自带的「本部门数据权限」,它的规则是 `__ALL__` 模型上的 `__dept_id__ == ${dept_id}` +2. **系统管理 → 用户管理**:新建一个用户,部门选「财务人事中心」,角色选「销售」。然后在这个用户的「权限与安全」里打开「后台登录」,不然他所有写操作都会被拒(见常见卡点) +3. 用这个用户登录 + +**验收**(实测结果): + +| 操作 | 超管 | 「销售」(财务人事中心) | +|---|---|---| +| `GET /crm/customers` | 两条都看得到 | 只看得到 `dept_id` 是财务人事中心的那条 | +| `GET /crm/customers/{总部那条的 id}` | 200 | **404** | +| `PUT` 总部那条 | 200 | **404**,数据没被改 | +| `DELETE` 总部那条 | — | 200,但**没有删掉任何行**(`get_visible_ids` 把它过滤掉了) | +| `POST` 不带 `dept_id` | — | 新客户归到财务人事中心,`owner_id` 和 `created_by` 都是这个用户自己 | + +再看一下种子里另外几个演示角色: + +- `VIEWER` 的「仅本人数据权限」规则是 `__created_by__ == ${user_id}`。表上有 `created_by` 列,所以这条规则能落到客户表上 +- `MANAGER` 的两条规则都写在 `Dept` 模型上,查客户表时用不上,所以 `MANAGER` 能看到全部客户。一条规则只对它所写的模型生效 + +这两条是按 `filter_data_permission_for_user` 的代码推出来的,验证时没有用这两个角色实际登录测过。 + +:::caution +`__ALL__` 规则落到一张没有对应列的表上时,这条规则会被跳过。如果角色的规则全都被跳过了,结果就是**放行全部**。所以如果你希望客户表按部门过滤,表上就一定要有 `dept_id` 列,并且新建的数据要把它填上。 +::: + +## 第 10 步:写测试 + +新 app 的测试要先加到 `apps/api/pyproject.toml` 的 `testpaths` 里,否则 `pnpm test` 收集不到这些测试,而且不会给出任何提示: + +```toml +testpaths = [ + "backend/app/admin/tests", + "backend/app/task/tests", + "backend/app/crm/tests", + "backend/plugin", + "backend/tests" +] +``` + +`apps/api/backend/app/crm/tests/test_customer.py`(同目录要有一个空的 `__init__.py`): + +```python +"""客户管理:接口走一遍 CRUD,再验受限用户碰不到范围外的客户""" + +import asyncio + +from types import SimpleNamespace + +import pytest + +from starlette.testclient import TestClient + +from backend.app.crm.schema.customer import DeleteCustomerParam, UpdateCustomerParam +from backend.app.crm.service.customer_service import customer_service +from backend.common.exception import errors +from backend.common.security.data_scope import set_current_user +from backend.database.db import create_database_async_engine, create_database_async_session, get_database_url + +BASE = '/crm/customers' + + +def _restricted() -> SimpleNamespace: + """开了范围过滤、一条规则都没有 —— fail-closed,什么都看不见""" + scope = SimpleNamespace(status=1, rules=[]) + return SimpleNamespace( + is_superuser=False, id=1, dept_id=1, roles=[SimpleNamespace(status=1, is_filter_scopes=True, scopes=[scope])] + ) + + +def _as_restricted(fn): # noqa: ANN001, ANN202 + async def go(): # noqa: ANN202 + engine = create_database_async_engine(get_database_url(unittest=True)) + try: + async with create_database_async_session(engine).begin() as db: + set_current_user(_restricted()) + try: + return await fn(db) + finally: + set_current_user(None) + finally: + await engine.dispose() + + return asyncio.run(go()) + + +@pytest.fixture +def customer(client: TestClient, token_headers: dict[str, str]): # noqa: ANN201 + name = 'pytest-客户' + res = client.post(BASE, headers=token_headers, json={'name': name, 'level': 1}) + assert res.json()['code'] == 200, res.text + items = client.get(BASE, headers=token_headers, params={'name': name}).json()['data']['items'] + assert len(items) == 1 + yield items[0] + client.request('DELETE', BASE, headers=token_headers, json={'pks': [int(items[0]['id'])]}) + + +def test_crud(client: TestClient, token_headers: dict[str, str], customer: dict) -> None: + pk = customer['id'] + assert isinstance(pk, str) # 雪花 ID 下发成字符串(硬纪律 6) + assert customer['created_by'] == customer['owner_id'] # 不传负责人 = 创建人 + + res = client.put(f'{BASE}/{pk}', headers=token_headers, json={'name': 'pytest-客户-改', 'level': 2}) + assert res.json()['code'] == 200, res.text + detail = client.get(f'{BASE}/{pk}', headers=token_headers).json()['data'] + assert detail['level'] == 2 + assert detail['dept_id'] == customer['dept_id'] # 没传的字段不能被冲成 NULL + + res = client.request('DELETE', BASE, headers=token_headers, json={'pks': [int(pk)]}) + assert res.json()['code'] == 200 + assert client.get(f'{BASE}/{pk}', headers=token_headers).status_code == 404 + + +def test_restricted_user_cannot_touch_rows_outside_scope( + client: TestClient, token_headers: dict[str, str], customer: dict +) -> None: + pk = int(customer['id']) + + with pytest.raises(errors.NotFoundError): + _as_restricted(lambda db: customer_service.update(db=db, pk=pk, obj=UpdateCustomerParam(name='越权'))) + assert _as_restricted(lambda db: customer_service.delete(db=db, obj=DeleteCustomerParam(pks=[pk]))) == 0 + + # 超管视角:那一行还在、名字没被改 + assert client.get(f'{BASE}/{pk}', headers=token_headers).json()['data']['name'] == 'pytest-客户' +``` + +测试跑在单独的测试库 `_test` 上(`conftest.py` 替换了 `get_db`)。测试库要先重建,它会按当前模型建表、灌入种子并 stamp 到 head: + +```bash +pnpm --filter api test:db +cd apps/api && uv run --no-sync pytest backend/app/crm/tests -rA +``` + +**验收**:`2 passed`。这两条测试都做过反向验证:把 service 里的 `get_visible_ids` 去掉,第二条测试会报 `assert 1 == 0`;把 `exclude_unset=True` 去掉,第一条测试会报 `dept_id` 变成了 `None`。 + +最后跑一遍和这次改动相关的守卫测试: + +```bash +cd apps/api && uv run --no-sync pytest \ + backend/app/task/tests/test_migrations.py \ + backend/app/admin/tests/security/test_permission_codes.py \ + backend/app/admin/tests/security/test_data_scope_coverage.py \ + backend/app/admin/tests/api_v1/test_column_length_gate.py \ + backend/tests/test_seed_dialects.py backend/tests/test_i18n.py +``` + +## 常见卡点 + +| 现象 | 原因和修法 | +|---|---| +| 非超管用户访问列表、详情都正常,新增、修改、删除一律 403,提示「用户已被禁止后台管理操作」 | 除 `GET` / `OPTIONS` 之外的请求还要求用户的 `is_staff` 为真。在「用户管理 → 权限与安全」里打开「后台登录」。这个开关的说明文字写的是「登录后台」,但关掉后用户其实还能登录、能读,只是不能写 | +| 非超管用户点了按钮没反应,接口返回 403 | 权限码不在他任何一个角色的菜单上。给菜单加上按钮行,并在角色的「功能权限」里勾上。`test_permission_codes.py` 能查出「后端有这个码、种子菜单里没有」的情况 | +| 用户看不到某条数据,却能按 ID 改掉或删掉它 | 数据范围只作用在读操作上,`update_model_by_column` / `delete_model_by_column` 不过滤。写之前先用 `get()` / `get_visible_ids()` 收窄 | +| 配了「本部门」,用户还是能看到所有客户 | 表上没有 `dept_id` 列,`__ALL__` 规则被跳过,结果是放行全部。或者规则写在别的模型上(比如 `Dept`),对这张表不生效 | +| 新建的客户谁都看不见(超管除外) | 新建时没填 `dept_id`,`dept_id = ${dept_id}` 匹配不到 NULL。在 service 里给它默认值 | +| 只改了名字,客户的部门变成了空 | `PUT` 时用 `model_dump()` 把没传的字段也按默认值 `None` 写了进去。改成 `model_dump(exclude_unset=True)` | +| 接口返回 500,日志里是字符串被截断之类的数据库错误 | 写入参数没有混入 `ColumnLengthChecked`,超长的值直接到了数据库。加上 mixin 并设置 `__sa_model__` | +| 改了模型,接口报数据库里没有这个表或列 | 没生成迁移,或者生成了没执行 `pnpm db:upgrade`。`--reload` 不会改表 | +| SQL Server 上新表插入时报 `Cannot insert explicit value for identity column` | 迁移里 `id` 列缺了 `autoincrement=False`,被建成了 IDENTITY。手写建表迁移时要自己写上 | +| autogenerate 生成了一堆 `drop_table` | `alembic/env.py` 里的 `import backend.main` 被删了,metadata 是空的。不要提交这份迁移 | +| 新环境(`fba init`)没有「客户管理」菜单,或者老环境升级后没有 | 菜单只加在了数据迁移里,或者只加在了种子 SQL 里。两边都要加,然后跑 `seed:manifest --write` | +| 给已有表加了列以后,全新环境初始化失败 | 那张表的种子 SQL 是带显式列名的 INSERT,三个方言的 `init_snowflake_test_data.sql` 要一起改 | +| 列表里的 ID 和数据库对不上,改 A 却改到了 B | 前端对雪花 ID 用了 `Number()`,精度丢了。ID 一律当字符串处理,search 参数里用 `z.coerce.string()` | +| 页签切走再切回来,筛选条件不见了,或者隐藏页签报错 | 页面组件内部调用了 `Route.useSearch()` / `useNavigate()`。改成通过 props 接收,`pnpm arch:check` 会检查这一条 | +| 接口失败时列表显示「暂无数据」 | 没用 `listState()`,漏了 `error`。把 `{...list}` 传给 `DataTable` | +| 菜单管理把这一行标成死链 | 菜单的 `path` 和前端路由文件的路径不一致。另外路由文件和 `page-registry.tsx` 要成对出现,`pnpm arch:check` 会核对 registry | +| `pnpm i18n:check` 报 `missing-keys` | 新文案没有加到两个语言包里 | +| 写了测试,`pnpm test` 照样全绿,但其实没跑到 | 新 app 的 `tests/` 目录没加进 `pyproject.toml` 的 `testpaths` | +| 把模块拆成新的 workspace 包后,生产镜像构建报 `Cannot find module` | 只配了 alias / tsconfig 路径,没在依赖方的 `package.json` 里声明这个包。`pnpm arch:check` 会核对 | + +## 相关源码 + +- [`backend/common/security/data_scope.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/security/data_scope.py):`DataScopedCRUD`,以及它覆盖了哪几个读方法 +- [`backend/common/security/permission.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/security/permission.py):规则怎么编译成 `WHERE` 条件 +- [`backend/common/security/rbac.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/security/rbac.py):`rbac_verify` +- [`backend/common/model.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/model.py):`Base`、`id_key`、`UniversalStr` +- [`backend/common/schema.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/common/schema.py):`ColumnLengthChecked` +- [`backend/utils/data_migration.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/utils/data_migration.py):`add_menu` / `grant_menu` +- [`backend/plugin/dict/`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/plugin/dict/):一个完整的简单三层模块 +- [`backend/app/router.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/app/router.py):app 路由挂载点 +- [`backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md):迁移和种子数据的规则 +- [`packages/platform/src/pages/notice/`](https://github.com/yilecoding/fastapi-react-admin/blob/main/packages/platform/src/pages/notice/):列表页范本 +- [`packages/platform/src/pages/_shared/list-query.ts`](https://github.com/yilecoding/fastapi-react-admin/blob/main/packages/platform/src/pages/_shared/list-query.ts):`listState()` +- [`packages/platform/src/pages/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/packages/platform/src/pages/AGENTS.md):列表页的写法规则 +- [`apps/web/src/lib/page-registry.tsx`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/web/src/lib/page-registry.tsx):页面注册表 diff --git a/apps/docs/src/content/docs/reference/faq.md b/apps/docs/src/content/docs/reference/faq.md new file mode 100644 index 00000000..97319b1b --- /dev/null +++ b/apps/docs/src/content/docs/reference/faq.md @@ -0,0 +1,284 @@ +--- +title: 常见问题 +description: 按报错原文排的排障清单:本地起服务、登录、数据库迁移、前端开发和生产部署里最常撞到的 25 个问题,各附原因、处理和出处。 +sidebar: + order: 1 +--- + +把终端或浏览器里的报错原文复制下来,用 Ctrl+F 或站内搜索在本页查找;每条都写了原因、处理办法,以及记录这条结论的源码笔记。 + +## 安装与本地启动 + +### `Port 8888 is already in use` + +**原因**:前端端口固定在 8888,`apps/web/vite.config.ts` 里写了 `strictPort: true`。端口被占时 Vite 直接退出,不会自己换到 8889,因为后端的 CORS 白名单、OAuth2 回跳地址和桌面端开发脚本都写死了 8888。 + +**处理**:找出占着 8888 的进程(常见的是上一次没退干净的 `pnpm dev`),关掉后重新起。如果确实要换端口,四处要一起改:`vite.config.ts`、`backend/core/conf.py` 的 `CORS_ALLOWED_ORIGINS`、`backend/plugin/oauth2/plugin.toml` 里两条 `OAUTH2_FRONTEND_*_REDIRECT_URI`、`apps/desktop/scripts/dev.mjs` 的 `DEV_SERVER_URL` 默认值。 + +```bash +lsof -i :8888 # 看是谁占着 +``` + +出处:[`CLAUDE.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CLAUDE.md)「本地起服务」一节 + +### `Can't open lib 'ODBC Driver 18 for SQL Server'` + +**原因**:切到 SQL Server 后,后端通过 `aioodbc` 连库,用的是**宿主机**上的 ODBC 驱动。SQL Server 容器里自带的那份驱动不算数,所以容器起来了,后端一连库就报这一句。 + +**处理**:在跑后端的机器上装 [Microsoft ODBC Driver 18](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)。`.env` 里的 `DATABASE_DRIVER`(默认 `ODBC Driver 18 for SQL Server`)要和下面这条命令列出的名字完全一致。生产镜像 `apps/api/Dockerfile.prod` 已经装了 `msodbcsql18`,worker / beat 用的也是这个镜像。 + +```bash +odbcinst -q -d +``` + +出处:[`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md)「换数据库 / 接对象存储」、[`apps/api/Dockerfile.prod`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/Dockerfile.prod) + +### `Login failed for user 'sa'`(SQL Server 错误 18456) + +**原因**:`.env` 里的 `DATABASE_PASSWORD` 和 SQL Server 实例的实际密码对不上。最常见的情况是改了 `docker-compose.dev.yml` 里的 `MSSQL_SA_PASSWORD`:SQL Server 用了持久化卷,已经初始化过的实例不会跟着改密码。 + +**处理**:让 `DATABASE_PASSWORD` 和实例当前的 sa 密码一致;确实想用新密码,就删掉那个数据卷,让实例重新初始化。`pnpm db:init` 撞到这个错误时会打印「数据库登录失败:…(SQL Server 错误 18456)」。 + +出处:[`apps/api/backend/cli.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/cli.py)(`_format_database_connection_error`) + +### 拉了新代码后 `pnpm test` / `pnpm db:upgrade` 报 `ModuleNotFoundError`,但 `pnpm dev` 跑得起来 + +**原因**:Python 依赖归 `uv` 管,`pnpm install` 不会装它们。`dev`、`db:init`、`db:reset` 用的是裸 `uv run`,会自动同步依赖;`test`、`test:db`、`db:upgrade`、`e2e:server`、`celery:*` 带了 `--no-sync`,缺包就直接报错。所以 `pnpm dev` 能跑,不代表依赖已经装齐。 + +**处理**:`pyproject.toml` 变了以后同步一次依赖。 + +```bash +pnpm --filter api exec uv sync # 或者 pnpm install:all +``` + +出处:[`apps/api/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/AGENTS.md)「`pnpm install` 补不了后端依赖」 + +### worker 日志里出现 `Received unregistered task of type '…'`,自己的任务时有时无 + +**原因**:Celery broker 默认用 Redis 的 1 号库(`CELERY_BROKER_REDIS_DATABASE=1`)。本机另一个项目的 Celery 也用同一个 Redis 的 1 号库时,两边的 worker 消费的是同一个队列:对方的任务被这边收走,报「未注册」;这边的任务被对方收走,悄无声息地丢了。 + +**处理**:在本地 `.env` 里把 `CELERY_BROKER_REDIS_DATABASE` 改成一个空库,然后重启 worker。改之前先确认那个库确实没人在用: + +```bash +docker exec fba_redis redis-cli -n 7 DBSIZE # 返回 0 才是空库 +``` + +出处:[`apps/api/backend/app/task/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/app/task/AGENTS.md)「Redis 和别的项目共用时,broker 要换一个没人用的库」 + +## 登录与会话 + +### 用 `http://localhost:8888` 能登录,一刷新页面就回到登录页 + +**原因**:前端默认直接请求 `http://127.0.0.1:8088`(`packages/platform/src/api-client/client.ts` 的 `API_BASE`)。从 `localhost` 打开页面时,那是一次跨站请求,浏览器不会保存刷新令牌的 cookie。登录本身能成功,但整页刷新后 `/auth/refresh` 返回 401,就被送回了登录页。 + +**处理**:本地开发打开 ,不要用 `localhost:8888`。 + +出处:[`apps/docs/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/docs/AGENTS.md)「截图怎么重拍」 + +### `pnpm db:init` 之后用 `admin` / `123456` 登录不上 + +**原因**:`fba init` 灌完种子后,会强制把 admin 的默认密码 `123456` 换掉:在终端里运行会提示你输入新密码;非交互环境(`--auto`、CI、没有 TTY)读环境变量 `FBA_INIT_ADMIN_PASSWORD`,两者都没有就直接失败。种子里的示例账号 `test` 也会被停用,并换成随机密码。 + +**处理**:用初始化时设置的那个密码登录。非交互初始化这样写: + +```bash +FBA_INIT_ADMIN_PASSWORD='<强口令>' pnpm db:reset +``` + +`123456` 只在 pytest / E2E 用的 `fba_test` 测试库里有效,那个库由 `pnpm --filter api test:db` 单独建。 + +出处:[`apps/api/backend/cli.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/cli.py)(`_set_admin_password`) + +### 登录提示 `请求过于频繁,请稍后重试`(HTTP 429) + +**原因**:登录相关接口按「IP + 路径」限流,每分钟 5 次;验证码接口 `/auth/captcha` 是每 30 秒 5 次。连续输错、或者写脚本连续登录多个账号,很快就会撞上。响应头里的 `Retry-After` 是要等待的秒数。 + +**处理**:等一会儿再试。触发限流的那次失败不会换验证码,因为验证码还没被消费,继续用当前这张就行。本地批量造数据时可以临时用 `REQUEST_LIMITER_ENABLED=false` 启动后端(生产环境会拒绝这个配置)。 + +出处:[`apps/docs/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/docs/AGENTS.md)「截图怎么重拍」、[`apps/api/backend/app/admin/api/v1/auth/auth.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/app/admin/api/v1/auth/auth.py) + +### `.env` 里设了 `LOGIN_CAPTCHA_ENABLED=false`,登录页还是要验证码 + +**原因**:这个开关在运行时以数据库 `sys_config` 表里的值为准。种子数据里有一条 `LOGIN_CAPTCHA_ENABLED`,后端加载后会覆盖 `.env` 里的值。 + +**处理**:到「系统管理 → 参数配置」里把 `LOGIN_CAPTCHA_ENABLED` 改成 `false`,改完立即生效。要照常输入验证码的话,可以记下 `GET /api/v1/auth/captcha` 响应里的 `uuid`,到 Redis 里查答案: + +```bash +docker exec fba_redis redis-cli --raw GET "fba:login:captcha:" +``` + +出处:[`apps/web/e2e/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/web/e2e/AGENTS.md)「验证码不能只靠 `.env.e2e` 关」、[`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md)「本地开发」 + +## 数据库与迁移 + +### `pnpm db:init` 之后原来的数据全没了 + +**原因**:`pnpm db:init`(即 `fba init`)会先 `drop_all` 再 `create_all`,然后灌种子数据并把 Alembic 标到 head。它是给全新环境用的,执行前会让你确认(默认是 `n`)。 + +**处理**:只在首次搭环境或确实想清空数据时运行它。已有的库要更新表结构,用迁移: + +```bash +pnpm db:upgrade +``` + +出处:[`apps/api/backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md)、[`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md) + +### `种子数据里没有 admin 账号,初始化流程可能已经变了` + +**原因**:`DATABASE_PK_MODE` 不是 `'snowflake'`。三种数据库的种子 SQL 都只有雪花 ID 版本(`init_snowflake_test_data.sql`),自增模式下找不到种子文件,这一步会被跳过,没有任何提示,于是库里没有 admin 账号,初始化在最后设置管理员密码时失败。注意 `core/conf.py` 里这个配置的默认值是 `'autoincrement'`,`.env` 漏写这一行时同样会踩到。 + +**处理**:在 `apps/api/backend/.env` 里写上 `DATABASE_PK_MODE='snowflake'`(`.env.example` 里已经有这一行),然后重新运行 `pnpm db:init`。 + +出处:[`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md)「换数据库 / 接对象存储」、[`apps/api/backend/cli.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/cli.py) + +### 改了模型,后端也自动重启了,可表结构没变 + +**原因**:`pnpm dev` 里的 `--reload` 只会重启进程、重新 import 模型,不会改已经存在的表。 + +**处理**:改完模型就生成迁移,读一遍生成的文件,然后升级: + +```bash +pnpm db:revision '加 xxx 列' +pnpm db:upgrade +``` + +如果改的是字段,还要同步 `backend/sql/*/init_snowflake_test_data.sql`。那些是写了显式列名的 INSERT,漏改会让全新环境初始化失败。 + +出处:[`CLAUDE.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CLAUDE.md)「数据库结构改动一律走 alembic」、[`apps/api/backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md) + +### `Cannot insert explicit value for identity column in table '…' when IDENTITY_INSERT is set to OFF. (544)` + +**原因**:这个错误只出现在 SQL Server 上,并且只出现在**由迁移建出来的表**上。Alembic autogenerate 渲染不出模型里 Python 侧的 `default=snowflake.generate`,写出来的 id 列被 SQL Server 建成了 `IDENTITY`,之后任何 INSERT 都会失败。用 `create_all` 建的表不会这样,所以开发库和测试库都看不出问题。 + +**处理**:手写或生成建表迁移时,雪花主键列要显式加上 `autoincrement=False`。仓库里的守卫测试 `test_created_tables_declare_snowflake_pk_as_non_autoincrement` 会扫描 `alembic/versions/` 里的 `create_table` 检查这一点。 + +出处:[`apps/api/backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md)「迁移建表时雪花主键会变成 IDENTITY」 + +### 第一次跑 pytest 或 E2E 就报数据库连接错误 + +**原因**:两套测试都跑在独立的 `fba_test` 库上,不用开发库 `fba`。这个库要先手工建一次,没建的时候报的只是一句 SQLAlchemy 连接错误,看不出原因。 + +**处理**:先建一个空的 `fba_test` 库(`CREATE DATABASE` 要在别的库上执行,脚本不替你做),再让脚本建表、灌种子: + +```bash +docker exec fba_postgres createdb -U postgres fba_test # 默认的 PostgreSQL;只需一次 +pnpm --filter api test:db # 按模型重建表、灌种子,并 stamp 到 head +pnpm test +``` + +模型加了列以后,测试报 `Invalid column name …` 之类的错,也是重跑一次 `test:db`。 + +出处:[`apps/api/backend/tests/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/tests/AGENTS.md)、[`CONTRIBUTING.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CONTRIBUTING.md)「几个容易卡住的地方」 + +## 前端开发 + +### ID `2049629108245233664` 变成了 `2049629108245233700`,更新或删除改错了记录 + +**原因**:雪花 ID 大约是 2^61,超过了 JavaScript 的 `Number.MAX_SAFE_INTEGER`(2^53-1)。一旦当数字解析,就会丢失精度,连续几个 ID 甚至会变成同一个值,拿它回传就会命中别的记录。路由参数也会出这个问题:TanStack Router 默认对 search 值做 `JSON.parse`。 + +**处理**:所有 ID 一律当字符串处理,永远不要 `Number()`。后端在 `backend/utils/serializers.py` 里已经把它们转成字符串下发;路由那边由 `apps/web/src/lib/search-params.ts` 拦住超出安全范围的纯整数。新增带 ID 的 search 参数前,先确认这个自定义解析还接在 `router.ts` 上。 + +出处:[`CLAUDE.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CLAUDE.md) 硬纪律 6 + +### `pnpm e2e -- tests/xxx.spec.ts` 只想跑一个文件,结果跑了全部用例 + +**原因**:pnpm 会把 `--` 原样传给 Playwright,后面的文件名和参数全被忽略,于是跑的是全套(十几分钟),看起来只像是「这个文件好慢」。 + +**处理**:去掉 `--`,在 `apps/web` 目录下运行: + +```bash +cd apps/web && pnpm e2e tests/dept-crud.spec.ts +``` + +出处:[`apps/web/e2e/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/web/e2e/AGENTS.md) + +### 文档站构建报 `Node.js v22.23.3 is not supported by Astro!` + +**原因**:文档站(`apps/docs`)锁在 Astro 7.3.x。`astro@7.4.4` 要求 Node ≥ 22.23.4,而仓库 `engines` 写的是 ≥ 22.13。如果依赖被升到了 7.4(比如跑了 `pnpm update`),在较老的 Node 22 上就会报这一句。`pnpm build` 会经 turbo 构建文档站,所以整仓构建也会挂。 + +**处理**:回到锁文件里的版本(`pnpm install --frozen-lockfile`),或者把 Node 升到 22.23.4 以上。要正式升级 Astro,得先在根 `package.json` 的 `engines` 和 CI 的 Node 版本上做决定。 + +出处:[`apps/docs/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/docs/AGENTS.md)「Astro 锁在 7.3.x」 + +## 部署与运维 + +### `ENVIRONMENT=prod,但有 N 项配置不合格,拒绝启动` + +**原因**:`ENVIRONMENT=prod` 时后端启动前会校验配置,不合格就拒绝启动。检查项包括:`TOKEN_SECRET_KEY`、`DATABASE_PASSWORD`、`REDIS_PASSWORD` 的强度;`LOGIN_CAPTCHA_ENABLED` 和 `REQUEST_LIMITER_ENABLED` 必须为 true;`CORS_ALLOWED_ORIGINS` 不能包含 `*`、`localhost`、`127.0.0.1`;`DATABASE_USER` 不能是 `sa`、`root`、`postgres` 这类超级用户。 + +**处理**:按报错里逐条列出的问题改 `.env.server`。签名密钥可以这样生成: + +```bash +python -c "import secrets;print(secrets.token_urlsafe(32))" +``` + +出处:[`apps/api/backend/core/conf.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/core/conf.py) + +### `ENVIRONMENT=prod,数据库检查未通过,拒绝启动` + +**原因**:生产环境的后端不会自己建表,启动时会检查数据库,有三种情况会被拦下: + +- `数据库里没有 alembic_version 表`:库是 `create_all` 建的,但没有 stamp; +- `数据库迁移版本 … 不等于 head`:部署新版本前没跑迁移; +- `这些账号仍在使用种子数据里的默认密码(123456)`:绕过 `fba init` 直接灌了种子 SQL。 + +**处理**:分别对应:用 `fba init` 建库(它会自动 stamp head),或对已有库 `alembic stamp <当前版本>`;先跑 `alembic upgrade head` 再启动;把列出的账号的密码改掉。 + +出处:[`apps/api/backend/alembic/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/alembic/AGENTS.md)、[`apps/api/backend/core/registrar.py`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/api/backend/core/registrar.py) + +### 容器里加载配置时报 `PermissionError: [Errno 13] Permission denied` + +**原因**:镜像里的进程以 `fba` 用户(uid 1000)运行,而部署检查要求 `.env.server` 的权限严格是 `600`。文件属主是 root 时,宿主机上看权限没问题,容器里却读不到它。这个报错出在 pydantic-settings 内部,很容易被当成配置内容写错了。 + +**处理**: + +```bash +chown 1000:1000 .env.server +chmod 600 .env.server +``` + +出处:[`deploy/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/AGENTS.md)「`.env.server` 属主必须是 uid 1000」 + +### API 容器起来了,健康检查一直连不上数据库 + +**原因**:数据库或 Redis 跑在宿主机上(宝塔面板自带的就是这种),而 `.env.server` 里写的是 `127.0.0.1`。在容器的桥接网络里,`127.0.0.1` 指向的是容器自己。 + +**处理**:`DATABASE_HOST` / `REDIS_HOST` 填宿主机在 docker 网桥上的地址(一般是 `172.17.0.1`,可以用 `ip addr show docker0` 查),或者给服务加上 `extra_hosts: ["host.docker.internal:host-gateway"]`,再填 `host.docker.internal`。 + +出处:[`deploy/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/AGENTS.md)「容器连宿主机上的数据库,不能填 127.0.0.1」 + +### 国内服务器上 `docker compose pull` 卡住不动 + +**原因**:GHCR 在国内经常被限速到每秒几十 KB。不会报错,看着像卡住了,其实是一层镜像要下好几个小时。 + +**处理**:在 compose 同级的 `.env` 里配置 `FBA_PULL_MIRROR`(加速器域名),`prod.sh pull` 会从加速器拉,再 retag 回 `ghcr.io/…`。不要把加速器地址直接写进 compose 的镜像名。层进度条几分钟都不动,就换一个加速器域名重试。 + +出处:[`deploy/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/AGENTS.md)「GHCR 在国内会被限速到几十 KB/s」 + +### `/healthz` 一直是绿的,但 API 其实已经挂了 + +**原因**:`/healthz` 是 web 容器里的 nginx 自己 `return 200`,只能证明 nginx 还活着。经域名去探一个不存在的路径,也会被前端的 history fallback 接住,返回 200 加 `index.html`。 + +**处理**:外部监控只探 `https://<域名>/api/health/status`。它会逐项列出 api、数据库、Redis、调度器、worker 的状态,全部正常返回 200,任何一项异常返回 503。判断前看一眼响应 body,不要只看状态码。 + +出处:[`deploy/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/AGENTS.md)「健康检查」、[`deploy/README.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/deploy/README.md)「外部监控探哪几条」 + +### 发版后页面报 `Failed to fetch dynamically imported module` + +**原因**:这不是服务器错误,而是前后版本不一致。浏览器里开着的还是旧版前端,它要加载的代码分片在新部署里已经不存在了。 + +**处理**:刷新页面即可。前端已经识别这类错误,会显示「已发布新版本」和一个刷新按钮,不再显示成 500。 + +出处:[`apps/web/AGENTS.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/apps/web/AGENTS.md)「错误页分两支」 + +### `Multiple artifacts named "github-pages" were unexpectedly found for this workflow run` + +**原因**:在 GitHub Actions 里「重跑失败的 job」时,同一个 run 会再跑一次 attempt。`upload-pages-artifact` 每次都会新建一个名为 `github-pages` 的 artifact,不会覆盖上一次留下的那份,`deploy-pages` 读到两份同名的就报错退出。 + +**处理**:上传之前,先把本次 run 里同名的旧 artifact 删掉。仓库 CI 的 `pages` job 就是这么做的(需要 `actions: write` 权限),fork 后自己部署文档站可以照抄那一步。 + +出处:[`.github/workflows/ci.yml`](https://github.com/yilecoding/fastapi-react-admin/blob/main/.github/workflows/ci.yml)(`pages` job) + +## 没找到你的问题? + +先看[快速开始](/getting-started/quick-start/)和[部署到服务器](/getting-started/deployment/)的步骤是否都走过;仍然不行,到 [GitHub Issues](https://github.com/yilecoding/fastapi-react-admin/issues) 贴上报错原文。 diff --git a/apps/docs/src/content/docs/reference/upgrade.md b/apps/docs/src/content/docs/reference/upgrade.md new file mode 100644 index 00000000..3ec167aa --- /dev/null +++ b/apps/docs/src/content/docs/reference/upgrade.md @@ -0,0 +1,110 @@ +--- +title: 版本升级 +description: 怎么从旧版本升级:直接用镜像部署的拉新镜像即可;fork 后写了业务的按 tag 合并上游,再跑迁移。附兼容承诺和升级前检查清单。 +sidebar: + order: 2 +--- + +升级方式取决于你怎么用这个项目:**直接用镜像部署、没改过代码**的,拉新镜像、重启就行,迁移会自动跑;**fork 之后在上面写了自己业务**的,要把上游的新版本合并进你的仓库,再跑迁移和测试。每个版本的变化写在 [GitHub Releases](https://github.com/yilecoding/fastapi-react-admin/releases) 里,每份发版说明都有一节「升级」,写明有没有数据库迁移、要不要改配置。 + +## 版本号怎么定 + +按 semver:合进一个大功能升次版本号(0.11 → 0.12),只有修复升修订号(0.9.0 → 0.9.1)。现在还是 0.x,但已经按下面的兼容约定执行。 + +## 哪些东西承诺不破坏 + +下表来自根目录 [`CLAUDE.md`](https://github.com/yilecoding/fastapi-react-admin/blob/main/CLAUDE.md) 的「1.0 起的兼容约定」。破坏其中任何一条都要升主版本号。 + +| 对外的面 | 承诺 | +|---|---| +| 开放接口 `/api/open/v1/*` 和三语言 SDK | 路径、参数、响应字段、错误码、签名算法不删、不改语义;只加可选参数和新字段。真要破坏就开 `/api/open/v2/*`,v1 保留 | +| Webhook 事件 | 事件名不改,载荷字段不删、不改名,签名方式不变;可以加事件、加字段 | +| 数据库迁移链 | 从任何一个已发布的版本都能 `pnpm db:upgrade` 升到最新;进过 tag 的迁移文件不再改,修错写新迁移 | +| 插件接口 | `plugin.toml` 字段、安装流程、扩展点保持可用,第三方插件照旧能装能跑 | +| 配置键 | `.env` 的键名和参数配置的键不改名;真要改,新旧键并存一个次版本 | +| 内部接口 `/api/v1/*` | 不是公共 API,但会照顾已经装出去的桌面端和移动端:删字段、改路径前会让新旧并存一个次版本 | + +不在表里的(页面、组件、`packages/*` 的内部导出)可能随版本调整。如果你直接改过这些地方,合并时留意冲突。 + +迁移链的承诺实测过:2026-10-31 在 MySQL 8.4 上,用 v0.0.1 建的库一路升到当时的最新版,和模型比对结构差异为 0。 + +## 直接用镜像部署的怎么升级? + +```bash +bash prod.sh status # 服务器上跑的是哪个提交、落后 main 多少 +bash prod.sh pull # 拉新镜像 +bash prod.sh up # 先跑迁移(一次性的 migrate 容器),成功了才启动服务 +``` + +**验收**:`bash prod.sh status` 显示的提交和你要升的版本一致;`https://<你的域名>/api/health/status` 返回 200。 + +升级前先备份数据库。新版本有问题时,按提交号回滚到旧镜像: + +```bash +TAG=sha-abc1234 bash prod.sh pull +TAG=sha-abc1234 bash prod.sh up +``` + +:::caution +回滚镜像**不会回滚数据库**。新版本如果带了迁移,回到旧镜像前要确认旧代码能跑在新表结构上,或者先用备份恢复数据库。发版说明的「升级」一节会写这一版有没有迁移。 +::: + +详细命令见[部署到服务器](/getting-started/deployment/)。 + +## fork 后写了自己业务的怎么升级? + +把上游加成一个远程仓库,**按 tag 合并**(不要直接合 `main`,`main` 上可能有还没发版的改动): + +```bash +git remote add upstream https://github.com/yilecoding/fastapi-react-admin.git # 只需一次 +git fetch upstream --tags +git merge v0.12.0 # 换成你要升到的版本 +``` + +解决完冲突后: + +```bash +pnpm install:all # 依赖可能有变化 +pnpm db:upgrade # 跑上游新加的迁移 +pnpm typecheck --force # 类型检查别信缓存 +pnpm test # 后端测试(需要本地 fba_test 库,见 CONTRIBUTING.md) +``` + +**验收**:`pnpm db:current` 显示在 head;`pnpm typecheck --force` 和后端测试全绿;`pnpm dev` 起来后登录、打开几个你自己的业务页面看一眼。 + +### 合并后出现两个迁移 head 怎么办? + +你自己写过迁移、上游这一版也加了迁移时,迁移链会分叉成两个 head,`pnpm db:upgrade` 会报多个 head。用 Alembic 生成一个合并迁移: + +```bash +cd apps/api/backend +uv run alembic heads # 确认是两个 head +uv run alembic merge heads -m "merge upstream v0.12.0" +cd ../../.. && pnpm db:upgrade +``` + +仓库里的守卫测试 `test_single_head` 也会在出现两个 head 时变红,提醒你做这一步。 + +:::caution +**不要改上游的迁移文件**,哪怕合并冲突落在里面。已经升级过的库不会重新执行旧迁移,改了只会让新装的库和老库对不上,而且不会报错。修错写一个新迁移。 +::: + +### 冲突一般出在哪? + +出在你和上游都改过的文件。常见的有:菜单和权限的种子数据(`backend/sql/*/init_snowflake_test_data.sql`)、前端的页面注册表(`apps/web/src/lib/page-registry.tsx`)、侧边栏和路由文件。把自己的业务尽量放在新文件、新目录、新插件里,不去改底座的文件,以后升级的冲突就少。 + +## 桌面端和移动端怎么升级? + +- 桌面端:从 [Releases](https://github.com/yilecoding/fastapi-react-admin/releases) 下载新安装包覆盖安装。应用内的自动更新要由你的页面主动调用检查,见[桌面端与移动端](/features/clients/) +- 移动端:重新构建并分发 App + +旧版客户端会拿旧前端调新后端,所以内部接口的删改会保留一个次版本的过渡期(见上面的兼容约定)。 + +## 升级前检查清单 + +- [ ] 读过目标版本(以及跳过的每个版本)发版说明里的「升级」一节 +- [ ] 备份了数据库 +- [ ] 确认这一版有没有迁移、要不要改配置 +- [ ] 测试环境先升一遍,跑过冒烟测试(登录、几个核心页面、一条审批) +- [ ] 准备好回滚用的镜像标签(`sha-<旧提交>`) +- [ ] 生产升级后检查 `/api/health/status` 返回 200
{err}