From 5376ba6f4dc78ae270bbcb196a8337d5b918ad50 Mon Sep 17 00:00:00 2001 From: liuyun Date: Wed, 14 Oct 2026 09:30:09 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20guide=20=E7=9B=AE=E5=BD=95=E5=90=8D?= =?UTF-8?q?=E7=94=B1=20sysmgr-*=20=E6=9B=B4=E6=96=B0=E4=B8=BA=20ec-nest/ec?= =?UTF-8?q?-admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/guide.md | 609 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 609 insertions(+) create mode 100644 docs/guide.md diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..2c6730f --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,609 @@ +# 通用系统管理脚手架 · 开发帮助手册 + +> 面向 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 # 拉取 ec-nest / ec-admin + +# 后端 +cd sources/ec-nest +cp .env.example .env # 若无 .env.example 则手建,见下 +pnpm install +npx prisma migrate deploy # 应用已有迁移(开发期可用 migrate dev) +pnpm seed # 初始化租户/用户/角色/资源/内置字典 + +# 前端 +cd ../ec-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/ec-nest && pnpm start:dev + +# 前端(Vite,直连 VITE_API_BASE_URL,默认 http://localhost:3000/api/v1,见 .env) +cd sources/ec-admin && pnpm dev:dev +``` + +### 1.5 默认账号 + +| 项 | 值 | +|---|---| +| 租户 | `touyun` | +| 用户名 | `touyun` | +| 密码 | `Touyun@123` | +| 角色 | `super_admin`(超管,绕过权限校验、数据权限看全量) | + +--- + +## 2. 后端开发指南(ec-nest) + +### 2.1 目录与分层 + +``` +src/ +├── auth/ # 认证:登录/刷新/登出、菜单树、JWT 守卫、权限守卫、装饰器、请求上下文 +├── users/ roles/ resources/ orgs/ dicts/ configs/ tenants/ audit-logs/ login-logs/ sessions/ +│ # 每个域一个目录:.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/ec-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/ec-nest/src/common/tenant-template.ts) 的 `MENUS` / `APIS` / `BUTTONS`,权限码来源是 [`src/common/permissions.ts`](../sources/ec-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/ec-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/ec-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. 前端开发指南(ec-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('/users', { params: { page: 1 } }) // 自动解包,直接拿 data +``` + +- 自动注入 `Authorization`;响应自动拆 `{code,message,data}`(`code=0` 返回 `data`);`401` 自动刷新 token 并重放;失败抛 `ApiError`。 +- 方法:`http.get/post/put/patch/delete/request(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/**` 目录自动生成;页面用 `` 自定义块声明 `meta.layout`(`default` | `blank`)选择布局: + +```vue + +meta: + layout: blank + requiresAuth: false + +``` +- 菜单从后端 `GET /auth/menus` 拉取(超管=全部启用菜单,普通用户=角色可访问菜单+祖先),存 `menu` store。 +- 页签存 `tabs` store,登出/401 清空。 +- **多页签 keep-alive**:布局用 `` 缓存打开页签的组件,切换保留状态、关闭销毁;页面默认缓存,在 `` 里声明 `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` | — | 必填,表单数据(就地绑定) | +| `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` | — | 搜索条件 | +| `collapseCount` | `number` | `3` | 折叠时显示字段数,超出「展开」 | + +**Emits**:`search`、`reset`。 + +#### 3.6.3 ty-table(表格) + +**Props** + +| 属性 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `columns` | `TableColumn[]` | — | 列配置 | +| `data` | `Record[]` | — | 行数据 | +| `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` | `{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` | `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` | — | 分页查询 | +| `create` | `(data) => Promise` | — | 新增 | +| `update` | `(id, data) => Promise` | — | 更新 | +| `remove` | `(id) => Promise` | — | 删除 | +| `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 + +``` + +#### 3.6.5 showDialog / ty-dialog(弹窗) + +`showDialog(options)` 打开弹窗,返回 Promise(确认 resolve 内容组件 `submit()` 数据,取消/关闭 resolve `null`)。`` 需挂载一次作为宿主(通常已在根组件)。 + +```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` | 传给内容组件的 props | +| `confirmText` / `cancelText` | `string` | 按钮文案 | +| `showFooter` | `boolean` | 默认显示取消/确定;`false` 时内容组件自带底部 | +| `onConfirm` | `(data) => void\|Promise` | 确认回调 | + +#### 3.6.6 useRequest + +```ts +useRequest(fetcher, options?) +// fetcher: (...args: [...TParams, AbortSignal]) => Promise +``` + +**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/ec-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`:`