Skip to content

新增业务模块 ​

本教程在已有 HoHu 项目中增加“业务便签”,完成创建和分页查看两个操作。先完成开发环境。示例是源码扩展,不是独立插件安装协议。

步骤速查 ​

阶段产出
确定边界接口、权限码与租户共享策略
后端接入模型、Schema、Service、API 与迁移
Web 接入类型、请求、页面、翻译与菜单
授权验收普通测试角色、权限拒绝与租户隔离验证

首次按顺序完成;已熟悉模块结构时,可直接通过右侧目录定位示例。

独立项目与文件清单 ​

首次学习请先按开发环境用 hohu create notes-tutorial 创建只包含 Backend 和 Frontend 的新项目,再执行 hohu init 和 hohu dev,完成首次登录。使用专用数据库和独立 Redis 实例;不要沿用已有应用的 .env、密钥或数据库。与其他项目并行运行时还需调整端口及前端代理,不能只修改浏览器地址。以下命令中的“后端目录”和“Web 目录”分别指这个新项目的 hohu-admin/ 与 hohu-admin-web/。

文档仓库的 examples/notes/ 是示例来源,不是运行目录。将 Python 文件按下方包结构复制到后端 app/modules/notes/,包含各层 __init__.py;将 notes.d.ts、notes.ts、index.vue、locales.ts 分别放到后文指定的 Web 路径。ai_tools/note.py 与 service/projection.py 留到下一篇再接入。不要把整份 examples 目录直接放进后端。

1. 确定边界 ​

同一租户中,拥有查看权限的用户可以读取该租户的全部便签;只有拥有新增权限的用户可以创建。本章暂不实现个人私有便签、部门范围、编辑和删除;AI 工具在下一章接入。需要这些能力时,应先补充相应权限和回归测试。

项目约定
数据表biz_note
接口GET /business/notes、POST /business/notes
查看权限business:note:list
创建权限business:note:add
前端页面src/views/notes/index.vue
租户来源认证后的 TenantContext,不从请求体接收

2. 创建模型和输入输出 ​

在后端按目录结构创建 app/modules/notes/。根级 __init__.py 为空,api/、models/、schemas/、service/ 分别包含下文的 note.py 与包导出。示例来源为 examples/notes/,Web 文件按各自目标路径复制。

models/note.py

py
from sqlalchemy import BigInteger, ForeignKey, Index, String
from sqlalchemy.orm import Mapped, mapped_column

from app.core.id_generator import next_id
from app.db.base import Base


class Note(Base):
    __tablename__ = "biz_note"
    __table_args__ = (Index("ix_biz_note_tenant_id_note_id", "tenant_id", "note_id"),)

    note_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, default=next_id)
    tenant_id: Mapped[int] = mapped_column(
        BigInteger,
        ForeignKey("sys_tenant.tenant_id", ondelete="RESTRICT"),
        nullable=False,
    )
    title: Mapped[str] = mapped_column(String(120), nullable=False)

联合索引服务于租户内按 ID 分页。外键约束租户必须存在;如果以后关联客户、订单等业务对象,还要增加同租户关联约束。

schemas/note.py

py
from pydantic import BaseModel, ConfigDict, Field, field_serializer
from pydantic.alias_generators import to_camel


class NoteCreate(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
    title: str = Field(min_length=1, max_length=120)


class NoteOut(BaseModel):
    model_config = ConfigDict(
        alias_generator=to_camel, populate_by_name=True, from_attributes=True
    )
    note_id: int
    title: str

    @field_serializer("note_id")
    def serialize_id(self, value: int) -> str:
        return str(value)

创建请求拒绝未声明字段,避免客户端传入 tenantId。输出使用 noteId,值为字符串,以保留 Snowflake ID 精度。便签标题属于用户数据,不随界面语言翻译。

3. 实现服务与接口 ​

service/note.py

py
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.base_response import PageResult
from app.core.tenant import TenantContext
from app.core.tenant_scope import tenant_filter, tenant_select, tenant_values

from ..models import Note
from ..schemas import NoteCreate, NoteOut


class NoteService:
    async def create(
        self, db: AsyncSession, body: NoteCreate, *, tenant: TenantContext
    ) -> Note:
        note = Note(**tenant_values(body.model_dump(), tenant=tenant))
        db.add(note)
        await db.flush()
        return note

    async def page(
        self, db: AsyncSession, *, tenant: TenantContext, current: int, size: int
    ) -> PageResult[NoteOut]:
        total = await db.scalar(
            select(func.count())
            .select_from(Note)
            .where(tenant_filter(Note, tenant=tenant))
        )
        query = (
            tenant_select(Note, tenant=tenant)
            .order_by(Note.note_id.desc())
            .offset((current - 1) * size)
            .limit(size)
        )
        rows = (await db.scalars(query)).all()
        return PageResult(
            records=[NoteOut.model_validate(row) for row in rows],
            total=total or 0,
            current=current,
            size=size,
        )


note_service = NoteService()

列表和总数使用相同租户范围。Service 只 flush,不 commit;排序显式使用 ID。本例展开分页 SQL 便于理解作用域,通用模块也可以复用分页工具。

api/note.py

py
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.auth import require_permissions
from app.core.base_response import PageResult, ResponseModel
from app.core.tenant import TenantContext
from app.db.session import get_db
from app.modules.auth.service import get_current_tenant_context

from ..schemas import NoteCreate, NoteOut
from ..service import note_service

router = APIRouter(prefix="/business/notes", tags=["Notes"])


@router.get(
    "",
    response_model=ResponseModel[PageResult[NoteOut]],
    dependencies=[Depends(require_permissions("business:note:list"))],
)
async def list_notes(
    current: int = Query(1, ge=1),
    size: int = Query(10, ge=1, le=100),
    db: AsyncSession = Depends(get_db),
    tenant: TenantContext = Depends(get_current_tenant_context),
):
    return ResponseModel.success(
        await note_service.page(db, tenant=tenant, current=current, size=size)
    )


@router.post(
    "",
    response_model=ResponseModel[NoteOut],
    dependencies=[Depends(require_permissions("business:note:add"))],
)
async def create_note(
    body: NoteCreate,
    db: AsyncSession = Depends(get_db),
    tenant: TenantContext = Depends(get_current_tenant_context),
):
    note = await note_service.create(db, body, tenant=tenant)
    result = NoteOut.model_validate(note)
    await db.commit()
    return ResponseModel.success(result)

API 层认证、检查功能权限、取得可信租户上下文,并在创建成功后提交事务。不要用前端隐藏按钮替代接口权限,也不要把业务异常改成未约定的响应结构。

包导出 ​

分别保存以下 __init__.py,使注册入口与相邻层能够导入对应对象;其余模块初始化文件保持为空。

api/__init__.py

python
from .note import router

__all__ = ["router"]

models/__init__.py

python
from .note import Note

__all__ = ["Note"]

schemas/__init__.py

python
from .note import NoteCreate, NoteOut

__all__ = ["NoteCreate", "NoteOut"]

service/__init__.py

python
from .note import note_service

__all__ = ["note_service"]

4. 注册路由并生成迁移 ​

在 app/main.py 的路由注册位置加入:

python
from app.modules.notes.api import router as notes_router

app.include_router(notes_router)

在 alembic/env.py 的模型导入区加入 from app.modules.notes.models import Note,确保模型进入已有的 Base.metadata,然后在后端目录执行:

bash
uv run alembic revision --autogenerate -m "add business notes"

同时在 app/core/tenant_inventory.py 的 TENANT_MODEL_INVENTORY 中登记资源,保留已有条目:

python
"biz_note": _resource("biz_note", "app.modules.notes.models:Note"),

在 tests/core/test_tenant_inventory.py 的预期清单加入该表,并保留租户索引、归属不重叠等检查。迁移生成后,将新 revision 加入 tests/test_release_migration_roundtrip.py 的实际迁移链与对应回归;历史迁移测试仍指向原来的 revision,不要统一替换为最新版本。

审查迁移只包含预期的 biz_note 表、外键和索引;如果出现其他表的删除,不要执行,先检查模型导入是否完整。确认后在开发库运行:

bash
uv run alembic upgrade head

重启后端,在 /docs 中应出现 Notes 分组和两个接口。不要在启动代码中调用 Base.metadata.create_all() 代替迁移。

5. 接入完整 Web 页面、翻译和菜单 ​

以下所有 Web 路径相对 hohu-admin-web/。本例提供中英文页面;实际模块的语言范围遵从需求,单语言模块不需要修改框架国际化。先完成翻译接线,再运行类型检查;否则新增 $t 键会报类型错误。

请求和页面 ​

先将业务类型保存为 src/typings/api/notes.d.ts:

ts
declare namespace Api {
  namespace Notes {
    interface Note {
      noteId: string;
      title: string;
    }

    interface Page {
      records: Note[];
      total: number;
      current: number;
      size: number;
    }
  }
}

将请求文件保存为 src/service/api/notes.ts:

ts
import { request } from '@/service/request';

export function fetchNotes(current = 1, size = 10) {
  return request<Api.Notes.Page>({
    url: '/business/notes',
    method: 'get',
    params: { current, size }
  });
}

export function createNote(title: string) {
  return request<Api.Notes.Note>({ url: '/business/notes', method: 'post', data: { title } });
}

在 src/service/api/index.ts 中追加 export * from './notes';,保留已有导出。页面使用 Api.Notes 类型,业务类型不放在请求文件里。

创建 src/views/notes/index.vue:

vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { NButton, NCard, NDataTable, NForm, NFormItem, NInput, NPagination, NSpace, useMessage } from 'naive-ui';
import type { DataTableColumns } from 'naive-ui';
import { useAuth } from '@/hooks/business/auth';
import { $t } from '@/locales';
import { createNote, fetchNotes } from '@/service/api/notes';

const { hasAuth } = useAuth();
const message = useMessage();
const title = ref('');
const records = ref<Api.Notes.Note[]>([]);
const total = ref(0);
const current = ref(1);
const size = ref(10);
const loading = ref(false);
const saving = ref(false);
let requestVersion = 0;
const columns = computed<DataTableColumns<Api.Notes.Note>>(() => [
  { title: $t('notes.title'), key: 'title', minWidth: 180 },
  { title: $t('notes.id'), key: 'noteId', width: 200 }
]);

async function loadNotes() {
  const version = ++requestVersion;
  loading.value = true;
  try {
    const { data, error } = await fetchNotes(current.value, size.value);
    if (version !== requestVersion) return;
    if (error || !data) {
      records.value = [];
      total.value = 0;
      return;
    }
    records.value = data.records;
    total.value = data.total;
  } finally {
    if (version === requestVersion) loading.value = false;
  }
}

function changePage(page: number) {
  current.value = page;
  void loadNotes();
}

function changeSize(value: number) {
  size.value = value;
  changePage(1);
}

async function saveNote() {
  if (saving.value || !hasAuth('business:note:add')) return;
  const value = title.value.trim();
  if (!value || [...value].length > 120) {
    message.warning($t('notes.titleRequired'));
    return;
  }
  saving.value = true;
  try {
    const { error } = await createNote(value);
    if (error) return;
    title.value = '';
    message.success($t('notes.created'));
    current.value = 1;
    await loadNotes();
  } finally {
    saving.value = false;
  }
}

onMounted(() => void loadNotes());
</script>

<template>
  <NSpace vertical :size="16">
    <NCard v-if="hasAuth('business:note:add')" :title="$t('notes.create')">
      <NForm @submit.prevent="saveNote">
        <NFormItem :label="$t('notes.title')">
          <NInput v-model:value="title" :disabled="saving" :placeholder="$t('notes.placeholder')" />
        </NFormItem>
        <NButton type="primary" attr-type="submit" :loading="saving" :disabled="saving">
          {{ $t('notes.create') }}
        </NButton>
      </NForm>
    </NCard>
    <NCard :title="$t('notes.list')">
      <NSpace vertical :size="16">
        <NButton :loading="loading" @click="loadNotes">{{ $t('notes.refresh') }}</NButton>
        <NDataTable
          :columns="columns"
          :data="records"
          :row-key="(row: Api.Notes.Note) => row.noteId"
          :loading="loading"
          :scroll-x="380"
        />
        <NPagination
          :page="current"
          :page-size="size"
          :item-count="total"
          :page-sizes="[10, 20, 50]"
          :page-slot="3"
          show-size-picker
          @update:page="changePage"
          @update:page-size="changeSize"
        />
      </NSpace>
    </NCard>
  </NSpace>
</template>

页面包含创建表单、标题校验、保存状态、刷新、分页和按钮权限。失败时保留输入,成功后回到第一页;列表使用请求序号防止较早的响应覆盖较新的分页结果。服务端仍是权限与租户隔离的最终边界。

中英文资源与类型 ​

将下面的共享资源保存为 src/locales/notes.ts,其中同时包含页面、菜单、AI 助手和错误文本,下一篇不需要重复添加:

ts
// Copy to src/locales/notes.ts. Merge into the existing global messages.
export const notesZh = {
  notes: {
    title: '标题',
    id: '编号',
    create: '创建便签',
    list: '业务便签',
    refresh: '刷新',
    placeholder: '输入 1–120 个字符的标题',
    titleRequired: '请输入 1–120 个字符的标题',
    created: '便签已创建'
  },
  route: { notes: '业务便签' },
  permission: { business_note_list: '查看便签', business_note_add: '创建便签' },
  agent: { notes: { name: '业务便签助手', description: '查询业务便签,并在确认后创建便签' } },
  tool: { note: { field: { title: '标题' }, create: { confirm: '创建便签:{title}' } } },
  chat: { confirmNoteCreate: '创建便签:{title}' },
  errorCode: {
    NOTE_TITLE_INVALID: '请输入 1–120 个字符的标题',
    NOTE_APPROVAL_REQUIRED: '需要匹配的操作确认,请重新发起创建'
  }
};

export const notesEn = {
  notes: {
    title: 'Title',
    id: 'ID',
    create: 'Create note',
    list: 'Business notes',
    refresh: 'Refresh',
    placeholder: 'Enter a title of 1–120 characters',
    titleRequired: 'Enter a title of 1–120 characters',
    created: 'Note created'
  },
  route: { notes: 'Business notes' },
  permission: { business_note_list: 'View notes', business_note_add: 'Create notes' },
  agent: {
    notes: { name: 'Business notes assistant', description: 'Query business notes and create them after confirmation' }
  },
  tool: { note: { field: { title: 'Title' }, create: { confirm: 'Create note: {title}' } } },
  chat: { confirmNoteCreate: 'Create note: {title}' },
  errorCode: {
    NOTE_TITLE_INVALID: 'Enter a title of 1–120 characters',
    NOTE_APPROVAL_REQUIRED: 'A matching approval is required. Start a new creation request.'
  }
};

在 src/locales/langs/zh-cn.ts 顶部添加 import { notesZh as notesMessages } from '../notes';;在 en-us.ts 顶部添加 import { notesEn as notesMessages } from '../notes';。两个文件都按以下位置合并,保留原有属性,不用本段替换整个语言文件:

typescript
// Inside the existing local object:
notes: notesMessages.notes,
// Replace the builtin shorthand:
builtin: {
  ...builtin,
  permission: { ...builtin.permission, ...notesMessages.permission },
  agent: { ...builtin.agent, ...notesMessages.agent }
},
// Inside route:
...notesMessages.route,
// Inside root errorCode, not page.errorCode:
...notesMessages.errorCode,
// Inside ai.tool:
...notesMessages.tool,
// page.ai.chat:
...notesMessages.chat,

在 src/typings/app.d.ts 的 App.I18n.Schema 中做四处扩展:

typescript
// At the Schema root:
notes: typeof import('../locales/notes').notesEn.notes;
// Inside Schema.ai.tool:
note: typeof import('../locales/notes').notesEn.tool.note;
// Inside Schema.errorCode:
NOTE_TITLE_INVALID: string;
NOTE_APPROVAL_REQUIRED: string;
// Schema.page.ai.chat:
confirmNoteCreate: string;

route 已允许字符串键;builtin.permission 与 builtin.agent 已使用 Record 类型,不需要添加硬编码类型。不要修改自动生成的路由类型,dev server 会随页面生成 notes 路由。

完整菜单种子 ​

将下面文件保存为后端 app/modules/notes/menu_seed.py:

py
"""Append these entries to the application's MENU_DEFINITIONS before syncing."""

NOTE_MENUS = [
    {
        "route_name": "notes",
        "parent_route": "0",
        "menu_name": "业务便签",
        "menu_type": "C",
        "icon": "carbon:document",
        "icon_type": "1",
        "component": "layout.base$view.notes",
        "layout": "base",
        "page": "notes",
        "route_path": "/notes",
        "i18n_key": "route.notes",
        "order": 20,
        "status": "1",
        "hide_in_menu": False,
        "keep_alive": False,
        "constant": False,
        "multi_tab": False,
    },
    {
        "key": "business_note_list",
        "parent_route": "notes",
        "menu_name": "查看便签",
        "menu_type": "F",
        "permission": "business:note:list",
        "route_path": "",
        "status": "1",
    },
    {
        "key": "business_note_add",
        "parent_route": "notes",
        "menu_name": "创建便签",
        "menu_type": "F",
        "permission": "business:note:add",
        "route_path": "",
        "status": "1",
    },
]

在 app/modules/system/menu_seed.py 的导入区加入 from app.modules.notes.menu_seed import NOTE_MENUS,在完整 MENU_DEFINITIONS = [...] 列表结束后加入一行:

python
MENU_DEFINITIONS.extend(NOTE_MENUS)

这行只添加一次。同步器会为按钮生成 builtin.permission.business_note_list 和 builtin.permission.business_note_add,上面的资源已提供对应翻译。

本步骤是新模块开发后的增量菜单同步,区别于首次 hohu init 已完成的基础初始化。在后端目录执行 uv run python -m scripts.sync_menus。在项目根目录运行 hohu dev,等待前端路由自动生成,再在 Web 目录运行 pnpm typecheck。后端 /docs 中有 Notes 分组、前端存在 /notes 路由后继续授权。

创建验收角色 ​

用开发实例管理员进入“角色管理”,创建专用测试角色,授予“业务便签”页面及“查看便签”“创建便签”按钮。创建一个普通测试用户并分配该角色,使用独立浏览器会话登录,打开业务便签页面;不要只用管理员验证。管理员需要已有角色菜单授权和用户角色授权权限。

菜单同步不等于自动授权,旧登录状态需退出再登录刷新。默认租户是本教程的首个验证范围;hosted 租户需要另外配置能力目录和菜单同步,见多租户管理。不要把“切换租户后菜单不存在”当成数据隔离验证成功。

6. 验证闭环 ​

先在新项目的后端目录运行 uv run ruff check app/modules/notes,在 Web 目录运行 pnpm typecheck。然后使用普通测试用户完成下表,并保留实际结果;编译通过不等于操作验收通过。

通过 /docs 使用开发账号的 access token 授权,向 POST 接口提交 {"title":"First note"}。响应应包含 code: 200 和字符串 data.noteId。GET 接口应返回 records、total、current、size。

验证预期
创建后刷新列表能找到刚创建的标题
提交空标题或 tenantId输入校验拒绝
普通角色没有新增权限POST 被拒绝,即使自行构造请求
切换到另一个租户列表和总数均不包含原租户便签
Service 后回滚事务新增记录不落库

文档仓库提供内存 SQLite 验证 tests/test_notes_example.py,覆盖列表和 count 隔离、分页、输入、ID 字符串化与回滚。它不替代 PostgreSQL 迁移、HTTP 认证和浏览器测试。使用后端虚拟环境,设置 HOHU_BACKEND_PATH 指向后端仓库后运行此文件;测试不会连接业务数据库。

在文档仓库目录使用已准备好的后端虚拟环境运行(替换示例后端路径):

bash
export HOHU_BACKEND_PATH=/path/to/hohu-admin
export PYTHONDONTWRITEBYTECODE=1
"$HOHU_BACKEND_PATH/.venv/bin/python" -m unittest discover -s tests -p 'test_notes*.py' -v

PowerShell 中设置 $env:HOHU_BACKEND_PATH 与 $env:PYTHONDONTWRITEBYTECODE,然后使用后端 .venv/Scripts/python.exe 执行相同 unittest 参数。

完成后将迁移、模块、菜单、前端页面和双语资源一起纳入版本管理。生产发布通过既有 CLI 流程执行,不把示例测试或演示数据加入初始化脚本。

完成时应有的文件 ​

位置新增或修改
后端 app/modules/notes/各包 __init__.py、models/note.py、schemas/note.py、service/note.py、api/note.py、menu_seed.py
后端注册app/main.py、alembic/env.py、app/core/tenant_inventory.py、app/modules/system/menu_seed.py
数据库alembic/versions/ 中本次生成并审核的迁移文件
Websrc/typings/api/notes.d.ts、src/service/api/notes.ts、src/views/notes/index.vue、src/locales/notes.ts
Web 接线src/service/api/index.ts、两个 src/locales/langs/*.ts 语言文件、src/typings/app.d.ts、自动生成的路由文件

若菜单存在但页面加载失败,检查文件目标路径、dev server 路由生成日志以及 component 是否为 layout.base$view.notes。若页面出现语言键而非文字,检查资源是否合并到正确层级。若保存失败,不要直接在数据库插入记录绕过接口,先检查接口响应和当前角色权限。

下一步:接入 AI ​

继续让 AI 使用业务模块,为便签接入查询工具和确认后创建。HTTP 接口和菜单完成后,不会自动出现在 AI 工具集合中。

页面状态与请求约定见 Web 页面与请求;提交前检查和排错见调试与验证。