liuyun f9b801a047
Some checks are pending
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
docs(spec): 脚手架可定制面 + 升级同步策略
- guide.md 补「升级同步(git upstream+merge)+ 可定制面三条铁律」
- shared/platform 主 spec 新增「脚手架可定制面」需求

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 23:06:12 +08:00

610 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 通用系统管理脚手架 · 开发帮助手册
> 面向 fork 本工程进行二次开发的工程师。读完能跑起来、能看懂结构、能照着新增功能。
> 更底层的架构与 spec 约定见 [`docs/architecture.md`](./architecture.md)、[`docs/spec-conventions.md`](./spec-conventions.md),工作流见根目录 [`CLAUDE.md`](../CLAUDE.md)。
---
## 0. 功能介绍与功能清单
一套可 fork 的「通用系统管理脚手架」:开箱即用后台基础能力,fork 后只改业务、不重复造轮子。后端 NestJS + Prisma + MySQL,前端 Vue 3 + Vite + Pinia + UnoCSS。
| 功能块 | 说明 |
|---|---|
| **认证** | 登录 / 登出;JWT(access 2h + refresh 7d),401 自动刷新;首登强制改密;注册/忘记密码默认隐藏(`showRegister`/`showForgetPassword` 可开启) |
| **权限管理** | 用户(增删改/重置密码/启停/锁定解锁)、角色(分配资源 + 数据权限)、资源(菜单/按钮/接口统一资源树)、组织(部门树) |
| **系统管理** | 租户、字典(类型 + 项,支持内置保护)、参数配置(键值) |
| **系统监控** | 操作日志、登录日志(只读查询)、在线会话(强退单会话 / 踢全部) |
| **布局与主题** | 多布局(左右 / 上下 / 纯上下 + MixBar)、暗黑模式、主题色、多页签(keep-alive 状态保留)、面包屑、全屏 |
| **平台能力** | 多租户隔离(tenantCode)、RBAC(资源表统一授权 + 数据权限 data_scope)、审计日志落库、字典驱动渲染 |
**代码规范**:本工程用 openspec 追踪需求与变更(spec-driven),新增功能走 `propose → apply → archive` 流程,见 [`CLAUDE.md`](../CLAUDE.md)。本文只讲「代码怎么改」,不讲「流程怎么走」。
---
## 1. 快速开始
### 1.1 环境要求
| 依赖 | 版本 |
|---|---|
| Node | 22.23.1(以 `.nvmrc` / `engines` 为准) |
| pnpm | 12.10.1(以 `packageManager` 为准) |
| MySQL | 8.x |
| Redis | 6+ |
### 1.2 拉取与安装
```bash
git clone <仓库地址> && cd <目录>
git submodule update --init --recursive # 拉取 sysmgr-nest / sysmgr-admin
# 后端
cd sources/sysmgr-nest
cp .env.example .env # 若无 .env.example 则手建,见下
pnpm install
npx prisma migrate deploy # 应用已有迁移(开发期可用 migrate dev)
pnpm seed # 初始化租户/用户/角色/资源/内置字典
# 前端
cd ../sysmgr-admin
pnpm install
```
### 1.3 `.env`(后端)
```env
DATABASE_URL="mysql://root:密码@127.0.0.1:3306/sysmgr"
REDIS_HOST="127.0.0.1"
REDIS_PORT=6379
REDIS_DB=0
REDIS_KEY_PREFIX="sysmgr:"
JWT_SECRET="请替换为强密钥(生产至少 32 位)"
JWT_ACCESS_TTL=7200
JWT_REFRESH_TTL=604800
PORT=3000
```
### 1.4 启动
```bash
# 后端(:3000,全局前缀 /api/v1)
cd sources/sysmgr-nest && pnpm start:dev
# 前端(Vite,直连 VITE_API_BASE_URL,默认 http://localhost:3000/api/v1,见 .env)
cd sources/sysmgr-admin && pnpm dev:dev
```
### 1.5 默认账号
| 项 | 值 |
|---|---|
| 租户 | `touyun` |
| 用户名 | `touyun` |
| 密码 | `Touyun@123` |
| 角色 | `super_admin`(超管,绕过权限校验、数据权限看全量) |
---
## 2. 后端开发指南(sysmgr-nest)
### 2.1 目录与分层
```
src/
├── auth/ # 认证:登录/刷新/登出、菜单树、JWT 守卫、权限守卫、装饰器、请求上下文
├── users/ roles/ resources/ orgs/ dicts/ configs/ tenants/ audit-logs/ login-logs/ sessions/
│ # 每个域一个目录:<name>.module.ts / controller / service / dto
├── prisma/ # PrismaService:统一注入多租户过滤 + 软删过滤
├── common/ # BaseCrudService、enums、permissions、page.dto、拦截器/过滤器、Redis
└── logging/ # pino 日志、请求体日志拦截器
```
每个域遵循同构结构(以 `tenants` 为例):
```
tenants/
├── tenants.module.ts # 声明 controller + service
├── tenants.controller.ts # 路由 + @RequirePermissions + @AuditLog
├── tenants.service.ts # extends BaseCrudService,写业务
└── dto.ts # class-validator DTO(创建/更新/查询)
```
### 2.2 Prisma 约定
- 表名 `sys_` 前缀,列名 snake_case(模型 camelCase + `@map`)。
- 主键 UUID v7:`id String @id @default(uuid(7)) @db.VarChar(36)`。
- 业务键关联用 `code`(角色/资源/组织/字典/租户),引用用户用 `id`,树引用用 `parent_code`。
- 业务软删实体带 `created_at / created_by / updated_at / deleted_at`;日志/会话实体无 `deleted_at`。
- 多租户:业务实体一律带 `tenant_code`。
### 2.3 BaseCrudService + PrismaService
所有域 service 继承 `BaseCrudService(prisma, '模型名')`,直接获得:
| 方法 | 说明 |
|---|---|
| `paginate(page, pageSize, where?, orderBy?)` | 分页,返回 `{ list, total, page, pageSize }` |
| `list(where?, orderBy?)` | 列表 |
| `findById(id)` | 按 id 查 |
| `create(data)` | 创建,自动注入 `tenantCode`(Tenant 除外) |
| `update(id, data)` | 更新 |
| `softRemove(id)` | 软删(置 `deletedAt`) |
`PrismaService` 通过 `$extends` 全局拦截,**无需手写**:
- 读操作自动追加 `tenantCode` 过滤(`Tenant` 模型除外);
- 软删模型(User/Role/Resource/Org/DictType/DictItem/Config)读操作自动追加 `deletedAt: null`。
> 因此业务 service 里写 `this.list({ status: 'enabled' })` 即可,租户与软删已由框架兜底。
### 2.4 RBAC:资源表统一授权
- 菜单、按钮、接口统一在 `resource` 表,`type ∈ { menu, button, api }`。
- `api`/`button` 型资源的 `code` 即**权限码**,形如 `domain:action`(`user:list`、`user:create`)。
- 权限码清单在 [`src/common/permissions.ts`](../sources/sysmgr-nest/src/common/permissions.ts),控制器与 seed 共用。
**三级鉴权**(由全局 `JwtAuthGuard` + `PermissionsGuard` 实现):
| 层级 | 写法 | 效果 |
|---|---|---|
| 公开 | `@Public()` | 无需 token |
| 仅登录 | 不加权限装饰器 | 只要登录态 |
| 权限码 | `@RequirePermissions(Permissions.user.list)` | 登录 + 拥有该权限码 |
```ts
@Get()
@RequirePermissions(Permissions.user.list) // 登录 + user:list
list(@Query() q: PageQuery) { return this.service.paginate(q.page, q.pageSize) }
```
- 角色 `code === 'super_admin'` 自动绕过权限码校验、`data_scope` 看全量。
- 数据权限 `role.data_scope ∈ { all, dept, dept_and_child, self, custom }`,`custom` 时经 `role_org` 关联可见部门。
**资源注册(新模块接 RBAC)**:菜单/接口/按钮资源的**单一来源**是 [`src/common/tenant-template.ts`](../sources/sysmgr-nest/src/common/tenant-template.ts) 的 `MENUS` / `APIS` / `BUTTONS`,权限码来源是 [`src/common/permissions.ts`](../sources/sysmgr-nest/src/common/permissions.ts)。新增模块三步:
1. `permissions.ts` 加 `domain:action` 权限码;
2. `tenant-template.ts` 的 `MENUS`/`APIS`/`BUTTONS` 加资源(`parent` 挂到上级菜单 code);
3. 存量租户跑 `pnpm sync:resources` 补齐(幂等,不触碰用户/密码);新租户经 `createTenant` 自动带全。
### 2.5 装饰器与拦截器
| 项 | 用途 |
|---|---|
| `@Public()` | 跳过登录校验 |
| `@RequirePermissions(...codes)` | 要求权限码 |
| `@AuditLog('模块', '动作')` | 写操作自动落 `sys_audit_log`(异步,失败不阻塞业务) |
| `@CurrentUser` | 取当前登录用户 |
全局拦截器/过滤器(`app.module.ts` 注册):`TransformInterceptor`(响应封装 `{code,message,data}`)、`AuditLogInterceptor`(审计)、`AllExceptionsFilter`(异常→`code = HTTP*100`)。
### 2.6 字典域(内置字典 + 保护)
- 字典类型/字典项在 `dicts/`;内置字典 `built_in=true`。
- **保护规则**:内置类型不可删、内置项不可删、内置项 `value` 不可改;`label/sort/color_type` 可改;内置类型下可新增非内置项。
- `GET /dicts`(仅登录态)一次返回当前租户全部启用类型 + 启用项,供前端缓存。
- 内置字典取值与 [`src/common/enums.ts`](../sources/sysmgr-nest/src/common/enums.ts) 一一对应,由 seed 预置(`common_status` / `user_status` / `resource_type` / `data_scope` / `login_status`)。
### 2.7 seed 与资源同步
`prisma/seed.ts` 只初始化平台租户 `touyun`(租户/用户/角色/资源/字典)。资源与字典的真实单一来源在 `tenant-template.ts` 的 `MENUS`/`APIS`/`BUTTONS`/`DICTS`,`seed` 与 `createTenant` 都调用 `initTenant`;新增资源后对**存量租户**跑 `pnpm sync:resources` 补齐。
### 2.8 账号与会话安全
- **首登强制改密**:`user.must_change_password` 标记临时密码(管理员创建默认强制、重置密码、建租户随机密码);登录响应返回 `mustChangePassword`,`JwtAuthGuard` 对未改密用户**只放行** `PATCH /auth/me/password`,其余接口 403。
- **账号锁定**:`user.locked_at` / `lock_reason` 独立于 `status`(停用);管理员锁定即踢全部会话,解锁同时清登录失败计数。
- **在线会话**:会话 = `refresh_token`,`sid` 标识单次登录(刷新轮换沿用同一 `sid`);`GET /sessions` 列活跃会话,`POST /sessions/:sid/kick` 单会话强退(吊销 refresh + sid 黑名单),`POST /users/:id/kick-sessions` 踢全部(复用 `tokenVersion`)。
### 2.9 定时任务
用 `@nestjs/schedule` 的 `@Cron` 声明定时任务;`ScheduleModule.forRoot()` 已在 `app.module.ts` 注册。现有任务:[`src/common/cleanup.service.ts`](../sources/sysmgr-nest/src/common/cleanup.service.ts) 每天 03:07 清理过期/吊销的 refresh token 与超过 90 天的审计/登录日志。
**新增定时任务**:
1. 新建 service,方法挂 `@Cron('秒 分 时 日 月 周')`(cron 表达式,如 `@Cron('0 3 * * *')` 每天 03:00);
2. 在 `app.module.ts` 的 `providers` 注册该 service;
3. 任务体内 `try/catch` 并记录日志(失败不抛出,避免 Cron 中断)。
> 本期定时任务仅清理一个,暂不做「任务注册表 + 界面手动触发」;有需要再扩展。
---
## 3. 前端开发指南(sysmgr-admin)
### 3.1 目录与自动导入
```
src/
├── api/ # http.ts(axios 封装)+ 每域 api(user/role/resource/org/dict/...)
├── components/ # ty-* 组件库(全局自动导入,无需 import)
├── composables/ # useRequest、useDict
├── config/ # layout.ts(布局设置)、login.ts(登录页配置)、menu.ts(兜底菜单)
├── layouts/ # default.vue(后台壳)、blank.vue(登录页等)
├── pages/ # 页面(unplugin-vue-router 按目录自动生成路由)
├── stores/modules/ # Pinia:app/auth/menu/tabs/dict
├── types/ # crud.ts(JsonSchema/TableColumn 等)、resource.ts
└── utils/ # dialog.ts(showDialog)、tree.ts、token.ts、theme.ts
```
已配置 `unplugin-auto-import` + `unplugin-vue-components`:Vue API、Element Plus、`@/components` 下的组件**均无需手动 import**。
### 3.2 网络请求:axios 封装 + useRequest
统一用全局 `http`(`@/api/http`):
```ts
const data = await http.get<User[]>('/users', { params: { page: 1 } }) // 自动解包,直接拿 data
```
- 自动注入 `Authorization`;响应自动拆 `{code,message,data}`(`code=0` 返回 `data`);`401` 自动刷新 token 并重放;失败抛 `ApiError`。
- 方法:`http.get/post/put/patch/delete/request<T>(url, data?, config?)`。
`useRequest`(`@/composables/useRequest`)管理请求状态:
```ts
const { data, loading, run, refresh } = useRequest((signal: AbortSignal) => listUsers(q, signal))
// 或手动触发:useRequest(fetcher, { manual: true }) → run(...args)
```
### 3.3 字典:dict store + useDict + dictCode
登录后自动缓存全部启用字典,页面/组件通过 `dictCode` 或 `useDict` 读取,**不要硬编码中文/颜色映射**。
```ts
const { items, tagMap, options } = useDict('common_status')
// items : [{ label:'启用', value:'enabled', colorType:'success', sort:1 }, ...]
// tagMap : { enabled: { label:'启用', type:'success' }, ... }
// options: [{ label:'启用', value:'enabled' }, ...]
```
在 JSON Schema / 表格列里直接写 `dictCode` 即可(见 §3.7 组件 API)。
### 3.4 菜单 / 路由 / 布局
- 路由由 `unplugin-vue-router` 按 `src/pages/**` 目录自动生成;页面用 `<route lang="yaml">` 自定义块声明 `meta.layout`(`default` | `blank`)选择布局:
```vue
<route lang="yaml">
meta:
layout: blank
requiresAuth: false
</route>
```
- 菜单从后端 `GET /auth/menus` 拉取(超管=全部启用菜单,普通用户=角色可访问菜单+祖先),存 `menu` store。
- 页签存 `tabs` store,登出/401 清空。
- **多页签 keep-alive**:布局用 `<keep-alive :include>` 缓存打开页签的组件,切换保留状态、关闭销毁;页面默认缓存,在 `<route>` 里声明 `meta.keepAlive: false` 可关闭。**每个菜单页面须 `defineOptions({ name })`**(name = 路径末段,如 `user`/`login-log`/`dashboard`),作为缓存标识;**name 须与路径末段一致且全局唯一**(避免未来同末段路径互相影响 keep-alive)。
### 3.5 样式:UnoCSS @apply
优先用 `@apply` 工具类,少写裸 CSS。注意主题色工具类写法:
```scss
.foo {
@apply bg-bg-base text-text-primary; // 背景/文字主题色:bg-bg-* / text-text-*
@apply border-solid rounded-base; // 语义色:primary/success/warning/danger/info
}
```
### 3.6 组件 API 文档
下面 5 个业务组件 + 2 个 composable 是日常开发主力。布局类组件(ty-header/ty-sidebar/ty-tabs 等)由 `layouts/default.vue` 组装,一般业务页无需直接使用。
#### 3.6.1 ty-form(JSON Schema 表单)
**Props**
| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `schema` | `JsonSchema` | — | 必填,表单结构描述 |
| `modelValue` | `Record<string, any>` | — | 必填,表单数据(就地绑定) |
| `inline` | `boolean` | `false` | 行内布局(搜索用) |
| `mode` | `'create' \| 'edit'` | `undefined` | 配合字段 `showOn` 过滤 |
| `enterToSubmit` | `boolean` | `false` | 输入框回车触发 `submit` |
| `readonlyKeys` | `string[]` | `[]` | 强制只读的字段名 |
**Expose(通过 `ref` 调用)**
| 方法 | 说明 |
|---|---|
| `submit()` | 校验并返回表单数据(校验失败抛错) |
| `validate()` | 仅校验 |
| `resetFields()` | 重置 |
| `clearValidate()` | 清空校验状态 |
**Emits**:`submit`。
**JsonSchemaProperty 字段速查**
| 字段 | 类型 | 说明 |
|---|---|---|
| `type` | `'string'\|'integer'\|'number'\|'boolean'` | 控件类型 |
| `title` | `string` | 标签 |
| `placeholder` | `string` | 占位 |
| `enum` / `enumNames` | `(string\|number\|boolean)[]` / `string[]` | 下拉选项(一一对应) |
| `dictCode` | `string` | 字典 code,声明后自动生成下拉(优先于 enum) |
| `format` | `'password'\|'email'\|'textarea'\|'tree-select'\|'date'\|'datetime'` | 特殊控件;`date/datetime` 目前按普通输入框渲染 |
| `default` | `unknown` | 默认值 |
| `readOnly` | `boolean` | 只读 |
| `showOn` | `('create'\|'edit')[]` | 仅指定模式显示 |
| `when` | `{ field; equals?; in? }` | 依赖字段值联动显隐 |
| `minLength`/`maxLength`/`minimum`/`maximum` | `number` | 校验 |
| `prefixIcon` | `string` | 输入框前缀 iconify 图标 |
| `width` | `string` | 控件宽度 |
| `treeData`/`treeProps` | — | `format:'tree-select'` 时的树数据与字段映射 |
> `type:'boolean'` 渲染为开关(el-switch),配合 `default:true/false` 设定默认值;布尔字段常用 `showOn: ['create']` 只在新增时出现。
```ts
const schema: JsonSchema = {
type: 'object',
properties: {
status: { type: 'string', title: '状态', dictCode: 'common_status', default: 'enabled' },
path: { type: 'string', title: '路由', when: { field: 'type', equals: 'menu' } },
},
required: ['status'],
}
```
#### 3.6.2 ty-search-form(搜索 + 折叠)
**Props**
| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `schema` | `JsonSchema` | — | 搜索字段 |
| `modelValue` | `Record<string, any>` | — | 搜索条件 |
| `collapseCount` | `number` | `3` | 折叠时显示字段数,超出「展开」 |
**Emits**:`search`、`reset`。
#### 3.6.3 ty-table(表格)
**Props**
| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `columns` | `TableColumn[]` | — | 列配置 |
| `data` | `Record<string, any>[]` | — | 行数据 |
| `loading` | `boolean` | `false` | 加载态 |
| `selection` | `boolean` | `false` | 多选列 |
| `total` / `page` / `pageSize` / `pageSizes` | `number`/`number[]` | `0`/`1`/`10`/`[10,20,50]` | 分页 |
| `actionsWidth` | `number\|string` | `200` | 操作列宽 |
| `rowKey` | `string` | `'id'` | 行唯一键(树形用 code) |
| `treeProps` | `Record<string,string>` | `{children:'children'}` | 树形字段映射 |
| `defaultExpandAll` | `boolean` | `false` | 树形默认展开 |
**Emits**:`update:page`、`update:pageSize`、`selection-change`。
**Slots**:`actions`(作用域 `{ row }`)。
**TableColumn 字段**
| 字段 | 类型 | 说明 |
|---|---|---|
| `prop` | `string` | 字段名 |
| `label` | `string` | 列标题 |
| `width` / `minWidth` | `number\|string` | 列宽 |
| `align` | `'left'\|'center'\|'right'` | 对齐 |
| `type` | `'text'\|'tag'\|'datetime'` | 渲染类型(默认 text) |
| `tagMap` | `Record<string, {label,type}>` | `type:'tag'` 的值→标签映射 |
| `dictCode` | `string` | `type:'tag'` 时按字典渲染(优先于 tagMap) |
| `formatter` | `(value, row) => string` | 自定义格式化 |
```ts
const columns: TableColumn[] = [
{ prop: 'status', label: '状态', width: 90, type: 'tag', dictCode: 'common_status' },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
```
#### 3.6.4 ty-crud(整页 CRUD)
组合「搜索 + 工具栏 + 表格 + 弹窗表单 + 删除」,多数单表页面用它几行搞定。
**Props**
| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `entityName` | `string` | — | 实体名(标题/确认文案) |
| `columns` | `TableColumn[]` | — | 列 |
| `formSchema` | `JsonSchema` | — | 新增/编辑表单 |
| `searchSchema` | `JsonSchema` | — | 搜索(可选) |
| `list` | `(query, signal?) => Promise<PageResult>` | — | 分页查询 |
| `create` | `(data) => Promise<unknown>` | — | 新增 |
| `update` | `(id, data) => Promise<unknown>` | — | 更新 |
| `remove` | `(id) => Promise<unknown>` | — | 删除 |
| `idKey` / `nameKey` | `string` | `'id'`/`'name'` | 主键/删除确认显示字段 |
| `editReadonlyKeys` | `string[]` | `[]` | 编辑只读字段 |
| `getEditReadonlyKeys` | `(row) => string[]` | — | 按行计算只读(优先) |
| `actions` | `CrudAction[]` | `['edit','delete']` | 操作列(内置或自定义 `{label,type,onClick,show}`) |
| `collapseCount` | `number` | `2` | 操作按钮折叠数 |
| `selection` | `boolean` | `false` | 多选(配合 `#toolbar-right`) |
| `actionsWidth` | `number\|string` | `140` | 操作列宽 |
**Slots**:`toolbar`、`toolbar-right`(作用域 `{ selectedRows }`)。
**Expose**:`reload()`。
```vue
<ty-crud
entity-name="用户"
:columns="columns"
:search-schema="searchSchema"
:form-schema="formSchema"
:list="list" :create="create" :update="update" :remove="remove"
id-key="id" name-key="username" :edit-readonly-keys="['username']"
/>
```
#### 3.6.5 showDialog / ty-dialog(弹窗)
`showDialog(options)` 打开弹窗,返回 Promise(确认 resolve 内容组件 `submit()` 数据,取消/关闭 resolve `null`)。`<ty-dialog />` 需挂载一次作为宿主(通常已在根组件)。
```ts
const data = await showDialog({
component: TyForm, // 内容组件(通常传 TyForm)
title: '新增用户',
width: '520px',
props: { schema, modelValue: model, mode: 'create' },
})
if (data == null) return // 取消
await createUser(data)
```
**DialogOptions**
| 字段 | 类型 | 说明 |
|---|---|---|
| `component` | `Component` | 内容组件(须 expose `submit()`) |
| `title` / `width` | `string` | 标题/宽度(默认 480px) |
| `props` | `Record<string, any>` | 传给内容组件的 props |
| `confirmText` / `cancelText` | `string` | 按钮文案 |
| `showFooter` | `boolean` | 默认显示取消/确定;`false` 时内容组件自带底部 |
| `onConfirm` | `(data) => void\|Promise` | 确认回调 |
#### 3.6.6 useRequest
```ts
useRequest<TData, TParams>(fetcher, options?)
// fetcher: (...args: [...TParams, AbortSignal]) => Promise<TData>
```
**Options**:`manual`(默认 false 自动请求)、`defaultParams`、`refreshDeps`(依赖变化重请求)、`onBefore/onSuccess/onError/onFinally`。
**返回**:`data`、`loading`、`error`、`run(...args)`、`refresh()`、`cancel()`、`mutate(data|fn)`。
#### 3.6.7 useDict
```ts
useDict(code) // → { items, tagMap, options },均响应式
```
---
## 4. 联调契约
### 4.1 响应封装
```json
{ "code": 0, "message": "ok", "data": { } }
```
- `code=0` 成功;失败 `code` 为业务码(5 位 `[域码 2 位][序 3 位]`,单一注册表见 [`src/common/error-codes.ts`](../sources/sysmgr-nest/src/common/error-codes.ts),如 `10001` 账号或密码错误、`11002` 用户名已存在);未登记业务码的异常回退 `HTTP状态码 × 100`(40000/40100/40300/40400/40900/50000)。
- 前端 `http` 已自动拆包:成功拿 `data`,失败抛 `ApiError(code, message)`。
### 4.2 分页
列表接口 `page/pageSize`,返回 `{ list, total, page, pageSize }`。
### 4.3 三级鉴权
公开(`@Public`)→ 仅登录(无装饰器)→ 权限码(`@RequirePermissions`),见 §2.4。
### 4.4 契约锚点
`shared/api-contract`(响应/错误码/分页/鉴权/接口目录)与 `shared/data-model`(字段字典)是前后端唯一锚点,字段/接口变更先改 `shared/`。
---
## 5. 常用操作手册(Cookbook)
### 5.1 新增一个业务实体(含权限码 + 页面)完整走查
以新增「岗位 post」为例,完整跑通「后端 → 前端 → 资源注册 → 授权验证」:
**后端**
1. `prisma/schema.prisma` 加 `Post` 模型(`sys_post`,带 `tenant_code` + 公共字段)。
2. `npx prisma migrate dev --name add_post` + `prisma generate`。
3. `src/common/permissions.ts` 加 `post: { list, create, update, delete }`。
4. 新建 `src/posts/`(module/controller/service/dto),service `extends BaseCrudService`。
5. controller 挂 `@RequirePermissions(Permissions.post.xxx)` + 写操作 `@AuditLog('post', 'xxx')`。
6. `app.module.ts` 注册 `PostsModule`。
7. **资源注册**:`src/common/tenant-template.ts` 的 `APIS` 加 4 条接口资源(`code=权限码`、`method`、`path`、`parent='post'`),`MENUS` 加菜单节点(`parent`、`path`、`component`、`icon`)。
8. 存量租户补资源:`pnpm sync:resources`(幂等);新租户 `createTenant` 自动带全。
**前端**
1. `src/api/post.ts` 加类型 + `listPosts/createPost/updatePost/deletePost`。
2. `src/pages/system/post/index.vue`:`<script setup>` 顶部加 `defineOptions({ name: 'post' })`(keep-alive 缓存标识),用 `ty-crud` 组装(列用 `dictCode`,表单用 JSON Schema,路由由文件路径自动生成)。
3. 若该页不要缓存,加 `<route lang="yaml">meta: keepAlive: false</route>`。
**授权验证**
1. 用超管登录「角色管理」给某角色分配 post 菜单 + 接口资源。
2. 切到该角色的用户登录,应看到「岗位管理」菜单、能操作;无权限用户看不到。
### 5.2 新增一个字典类型
1. 登录进入「系统管理 → 字典管理」→ 新增字典类型(填 code/name)。
2. 选中类型 → 新增字典项(label/value/标签色/排序)。
3. 页面/组件里用 `dictCode: '<code>'` 或 `useDict('<code>')` 引用。
> 内置字典(seed 预置)的 `value` 与内置项受保护,只能改 label/排序/颜色;普通字典完全自由。
### 5.3 新增一个权限码 / 按钮
1. `common/permissions.ts` 加 `domain.action`。
2. `tenant-template.ts` 的 `APIS`(接口)或 `BUTTONS`(纯前端按钮)加对应资源;存量租户跑 `pnpm sync:resources`。
3. 后端 controller 用 `@RequirePermissions(...)`;前端按钮用 `v-permission` 或资源 code 下发渲染。
### 5.4 新增租户
「系统管理 → 租户管理」新增;或 seed 里 upsert。租户之间 `tenant_code` 隔离,登录时按账号+租户识别当前租户。
### 5.5 扩展资源 / 字典
改 `tenant-template.ts` 的 `MENUS` / `APIS` / `BUTTONS` / `DICTS` 常量;新租户自动生效,存量租户跑 `pnpm sync:resources` 补齐(幂等,重复运行不重复插入)。
---
## 6. 规范与约定
- **提交信息**:Conventional Commits(`feat`/`fix`/`chore`/`docs`/`refactor`)。
- **命名**:后端模型 camelCase + `@map` snake_case;业务键统一 `code`;权限码 `domain:action`。
- **样式**:UnoCSS `@apply`,少写裸 CSS;主题色用 `bg-bg-*` / `text-text-*`。
- **自测后停服务**:冒烟测试临时起的服务,测完立即停掉。
- **单一事实源**:字段/接口以 `shared/data-model`、`shared/api-contract` 为准,不各自命名。
---
## 7. 排障 FAQ
| 现象 | 原因 / 处理 |
|---|---|
| 启动报 `EADDRINUSE :3000` | 端口被占,换 `PORT=3100` 或停掉旧进程 |
| `prisma migrate dev` 非交互报错 | 用 `prisma migrate diff` 生成 SQL + `prisma migrate deploy`,或开发期用 `prisma db push` |
| seed 重复插入报唯一约束 | 检查 upsert 的 `where` 是否用了唯一键(如 `tenantCode_code`) |
| 接口返回 401 | token 缺失/过期;前端会自动刷新,刷新失败跳登录 |
| 接口返回 403 | 权限码未授权;确认 resource 表登记了该接口、角色已分配 |
| 字典下拉/标签不显示 | 确认登录后 `dictStore.fetch()` 成功;字典类型/项 `status=enabled`;`dictCode` 拼写一致 |
| 改字典 label 页面没变 | 字典缓存未刷新:重新登录,或字典页保存后 `dictStore.fetch(true)` 强制刷新 |
| 登录失败 | 确认 seed 已跑、账号 `touyun / Touyun@123`、租户 `touyun` 存在且 enabled |
| 内置项 value 改不动 | 内置项受保护,仅 label/排序/颜色可改(有意为之) |
| 首登登录后所有接口 403 | `must_change_password=true`,需先改密:仅 `PATCH /auth/me/password` 放行,前端会强制跳改密页 |
| 强退后 token 仍短暂可用 | access 最长 2h 内失效(sid 黑名单);refresh 已被吊销无法续期 |
| 切页签数据没保留 | 该页声明了 `meta.keepAlive:false`,或页面缺 `defineOptions({ name })` 导致缓存标识失效 |
---
## 8. 升级与定制(fork 后如何同步上游)
脚手架是「通用底座」,fork 成具体项目后,**业务代码和脚手架代码必须物理分离**,才能在上游升级时干净合并。
### 8.1 升级同步(git upstream + merge)
```bash
git clone <脚手架地址> my-project
cd my-project
git remote rename origin upstream # 脚手架 = upstream
git remote add origin <我的业务仓库地址> # 业务 = origin
# 日常在 main 上开发业务,升级时:
git fetch upstream
git merge upstream/main # 冲突只在「你改过脚手架文件」的地方出现
```
- 业务代码**纯新增**(新模块/新页面/新资源)→ 升级几乎零冲突。
- 业务代码**改了脚手架文件** → 上游升级该文件即冲突,改得越深越痛。
- 将来项目变多、需要版本语义时,再评估把脚手架抽成 npm 包 / monorepo 依赖(彻底隔离、按版本升级)。
### 8.2 可定制面(三条铁律)
| 铁律 | 说明 |
|---|---|
| **框架代码只读** | 脚手架自带模块/页面/服务(`users`/`roles`/`system/*`/`common/*`/`ty-*`…)只读不改 |
| **业务代码落点** | 新增放独立业务目录:后端新域 `src/<domain>/`,前端新页面 `src/pages/<biz>/`,不侵入框架目录 |
| **定制走扩展点** | 外观/权限优先走数据驱动扩展点:租户品牌(`tenant.brand_*`)、字典、主题色、资源注册(`tenant-template.ts` + `pnpm sync:resources`),而非改源码 |