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" + > +
+ + + + + + +
+ + + + + + +
+ +