新增业务模块
本教程在已有 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
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
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
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
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
from .note import router
__all__ = ["router"]models/__init__.py
from .note import Note
__all__ = ["Note"]schemas/__init__.py
from .note import NoteCreate, NoteOut
__all__ = ["NoteCreate", "NoteOut"]service/__init__.py
from .note import note_service
__all__ = ["note_service"]4. 注册路由并生成迁移
在 app/main.py 的路由注册位置加入:
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,然后在后端目录执行:
uv run alembic revision --autogenerate -m "add business notes"同时在 app/core/tenant_inventory.py 的 TENANT_MODEL_INVENTORY 中登记资源,保留已有条目:
"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 表、外键和索引;如果出现其他表的删除,不要执行,先检查模型导入是否完整。确认后在开发库运行:
uv run alembic upgrade head重启后端,在 /docs 中应出现 Notes 分组和两个接口。不要在启动代码中调用 Base.metadata.create_all() 代替迁移。
5. 接入完整 Web 页面、翻译和菜单
以下所有 Web 路径相对 hohu-admin-web/。本例提供中英文页面;实际模块的语言范围遵从需求,单语言模块不需要修改框架国际化。先完成翻译接线,再运行类型检查;否则新增 $t 键会报类型错误。
请求和页面
先将业务类型保存为 src/typings/api/notes.d.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:
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:
<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 助手和错误文本,下一篇不需要重复添加:
// 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';。两个文件都按以下位置合并,保留原有属性,不用本段替换整个语言文件:
// 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 中做四处扩展:
// 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:
"""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 = [...] 列表结束后加入一行:
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 指向后端仓库后运行此文件;测试不会连接业务数据库。
在文档仓库目录使用已准备好的后端虚拟环境运行(替换示例后端路径):
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' -vPowerShell 中设置 $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/ 中本次生成并审核的迁移文件 |
| Web | src/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 工具集合中。
