签入版本

This commit is contained in:
小蚂蚁云
2026-10-20 21:53:26 +08:00
parent 5cc3da657c
commit b33799aa63
427 changed files with 32547 additions and 1 deletions
@@ -0,0 +1,75 @@
# 6.7 文章管理
:::tip 概述
文章模块(goframe_article,租户级表)是文件/富文本字段的代表模块:cover 单图、images 多图、content 富文本([IMG_URL] 持久化契约),配合分类关联与状态/点击量/排序。
:::
## 接口清单
```text
七件套 + PUT /api/article/status(sys:article:status)
sys:article:{page|list|detail|add|update|delete|batchDelete|status}
```
## 表特征列
```text
category_id 所属分类(逻辑外键 → goframe_category)
cover 封面单图(FileFields)
images 多图(MultiFileFields,逗号分隔)
content 富文本(ContentFields,[IMG_URL] 占位符契约)
status 0-正常 1-下架(注释枚举)
click 点击量
sort 排序
```
## 声明式文件/富文本处理(spec 模式)
```go
// articleSpec 关键声明(模式示意)
var articleSpec = ModuleSpec[...]{ ...,
FileFields: []string{"cover"}, // 单图
MultiFileFields: []string{"images"}, // 逗号多图
ContentFields: []string{"content"}, // 富文本
FileSaveDir: "article", // 转正目录
}
```
基座自动完成:
```text
写侧(saveTransform):
cover/images:temp 路径 → SaveFromTemp 转正到 article/{日期}/
content:LocalizeContentFields —— 本站 /temp/ 引用转正、
本站正式引用剥离域名 → "[IMG_URL]/相对路径"
外站 URL 不处理(防 SSRF)
读侧(readTransform):
cover/images:GetFileUrl 补域名(domainUrl 或 /upload/)
content:ExpandContentFields —— "[IMG_URL]" 还原为访问基址
```
详见 [7.3 富文本处理](../tools/rich-text) 与 [7.1 文件上传全流程](../tools/upload)。
## 分类关联
```text
article.category_id → category.id(树形模块,见 6.10 分类管理)
列表可按分类筛选(EqFields);分类删除有引用守卫意识(避免文章悬挂)
```
## 状态与展示
```text
status 0-正常 1-下架:前端列表按状态过滤展示
SerializeMaps: status → article_status(字典驱动 {field}Text)
click 点击量:详情阅读累加(业务自定义)
sort 排序:OrderBy "sort asc" 或前端排序白名单
```
## 管理建议
+ 富文本图片会被自动本地化(temp → 正式目录)——编辑器直传的图片安全落库
+ 外站图片链接保持原样不抓取(设计边界:防 SSRF)
+ content 字段含 [IMG_URL] 占位符是库内格式契约——content 不供第三方系统直接消费,读取必须走 Detail/List(基座自动还原)
+ cover/images 字段入库为相对 uploadDir 路径,展示层经 GetFileUrl(基座已处理,勿手拼)
@@ -0,0 +1,66 @@
# 6.18 缓存监控
:::tip 概述
缓存监控是无表模块:解析 Redis INFO 返回内存、客户端、命中率、键空间等指标;内存降级时返回 mode=memory 标识。路由 GET /api/cache/info(登录即可)。
:::
## 接口
```text
GET /api/cache/info 登录即可,log "缓存监控-查询信息"
```
## 返回内容
```text
内存降级(cache.UsingMemory()):
→ {mode:"memory", connected:false}(仅返回模式标识,无 INFO 指标)
Redis 模式:依次 INFO server/memory/stats/clients/keyspace + DBSIZE,
解析(parseRedisInfo:# 注释行跳过,k:v 映射):
version / uptime / usedMemory / usedMemoryPeak / maxMemory / fragRatio /
connectedClients / expiredKeys / evictedKeys / totalCommands / totalConns /
hits / misses / hitRate(hits/(hits+misses),两位小数)/
keys(DBSIZE)/ keySpace[](每 db 的 keys/expires)
任一 INFO 失败 → connected=false 返回已解析部分
```
## 关键指标解读
| 指标 | 健康参考 | 异常含义 |
|------|---------|---------|
| mode | redis(生产必须) | memory = 已降级,多实例共享能力全失效 |
| hitRate | > 0.8 | 偏低 → 键 TTL 过短或缓存未命中业务路径 |
| usedMemory | 远小于 maxMemory | 接近 maxmemory → 需扩内存或清理 |
| fragRatio | < 1.5 | 偏高 → 内存碎片(考虑碎片整理) |
| connectedClients | 稳定 | 持续增长 → 连接泄漏 |
| expiredKeys / evictedKeys | 有自然过期 | evictedKeys 持续增长 → maxmemory 淘汰(容量不足) |
## 与整体架构的关系
```text
缓存分层:进程内(dictTextCache 等)→ Redis(跨实例共享)→ 数据库(唯一真相)
本接口观测的是 Redis 层;进程内层不可远程观测(重启即清)
降级影响面(mode=memory 时):
限流计数 / 任务锁 / 在线用户 / JWT 黑名单 / 验证码 / 字典缓存 全部退化单机语义
```
## 运维用法
```text
日常巡检:mode 必须为 redis(生产红线)
容量规划:usedMemory/maxMemory 趋势 + evictedKeys 增速
命中率治理:hitRate 持续偏低时排查 TTL 策略(见 4.8 缓存架构设计 TTL 原则)
连接治理:connectedClients 对照 redis maxActive(连接池配置)
```
## 排障
| 症状 | 处理 |
|------|------|
| mode=memory | Redis 不可用——查地址/密码/网络;恢复后自动回 redis(运行期探测) |
| connected=false 但 mode=redis | INFO 命令失败(权限/超时);返回已解析部分 |
| hitRate=0 | 全部未命中——检查是否有业务直连 g.Redis 绕过 cache 门面 |
| 键数量异常多 | redis-cli KEYS 'easygoadmin:*' 抽查;对照键全集(4.8 缓存架构设计) |
@@ -0,0 +1,60 @@
# 6.10 分类管理
:::tip 概述
分类模块(goframe_category,租户级表)管理文章/商品等树形分类:ModuleSpec 树形声明(IsTree + ParentOf)、租户内名称唯一、删除守卫。是三个树形模块(menu/dept/category)之一。
:::
## 接口清单
```text
七件套:sys:category:{page|list|detail|add|update|delete|batchDelete}
List 返回树形(IsTree + ParentOf)
```
## 树形声明(spec 模式)
```go
// categorySpec 树形声明(模式示意)
var categorySpec = ModuleSpec[...]{ ...,
IsTree: true,
ParentOf: func(item *v1.CategoryItem) int { return item.ParentId },
// ChildSetter 或 DTO 的 Children 字段(叶子预置 [] 而非 null)
}
```
基座 List 末尾自动建树;DTO 必须含 Id 字段;缺 ParentOf 时 fail-fast(启动即报)。
## 模块特征
| 特征 | 实现 |
|------|------|
| 树形 | IsTree + ParentOf,List 建树(叶子 Children 预置空数组) |
| 唯一性 | 租户内分类名唯一(uk_tenant_name);未声明预检,1062 兜底 |
| 排序 | sort 列(有 sort 列时生成器默认 "sort asc") |
| 引用方 | article.category_id(文章归属分类) |
## 树构建的两条路线
```text
路线一(后端建树,本模块采用):
spec.IsTree=true → List 返回树形 JSON → 前端直接渲染树
路线二(前端建树):
后端返回平铺列表 → 前端 buildTree 组装
(example 模块即此模式:"data 直接为数组,树形结构由前端 buildTree 构建")
选择:菜单/部门/分类走后端建树(权限/守卫与树结构耦合);
简单展示树可走前端建树(后端更轻)。
```
## 删除守卫与引用
+ 删除分类前检查子分类(树形守卫,1011 语义)
+ 删除被文章引用的分类前,先调整文章归属(避免悬挂引用)
+ 批量删除逐条守卫
## 管理建议
+ 分类层级控制在 3 层内(文章分类场景足够;更深考虑是否该拆多套分类)
+ sort 控制同级排序(间隔 5/10 留出插入空间,生成器同款策略)
+ 租户内独立分类体系;跨租户共享分类不在设计内(平台共享行 tenant_id=0 为扩展方向)
@@ -0,0 +1,72 @@
# 6.9 行政区划管理
:::tip 概述
行政区划模块(goframe_city,平台级大表)管理全国省/市/区/街道/社区数据:树形双键(pid + parent_code)、级联懒加载查询、双向编解码包级函数(user/index 模块复用)。78 万+ 行,查询必须走索引路径。
:::
## 接口清单
| 方法 | 路由 | 权限 | 说明 |
|------|------|------|------|
| GET | /api/city/page | sys:city:page | 分页 |
| GET | /api/city/list | sys:city:list | 列表 |
| GET | /api/city/detail/:id | sys:city:detail | 详情 |
| POST | /api/city/add | sys:city:add | 新增 |
| PUT | /api/city/update | sys:city:update | 修改 |
| DELETE | /api/city/delete/:id | sys:city:delete | 软删 |
| DELETE | /api/city/batchDelete | sys:city:batchDelete | 批量删除 |
| GET | /api/city/getCityList/:parentCode | 登录即可 | 下级城市(级联懒加载) |
| GET | /api/area/getParent | 登录即可 | 省份列表(别名路由) |
| GET | /api/area/findByParentCode | 登录即可 | 按父编码查城市(别名) |
七件套权限 sys:city:*;三个查询别名登录即可(前端级联选择器高频调用)。
## 表特征
```text
树形双键:pid(父 ID)+ parent_code(父编码)——级联查询走 parent_code 索引
area_code 区划编码(唯一业务键,编解码主键)
level 0-省份 1-城市 2-县区 3-街道 4-社区(注释枚举)
lng/lat/pinyin 经纬度与拼音(检索辅助)
平台级表(无租户):全国统一数据
```
## 大表性能纪律(78 万+ 行)
::: warning 大表注意
+ **勿全表 List**——查询走 area_code / parent_code / pid 索引路径
+ 级联懒加载 `getCityList/:parentCode` 只取单级子集
+ 导入/迁移注意批大小(与整库迁移器 batch-size 同理)
:::
## 双向编解码(包级共享函数)
```text
ResolveCityNames(ctx, codes []string, separator) string
area_code IN(...) 批查 → 按入参顺序拼名称串(空编码跳过)
用途:详情/列表把存储的四级编码还原为"江苏省,南京市,建邺区"
ResolveCityCodes(ctx, cityInfo string) [4]string
名称串按逗号拆 4 级,逐级 parent 约束消歧:
省:pid=0 AND name=?
市:parent_code=省area_code AND name=?
→ 区 → 街道(逐级依赖上级结果;任一级失败后续留空)
例:"江苏省,南京市,建邺区" → [320000000000, 320100000000, 320105000000, ""]
```
因全部 logic 同包,user/index 直接调用无需 import。user 模块 Add/Update/Import/Detail 已接入:**存库用编码(四列),展示用名称**。
## 与用户模块的协作流
```text
写侧:前端名称串 → ResolveCityCodes → 四列编码入库
(user 表 province/city/district/street_code)
读侧:Detail/List → ResolveCityNames(编码) → 名称串还原
(city_info 为空时按编码回填)
```
## 管理建议
+ 区划数据以国家统计口径为准,导入前核对 area_code 唯一性
+ 业务代码消费区划一律走 getCityList 级联(勿全量拉取)
+ level 枚举新增档位(如街道/社区细分)需同步字典与前端级联深度
@@ -0,0 +1,51 @@
# 6.8 公告与链接管理
:::tip 概述
内容域还有两个租户级模块:通知公告(goframe_notice)与友情链接(goframe_link)。公告带状态开关,友链无状态列——两者均为标准 CRUD 的典型代表。
:::
## 通知公告(goframe_notice,/api/notice)
```text
七件套 + PUT /api/notice/status(sys:notice:status)
sys:notice:{page|list|detail|add|update|delete|batchDelete|status}
```
| 特征 | 说明 |
|------|------|
| status | 1-正常 2-禁用(注释枚举,字典驱动 {field}Text) |
| 租户级 | 各租户独立公告 |
| 文件字段 | 支持附件(FileFields 模式) |
| 富文本 | 内容字段可按 ContentFields 声明(如启用富文本编辑) |
## 友情链接(goframe_link,/api/link)
```text
七件套(无 status 接口)
sys:link:{page|list|detail|add|update|delete|batchDelete}
```
| 特征 | 说明 |
|------|------|
| 无 status 列 | 表结构无状态字段,不声明 Status 接口 |
| 租户级 | 各租户独立友链 |
| URL 字段 | website/site/url 等列不作模糊筛选列(生成器 isUrlField 判定) |
## 标准 CRUD 模式的教学价值
两个模块都是"最小差异"的标准形态——新模块开发可直接对照:
```text
差异只体现在:
* SerializeMaps(status → notice_status)
* LikeFields/EqFields 筛选声明
* FileFields/ContentFields(如有文件/富文本列)
* 有无 Status 接口(看表有无 status 列)
其余七件套结构与 example 模块完全一致(见第 3 章)。
```
## 管理建议
+ 公告状态用于"草稿/下架"语义(禁用后前端不展示)
+ 友链删除无守卫(软删可恢复);批量删除支持勾选操作
+ 两者均为租户级——平台统一公告场景需在租户维度分别维护(或扩展平台共享行 tenant_id=0,需业务确认)
@@ -0,0 +1,43 @@
# 6.4 部门管理
:::tip 概述
部门模块(goframe_dept,租户级表)管理组织架构:树形结构、删除有子级/用户守卫、租户内名称唯一。部门是数据权限(本部门及以下/本部门)的组织基础。
:::
## 接口清单
```text
七件套:sys:dept:{page|list|detail|add|update|delete|batchDelete}
List 返回树形(IsTree + ParentOf)
```
## 模块特征
| 特征 | 实现 |
|------|------|
| 树形 | IsTree + ParentOf,List 自动建树 |
| 删除守卫 | 存在子部门拒绝(1011);存在用户引用拒绝(1014 语义) |
| 唯一性 | 租户内部门名唯一(uk_tenant_name) |
| 数据权限 | dept_id 是 data_scope 档 2/3 的过滤维度 |
| 子树查询 | data_scope.go deptSubtreeIds:拉全租户部门行后 BFS |
## 与其他模块的关系
```text
user.dept_id → dept.id 用户归属部门(用户表单部门选择)
data_scope 档 2/3 按部门维度过滤可见数据(见 5.9 数据权限)
deptSubtreeIds(deptId) 档 2 "本部门及以下"的子孙部门集合计算
(每次全量拉部门表 BFS,树小无缓存;
部门表数百行内无感知,上万行需加短时缓存)
```
## 唯一性说明
建表有 `uk_tenant_name (tenant_id, name, del_flag)` 唯一索引,但 dept 模块未声明 UniqueFields 预检——重复名称提交会收到数据库 1062 裸错误。修复方向:补 `UniqueFields{Field:"name", Group:"tenant_id"}`(预检范围=索引范围)。
## 管理建议
+ 部门层级建议控制在 5 层内(子树 BFS 与前端展示友好)
+ 删除部门前先处理子部门与挂载用户(守卫会拦截)
+ 未分配部门(dept_id=0)的用户挂数据权限档 2/3 时降级为"仅本人"(防可见面意外放大)
+ 部门调整后,受影响用户的数据权限即时生效(user.Update 清身份缓存路径覆盖 dept_id 变更)
@@ -0,0 +1,80 @@
# 6.19 案例演示模块
:::tip 概述
案例演示模块(goframe_example,tool 域)是官方完整样板:七件套 + 状态切换 + Excel 导入导出 + 文件字段 + 字典填充,前端五件套齐备。第 3 章模块开发实战全程以它为参照。
:::
## 接口清单
```text
七件套 + PUT /api/example/status(sys:example:status)
+ POST /api/example/import(sys:example:import)
+ GET /api/example/export(sys:example:export)
sys:example:{page|list|detail|add|update|delete|batchDelete|status|import|export}
```
## 后端声明(真实代码,摘自 internal/logic/example.go)
```go
// exampleSerializeMaps 枚举字段到字典编码的映射
// {field}Text 由基座自动填充
var exampleSerializeMaps = map[string]string{
"status": "example_status",
}
// exampleSpec 案例演示模块声明
//
// 采用声明式实现:查询、输出与规则均在声明中定义,方法仅做委托
var exampleSpec = ModuleSpec[v1.ExampleItem, v1.ExampleItem, v1.ExampleDetailRes]{
Model: dao.Example.Ctx,
LikeFields: []string{"name"},
EqFields: []string{"type", "status"},
OrderBy: "sort, id",
SerializeMaps: exampleSerializeMaps,
FileFields: []string{
"avatar",
},
FileSaveDir: "example",
ToData: exampleToData,
}
```
覆盖的基座能力:Like/Eq 筛选、默认排序、字典文本填充、单文件字段两段式、全量表单转换。
## 扩展方法(手写示范)
+ **Status**:非标准方法手写实现(dao 直接 Update 单列)
+ **Import**:excel.ReadSheet 逐行校验写入,返回成功/失败统计与逐行原因
+ **Export**:`exampleSpec.applyFilters` 与列表同套筛选 → DictGetText 枚举转文本 → excel.WriteSheet
完整代码见 [3.10 完整代码清单](../guide/code-list)。
## 前端五件套(ui/src/views/tool/example/)
| 文件 | 职责 |
|------|------|
| index.vue | 列表页(分页表格 + querySchemas 筛选 + 增删改入口) |
| edit.vue | 新增/编辑弹窗表单 |
| detail.vue | 详情视图 |
| columns.ts | 表格列配置(图片列 ElAvatar 渲染示范) |
| querySchemas.ts | 筛选表单配置(Input/Select 示范) |
接口模块:`ui/src/api/tool/example.ts`(getExamplePage/exampleAdd…)。
## 学习路径建议
```text
1. 跑通页面:登录 → 工具 → 案例演示 → 增删改查/导入导出全流程点一遍
2. 对照后端:api/v1/example.go(gmeta+gvalid)→ logic/example.go(spec+委托)
→ controller/service(透传/接口)→ dao/example.go(钩子+Ctx)
3. 改一个字段:加 remark 列 → 四处补齐(3.3 改字段实战)
4. 仿写新模块:以 example 为骨架手动写一遍,或用生成器产出后对照差异
```
## 生成器视角
example 也是生成器产物的示范形态(ModuleSpec 基座风格的完整可用实现)。生成器对含枚举注释/图片列的表会产出同样的模式:
```sh
go run ./cmd/generator -tables "goframe_example|案例演示表" # 产出与 example 同构的全套文件
```
@@ -0,0 +1,48 @@
# 6.14 文件模板管理
:::tip 概述
文件模板模块(goframe_file_template,平台级表)在线管理文件/代码片段模板:code 全局唯一、按编码查询(getTemplateByCode)。注意与代码生成器的磁盘模板(templates/*.tpl)是两套互不相干的体系。
:::
## 接口清单
```text
七件套:sys:fileTemplate:{page|list|detail|add|update|delete|batchDelete}
GET /api/file/template/getTemplateByCode/:code 登录即可 按编码查模板
```
## 模块特征
| 特征 | 实现 |
|------|------|
| 唯一性 | code 全局唯一(uk_code);Add 走 CheckUnique 预检 |
| 平台级 | 无租户隔离(全局模板库) |
| 快捷查询 | getTemplateByCode(登录即可,供业务按编码取模板内容) |
| 内容形态 | 模板正文(文本/HTML/代码片段) |
## 易混淆点:两套"模板"
::: warning 两套模板互不相干
| | templates/*.tpl(磁盘目录) | goframe_file_template 表 |
|--|--|--|
| 定位 | **代码生成器的渲染模板** | 业务功能(在线管理文件/代码片段模板) |
| 消费方 | generator.go 直接读取渲染 | /api/file/template 模块 + getTemplateByCode |
| 版本管理 | 随仓库版本管理 | 数据库数据 |
| 修改影响 | 改变生成产物风格 | 影响业务取到的模板内容 |
修改生成产物改 `templates/*.tpl`;不要到 file_template 表里找生成器模板。两者无调用关系。
:::
## 使用场景
```text
* 系统内需要下发"格式文本"的场景:导出模板、打印模板、消息模板、代码片段
* 业务按编码取模板:GET /api/file/template/getTemplateByCode/{code}
* 在线编辑维护:模板管理页 CRUD(无需发版)
```
## 管理建议
+ code 命名语义化(如 export_header / print_invoice),全局唯一
+ 模板内容变更即时生效(无缓存层,按编码实时查询)
+ 平台级模块——租户差异化模板需扩展(或按 code 前缀约定租户命名空间)
@@ -0,0 +1,79 @@
# 6.15 首页与个人中心
:::tip 概述
index 模块(无表,聚合 user/menu)提供个人中心五接口:当前用户信息(超管返回通配权限)、菜单树、修改密码、更新个人信息。路由前缀 /api/index,登录即可(无 permission 节点)。
:::
## 接口清单
| 方法 | 路由 | 权限 | 说明 |
|------|------|------|------|
| GET | /api/index/getUser | 登录即可 | 当前用户信息(超管返回 `*:*:*`) |
| GET | /api/index/getMenus | 登录即可 | 当前用户菜单树(前端 common 菜单调用) |
| GET | /api/index/getMenus/:id | 登录即可 | 同上(system/menu 页面调用) |
| PUT | /api/index/updatePassword | 登录即可 | 修改个人密码(旧密码校验) |
| PUT | /api/index/updateProfile | 登录即可 | 更新个人信息 |
## GetUser(当前用户信息)
```text
数据来源与安全边界:
* 用户行经 dao.User.Ctx(自动过滤软删/租户);password/salt 永不返回
* Roles/Perms 优先取 JWT 中间件注入的 ctx(与鉴权同源),为空时回源 LoadIdentity
* 超管强制 perms=["*:*:*"](前端据此放开全部按钮)
* 头像经 upload.GetFileUrl 补域名
* 城市编码四列(province/city/district/street_code)组装为 city[] 数组;
city_info 为空时用 ResolveCityNames 按编码回填名称
```
## GetMenus(菜单树)
```text
两条路由共用 getMenuTree:
* 仅取 type=0(目录/菜单)且 status=0 且 hide=0 的节点
——按钮权限节点不进菜单树,由 GetUser.permissions 下发
* 超管全量;其余 user→user_role→role_menu→menu.id IN(...) 授权过滤
* 无角色返回空树([] 非 null)
* 挂树规则:父节点不在授权集合内(未授权/顶级)一律挂根,避免菜单丢失
* 排序 sort,id(根级再按 sort+id 兜底稳定排序)
```
## UpdatePassword(修改密码)
```text
流程:
1) 只取 id/password/salt 三列
2) 旧密码 IsBcrypt + VerifyPassword 校验(错误 1012 原密码错误)
3) 事务外 GenerateSalt + HashPassword(CPU 密集预计算)
4) Update 覆盖 password/salt(update_user/time 由 hooks 盖章)
注意:改密后未强制下线(令牌仍有效至过期);
如需立即失效可配合在线用户强退(见 6.16 在线用户监控)
```
## UpdateProfile(更新个人信息)
```text
流程:
* 头像 SaveFileField(avatar, "user") 临时转正
* 城市优先由编码数组经 ResolveCityNames 解析名称覆盖 city_info
* 四列编码经 cityCodeAt(req.City, i) 取值写入
安全边界:不触达 username/password/tenant 等敏感列
```
## 前端消费
```text
登录后首页 dashboard:调 getUser(头像/名称/权限)+ getMenus(侧边菜单)
路由守卫:动态路由由 getMenus 生成(见 9.5 路由与动态菜单)
个人中心页:updateProfile(资料)/ updatePassword(改密)
```
## 排障
| 症状 | 原因 |
|------|------|
| 菜单为空 | 无角色或角色未勾选菜单;超管应返回全量 |
| getUser 权限缺失 | 身份缓存异常;超管应返回 `*:*:*` |
| 改密后旧令牌仍可用 | 设计预期(不强制下线);配合在线用户强退 |
| 头像不显示 | upload.GetFileUrl 域名配置(file.domainUrl)与静态服务根 |
@@ -0,0 +1,132 @@
# 6.11 定时任务
:::tip 概述
定时任务是数据驱动的常驻调度器:任务定义在 goframe_job 表,启动时加载 status=1 任务挂载 gcron,执行目标分双形态(HTTP 调用 / 本地处理器),三种执行策略叠加多实例执行锁,结果落 goframe_job_log。
:::
## 架构总览
```text
启动(cmd.go 延时 2s)→ DaemonInit 加载 goframe_job 中 status=1 的任务
→ 按 cron_expression 挂载 gcron → tick 触发 executeJob
→ 执行目标分派(HTTP 调用 或 本地处理器)→ 结果写 goframe_job_log
职责分层:
调度基础设施(挂载/摘除/锁/执行/落日志)在 internal/logic 的 job 模块
任务业务实现在 internal/task 包(单向依赖 task→logic,logic 禁止反向 import task)
由 internal/cmd 空白导入 internal/task 触发 init 注册
```
## 执行目标双形态(executeJob 读 url 字段分派)
```text
url 为 http:// 或 https:// 开头 → HTTP 调用目标地址:
params 为空 GET,非空 POST(application/json,超时 30s)
非 200 或网络错误 → 失败(exception 记录状态与响应前 1000 字符)
url 其余取值 → 本地处理器方法名,从 jobHandlers 注册表查找:
签名 func(ctx context.Context, params string) (string, error)
返回摘要写入任务日志 message
未注册 → 失败"未找到本地任务处理方法"
```
## 执行策略与执行方式
```text
execute_policy(列值):
1 立即执行 允许并发:gcron.Add,tick 即启动新执行;不加执行锁
2 执行一次 单例调度(gcron.AddSingleton),上次未结束跳过本次;叠加执行锁
3 放弃执行 并发 tick 照常触发,但抢不到执行锁则放弃本次
is_sync(列值):
1 同步 在调度协程内执行(阻塞本条目下一 tick 的派发)
0 异步 go 协程执行(context.Background())
执行锁 jobTryLock(策略 2/3 叠加):
键 job:lock:{id},utility/cache SET NX(TTL 300s 兜底——实例崩溃未释放自动过期;
正常路径 defer 立即释放)。多实例部署共享同一把锁(Redis 模式防跨实例并发;
内存降级时退化单机语义)
cron 表达式:gf gcron 格式,默认 6 段(秒 分 时 日 月 周);
# 前缀表示标准 5 段忽略秒(如 "# */5 * * * *" = 每 5 分钟)
```
## 调度流程与状态机
```text
Add(固定置0)
│
▼
┌─────────┐ Status(1) / Resume ┌─────────┐
│ 0 未发布 │─────────────────────▶│ 1 运行中 │◀──┐
└─────────┘ └────┬────┘ │
▲ Status(2)/Pause │ │ Resume
│ ─────────────────────▶│ │
│ 摘除调度 ▼ │
│ ┌─────────┐ │
│ Status(0) │ 2 暂停 │───┘
│◀─────────────────────────── └─────────┘
│ 摘除调度
Delete:软删 + 摘除调度
运行中 Update:摘除 → 按新 cron 重挂(失败降级置 2 并报 Cron 无效)
RunOnce:不看状态,异步 force 执行一次(不改状态)
```
## 管理接口(/api/job)
```text
七件套 sys:job:*
GET /api/job/runOnce/:id sys:job:execute 立即执行(异步,未发布也可执行)
GET /api/job/pause/:id sys:job:pause 摘除调度 + status=2
GET /api/job/resume/:id sys:job:resume 重新挂载 + status=1
POST /api/job/status sys:job:update 设状态(1→挂载,其他→摘除)
GET /api/job/daemonStatus 登录即可 调度器状态(running/jobIds/startTime)
业务钩子:BeforeAdd 固定 status=0;AfterUpdate 运行中先摘除再重挂;
BeforeDelete 存在性检查 + 摘除调度
字典:job_status / job_execute_policy / job_is_sync(SerializeMaps 驱动 {field}Text)
```
## 本地处理器注册(internal/task)
```text
logic.RegisterJobHandler(name, h):注册名全局唯一,重复注册进程 panic
注册名与任务记录 url 字段值一一对应(数据驱动查找)
internal/task 包约定:
每任务一个文件(snake_case),init 中注册
处理方法签名 func(ctx, params) (string, error),返回值写任务日志摘要
由 internal/cmd 空白导入触发加载(不在 HTTP 启动链路内)
内置任务:
HelloWorld(hello_world.go) 示例,仅打日志(验证调度链路)
SendSms(send_sms.go) 仅记录执行参数日志(不对接短信通道)
LogClean(log_clean.go) 日志清理:每日 02:30 分批物理删除三张日志表
参数 {"retentionDays": n} 可覆盖 job.logRetentionDays(默认 90)
单批 5000 行循环至不足一批;单表单次任务上限 200000 行(防长事务/runaway)
```
## 新增任务开发步骤
```text
1) internal/task/{任务名}.go 实现 handler:func(ctx, params) (string, error)
2) init() 中 logic.RegisterJobHandler("TaskName", handler)
3) 任务管理页新增记录:url 填 "TaskName",params 填 JSON,
cron_expression 填 6 段或 # 5 段,execute_policy/is_sync 按需
4) Status 置为运行中(发布)→ 调度器挂载;观察 job/log 执行日志
5) go build ./... 并重启(init 注册发生在进程加载期)
注意:handler 可复用 base 共享能力(WithTrans/ChunkQuery)与 dao;
长任务用分批+上限;HTTP 目标任务自带 30s 超时,长任务应改本地处理器
```
## 多实例部署语义
```text
单实例:gcron 各自挂载,正常运行
多实例 + Redis:策略 2/3 经 job:lock:{id} 全局互斥(同一时刻至多一个实例执行)
——仍会重复"触发"(各实例各自 tick),未抢到锁的实例直接放弃
多实例 + 内存降级:锁退化单机语义,可能跨实例并发执行——生产 Redis 必须可用
```
排障口径差异:`daemonStatus.running` 是 gcron 全部条目数(含 CleanTemp 等非任务条目);`server/info` 的 `Scheduler.mounted` 只统计 "job:" 前缀条目。
@@ -0,0 +1,54 @@
# 6.5 职级管理
:::tip 概述
职级模块(goframe_level,租户级表)管理用户职级属性:标准 CRUD + 状态切换 + Excel 导入导出(与 user/example 并列的三个导入导出示范模块之一),列表带 user_count 引用统计。
:::
## 接口清单
| 方法 | 路由 | 权限 | 说明 |
|------|------|------|------|
| GET | /api/level/page | sys:level:page | 分页 |
| GET | /api/level/list | sys:level:list | 列表 |
| GET | /api/level/detail/:id | sys:level:detail | 详情 |
| POST | /api/level/add | sys:level:add | 新增 |
| PUT | /api/level/update | sys:level:update | 修改 |
| PUT | /api/level/status | sys:level:status | 状态切换 |
| DELETE | /api/level/delete/:id | sys:level:delete | 软删 |
| DELETE | /api/level/batchDelete | sys:level:batchDelete | 批量删除 |
| POST | /api/level/import | sys:level:import | Excel 导入 |
| GET | /api/level/export | sys:level:export | Excel 导出 |
## 模块特征
| 特征 | 实现 |
|------|------|
| 导入导出 | excel.ReadSheet / WriteSheet + ChunkQuery 分批导出 |
| 引用统计 | fillUserCount:按 level_id 统计引用用户数(base.go 共享能力,level/position 复用) |
| 唯一性 | 租户内名称唯一(uk_tenant_name);未声明预检,1062 兜底 |
| 字典 | status → 字典驱动 {field}Text |
## 与其他模块的关系
```text
user.level_id → level.id 用户归属职级(用户表单职级选择)
fillUserCount 列表/详情展示 user_count(该职级下用户数)
—— 删除前可据此判断是否被引用
```
## 导入导出模式(与 user/example 同款)
```text
Import:multipart file(*ghttp.UploadFile + p:"file")
→ excel.ReadSheet(首行表头,全单元格字符串,空行跳过)
→ 逐行映射/校验(唯一、字典值)→ 批量 Insert → 返回导入统计
Export:与列表同筛选 → ChunkQuery 分批(默认 500/页,防 OOM)
→ excel.WriteSheet 写临时文件 → 返回文件 URL(/upload/temp/...)
```
## 管理建议
+ 职级是租户级数据——各租户独立维护自己的职级体系
+ 删除前查 user_count 引用;有引用先调整用户职级
+ 导入模板与导出列保持一致(表头=表单字段名);批量导入注意租户内唯一名称
@@ -0,0 +1,67 @@
# 6.3 菜单管理
:::tip 概述
菜单模块(goframe_menu,平台级表)统一承载两类节点:type=0 目录/菜单(进前端菜单树)与 type=1 权限节点(进后端 Perms 校验)。树形结构、防环校验、关联清理齐备。
:::
## 接口清单
```text
七件套:sys:menu:{page|list|detail|add|update|delete|batchDelete}
List 返回树形(IsTree + ParentOf)
```
## type 语义(关键概念)
| type | 名称 | 消费方 |
|------|------|--------|
| 0 | 菜单/目录 | 前端菜单树(getMenuTree 过滤 type=0 且 status=0 且 hide=0) |
| 1 | 权限节点 | 后端 Perms 校验(LoadIdentity 过滤 type=1 且 status=0 且 hide=0) |
```text
menu 行的关键列:
parent_id 父级(树形)
type 0-菜单 1-权限节点(注释枚举)
permission 权限码(sys:user:page 形式;type=1 必填)
status 0-启用(禁用节点不进树也不进 Perms)
hide 0-显示(hide=1 不进菜单树)
sort 排序
```
::: warning hide 与 status 的区别(易错)
hide=1 的权限节点**不生效**:getMenuTree 与 LoadIdentity 都过滤 hide=0,hide=1 节点既不进菜单树也不进 Perms。"禁用权限"必须用 status,不能用 hide。
:::
## 树形与守卫
```text
List 树形:spec 声明 IsTree + ParentOf(DTO 含 Id/Children,叶子 Children 预置 [])
删除守卫 menuDeleteGuard:存在子菜单拒绝(1011)
关联清理 menuCleanRelations:删除菜单时同步清理 role_menu 引用
(批量删除同理)——防止悬挂授权
父级校验 menuParentGuard:parentId 不得指向自身或后代(防环)
```
## 菜单树的消费(index.GetMenus)
```text
getMenuTree:
1) 仅取 type=0 且 status=0 且 hide=0 的节点(按钮权限不进菜单树,
由 GetUser.permissions 下发)
2) 超管全量;其余 user→user_role→role_menu→menu.id IN(...) 授权过滤
3) 无角色返回空树([] 非 null)
4) 挂树规则:父节点不在授权集合内(未授权/顶级)一律挂根,避免菜单丢失
5) 排序 sort,id(稳定排序)
```
## 与生成器的关系
代码生成器 createMenu 副作用自动创建:主菜单(type=0,挂 generator.parentMenuId 下)+ 权限节点(type=1,7 固定 + status/import/export 按需)。按 path+type 查重幂等。详见 [10.8 实战案例](../generator/demo)。
## 管理建议
+ 权限节点命名与 gmeta permission 严格一致(sys:{module小驼峰}:{action})
+ 一个模块一个主菜单 + N 个权限节点,权限节点挂主菜单下
+ 禁用节点用 status=2(保留数据可恢复);删除走软删 + 关联清理
+ hide 只用于"菜单树隐藏但(当前实现下)不建议用于权限节点"——见上方警告
@@ -0,0 +1,82 @@
# 6.16 在线用户监控
:::tip 概述
在线用户是无表模块(数据源 Redis):登录/刷新登记会话,分页查看在线列表,强退踢出(access+refresh 双令牌拉黑,防被踢会话经刷新复活)。路由前缀 /api/onlineUser。
:::
## 数据结构(Redis)
```text
zset easygoadmin:online:jti:set score = 访问令牌过期时间戳
键 easygoadmin:online:info:{jti} JSON 详情(TTL = access 剩余期 + 60s)
onlineInfo 字段:
jti / userId / username / realname / ip / location / os / browser /
loginTime / issuedAt / expireAt / refreshJti / refreshIssuedAt / refreshExpireAt /
accessToken / refreshToken(令牌原文,仅详情查看返回)/ tokenType / expiresIn
```
## 会话生命周期
```text
登录/refresh 成功
│ MarkOnline
▼
┌─────────────────────────────────────────────┐
│ zset easygoadmin:online:jti:set │ score=access过期ts
│ 键 easygoadmin:online:info:{jti} = JSON │ TTL=access剩余+60s
└──────────────────┬──────────────────────────┘
页面访问(有效期内)│ │登出 Logout / 强退 ForceLogout
(token 有效,│ ▼ MarkOffline + access/refresh 双拉黑
JWT 放行) │ zset 移除 + info 删除 → 会话消失
▼
access 到期:zset score < now 被区间过滤(列表不可见);
info 键 TTL 到期自动清除
refresh 到期:无法再换新令牌
无心跳/续期机制:会话有效期即 access 令牌有效期(2h);
前端 refresh 轮换会刷新在线登记(MarkOnline 覆盖写)
```
## 接口清单
| 方法 | 路由 | 权限 | 说明 |
|------|------|------|------|
| GET | /api/onlineUser/page | sys:onlineUser:page | 在线会话分页 |
| GET | /api/onlineUser/detail/:id | sys:onlineUser:detail | 会话详情(含令牌原文) |
| GET | /api/onlineUser/forceLogout/:id | sys:onlineUser:forceLogout | 单会话强退 |
| GET | /api/onlineUser/forceLogoutUser/:userId | sys:onlineUser:forceLogout | 踢出该用户全部设备 |
## Page 流程
```text
ZRangeByScore(now..+∞) → 逐 jti 读详情 → username/ip 子串过滤
→ 按登录时间倒序 → 同用户多会话标注(SessionNo/Sessions 序号与总数)
→ 批量回填头像(按 userId 查 user.avatar,GetFileUrl 补域名,单用户只查一次)
→ 内存分页(NewPageData)
```
## 强退的双令牌拉黑(安全关键)
```text
单会话强退(forceLogout/:id):
1) access jti 拉黑(TTL=剩余期+60s)
2) refresh jti 一并拉黑——防被踢会话经 refresh 换新令牌复活
3) MarkOffline(zset 移除 + info 删除)
踢全部设备(forceLogoutUser/:userId):
遍历 zset 匹配 userId → 逐会话双令牌拉黑 + 注销
```
::: tip 为什么双拉黑
只拉黑 access 的话,被踢用户的 refresh_token 仍能兑换新令牌对重新"复活"。access+refresh 一并拉黑才能真正踢出。
:::
## 排障
| 症状 | 原因 |
|------|------|
| 在线列表为空但有人在线 | Redis 内存降级(各实例独立);或会话已过 access 有效期 |
| 强退后对方仍能请求 | 该实例内存降级(黑名单不跨实例);等窗口或查 /api/cache/info |
| 会话数偏多 | access 2h 有效期内多次登录=多会话(同用户多设备标注序号) |
| 详情泄露令牌原文 | 设计如此(运维排查用),依赖 sys:onlineUser:detail 权限收口 |
@@ -0,0 +1,36 @@
# 6.12 参数管理
:::tip 概述
参数模块(goframe_param,平台级表)管理系统级扁平键值参数:code 全局唯一(uk_code),标准七件套 CRUD。机制说明见 [5.13 系统参数管理](../core/param),本节给出模块视角。
:::
## 接口清单
```text
七件套:sys:param:{page|list|detail|add|update|delete|batchDelete}
```
## 模块要点
| 要点 | 实现 |
|------|------|
| 唯一性 | UniqueFields{Field:"code"}(与全局 uk_code 一致,无偏差)——Add 前预检 |
| 平台级 | 无租户隔离(全局参数) |
| 消费方式 | 业务按 code 查询取值(低频直接查库) |
| 选择判据 | 简单键值对 → param;结构化表单配置 → config;需重启 → config.yaml |
## 与 config 模块的对照
| 维度 | param | config + config_item |
|------|-------|---------------------|
| 形态 | 扁平 code→value | 分组 + 键值明细(type/options 结构化) |
| 适用 | 运行参数快速读写 | 网站配置等表单化场景 |
| 接口 | 七件套 | 七件套 + web/index + web/save |
| 唯一预检 | 有(spec UniqueFields) | Add 走 CheckUnique(Update 1062 兜底) |
## 管理建议
+ code 命名 snake_case 语义化(default_password / page_size / user_code_prefix)
+ 删除是软删(可恢复),但删除前确认无业务引用(param 无引用守卫)
+ 高频消费场景(每请求读)建议调用方自建缓存(param 模块无缓存层)
+ 新增参数后在代码中以常量引用 code 字符串,避免拼写漂移
@@ -0,0 +1,41 @@
# 6.6 岗位管理
:::tip 概述
岗位模块(goframe_position,租户级表)管理用户岗位属性:标准 CRUD + 状态切换 + 引用统计。与职级模块同构(同为用户属性维护模块)。
:::
## 接口清单
```text
七件套 + PUT /api/position/status(sys:position:status)
sys:position:{page|list|detail|add|update|delete|batchDelete|status}
```
## 模块特征
| 特征 | 实现 |
|------|------|
| 状态切换 | status(1-正常 2-禁用,字典驱动 {field}Text) |
| 引用统计 | fillUserCount:按 position_id 统计引用用户数 |
| 唯一性 | 租户内名称唯一(uk_tenant_name);未声明预检,1062 兜底 |
| 租户级 | 各租户独立维护 |
## 与其他模块的关系
```text
user.position_id → position.id 用户归属岗位(用户表单岗位选择)
fillUserCount 列表展示 user_count(该岗位下用户数)
```
## 与职级模块的差异
| 维度 | 岗位 position | 职级 level |
|------|--------------|-----------|
| 导入导出 | 无 | 有(import/export) |
| 其余 | 同构(CRUD + status + 引用统计) | 同构 |
## 管理建议
+ 岗位与职级的分工:岗位偏"职责"(如前端工程师),职级偏"层级"(如 P7)
+ 删除前查 user_count 引用;有引用先调整用户岗位
+ 岗位编码/名称变更即时生效(无缓存层,用户展示经 userExpandNames 批查回填)
@@ -0,0 +1,68 @@
# 6.2 角色管理
:::tip 概述
角色模块(goframe_role,租户级表)承载 RBAC 分配与数据权限四档:MenuList 返回带勾选状态的全量菜单树,SavePermission 全量重写角色菜单并清理身份缓存,data_scope 经字典驱动展示。
:::
## 接口清单
| 方法 | 路由 | 权限 | 说明 |
|------|------|------|------|
| GET | /api/role/page | sys:role:page | 分页(含 dataScope + dataScopeText) |
| GET | /api/role/list | sys:role:list | 列表 |
| GET | /api/role/detail/:id | sys:role:detail | 详情 |
| POST | /api/role/add | sys:role:add | 新增(data_scope 缺省归一 1) |
| PUT | /api/role/update | sys:role:update | 修改 |
| DELETE | /api/role/delete/:id | sys:role:delete | 软删(存在用户引用则拒绝) |
| DELETE | /api/role/batchDelete | sys:role:batchDelete | 批量删除 |
| GET | /api/role/menu/list/:roleId | sys:role:getPermission | 查询角色菜单(带勾选状态) |
| POST | /api/role/menu/save | sys:role:savePermission | 保存权限(全量重写) |
## 权限勾选(MenuList / SavePermission)
```text
MenuList(GET /api/role/menu/list/:roleId):
返回全量菜单树(type 0/1 均含,供勾选界面展示目录+菜单+按钮三层),
每节点带 Checked = role_menu 是否含该 id(checkedSet 预构建)
SavePermission(POST /api/role/menu/save)——语义为"全量重写":
1) 先 Delete 该角色全部 role_menu(软删)
2) 逐个 Insert req.MenuIds
3) clearRoleIdentityCache(roleId)
缓存清理链:按 roleId 查受影响 user_id 列表 → 逐个 Del
easygoadmin:user:identity:{uid}
```
## data_scope 数据范围链路
```text
roleToData:Add/Update 均带 data_scope,入参 ≤0 归一为 1(全部),
gvalid in:1,2,3,4 校验
roleSpec.SerializeMaps = {"data_scope": "role_data_scope"}
→ 列表行 dataScopeText 由字典填充
AfterUpdate(roleClearCache):数据范围变更后失效该角色全部用户身份缓存
→ 下一次请求 LoadIdentity 重取 min(data_scope) 即生效
```
四档语义与安全边界见 [5.9 数据权限](../core/data-scope)。
## 删除守卫
存在用户引用则拒绝删除(1014/1011 语义)——先给用户换角色,再删空角色。
## 已知偏差(如实记录)
::: warning 唯一预检范围偏差
role 的 spec 唯一预检当前声明为 `UniqueFields{Field:"code"}`(未带 Group),而索引 `uk_tenant_code` 是"租户内唯一"——预检范围偏宽:超管为不同租户创建相同角色编码时会被误报"已存在"(数据库层不会冲突)。
修复方向:补 `Group:"tenant_id"`,使预检范围等于索引范围。
:::
## 排障
| 症状 | 原因 |
|------|------|
| 保存权限后用户仍 403 | 身份缓存未清(SavePermission 应触发清理链);DEL `easygoadmin:user:identity:{uid}` |
| 改 data_scope 不生效 | 同上——AfterUpdate 应清该角色全部用户缓存 |
| 删除角色报"存在关联" | 有用户挂该角色,先在用户管理换角色 |
| 不同租户建同名角色编码被拒 | 上述已知偏差(预检缺 Group) |
@@ -0,0 +1,76 @@
# 6.17 服务器监控
:::tip 概述
服务器监控是无表模块:单接口聚合返回运行时、内存、磁盘、数据库连通、缓存模式与调度器健康。磁盘读取按平台分文件实现(windows/other build tags)。路由 GET /api/server/info(登录即可)。
:::
## 接口
```text
GET /api/server/info 登录即可,log "服务器监控-查询信息"
权限:无 permission 节点(登录即可)
```
## 返回结构(ServerInfoRes)
```text
Server hostname / goVersion / arch / numCPU / goroutines / uptime
(procStart 为包加载时刻)
Mem allocMB / totalAllocMB / sysMB / heapAllocMB / heapSysMB /
heapObjects / gccycles(runtime.ReadMemStats,MB 保留两位小数)
Disk 平台分支实现:
windows(//go:build windows):GetDiskFreeSpaceEx 读工作目录所在盘符
(wd[:3] 如 "E:\"),返回 totalGB/freeGB/usedPercent
other(!windows):syscall.Statfs
Db SELECT VERSION() 计时(pingMs)+ 版本号;
数据库不通不视为整体失败(版本留空,err 置 nil 返回)
Cache cache.UsingMemory() → {mode:"memory", connected:false}
否则 Redis PING → {mode:"redis", connected:true/false}
Scheduler 调度器健康:统计 gcron 中 "job:" 前缀条目数(mounted)与
goframe_job status=1 计数(expected);enabled=DaemonInit 已执行;
healthy = mounted >= expected(挂载失败或数据被外部改动会出现差值)
```
## 平台分支实现(文件组织示例)
```text
internal/logic/server_monitor.go 公共组装
internal/logic/server_monitor_windows.go //go:build windows —— GetDiskFreeSpaceEx
internal/logic/server_monitor_other.go //go:build !windows —— syscall.Statfs
Go build tags 实现"同函数名、按平台选实现"——编译期裁剪,零运行时分支
```
## 调度器健康判定(运维关键)
```text
mounted = gcron 中 "job:" 前缀条目数(实际挂载)
expected = goframe_job status=1 计数(应该挂载)
healthy = mounted >= expected
差值排查:
mounted < expected 部分任务挂载失败(cron 表达式无效?)
或任务数据被外部改动(直接改库没走 API)
mounted > expected 摘除遗漏(API 路径应先摘除再处理)
注意口径:daemonStatus.running 是 gcron 全部条目数(含 CleanTemp 等非任务条目),
Scheduler.mounted 只统计 "job:" 前缀——两处口径不同
```
## 运维用法
```text
日常巡检:GET /api/server/info 一眼看全(CPU 核数/内存/磁盘余量/DB/缓存/调度)
磁盘告警阈值:usedPercent > 85% 关注
内存观察:heapObjects 持续增长 + gccycles 低 → 疑似泄漏
调度异常:healthy=false → 逐条核对任务 cron 与 status
```
## 排障
| 症状 | 原因 |
|------|------|
| Disk 数据异常 | windows 读的是工作目录所在盘符(wd[:3])——服务在哪个盘就读哪个盘 |
| Db 版本为空 | 数据库不通(不视为整体失败的设计);查 database 配置 |
| Cache mode=memory | Redis 不可用已降级(限流/锁/黑名单退化单机语义) |
| Scheduler healthy=false | 见上方差值排查;对照 /api/job/daemonStatus |
| uptime 很长但内存高 | procStart 是包加载时刻——优雅重启才会重置 |
@@ -0,0 +1,66 @@
# 6.13 租户管理
:::tip 概述
租户模块(goframe_tenant,平台级表)管理多租户生命周期:状态/过期、用户上限、事务化开户(Account,code 按 T+ID 规则回填)。隔离机制详见 [5.8 多租户隔离](../core/tenant)。
:::
## 接口清单
| 方法 | 路由 | 权限 | 说明 |
|------|------|------|------|
| GET | /api/tenant/page | sys:tenant:page | 分页(平台专属视图,带用户数统计) |
| GET | /api/tenant/list | sys:tenant:list | 列表 |
| GET | /api/tenant/detail/:id | sys:tenant:detail | 详情 |
| POST | /api/tenant/add | sys:tenant:add | 新增 |
| PUT | /api/tenant/update | sys:tenant:update | 修改 |
| PUT | /api/tenant/status | sys:tenant:status | 状态切换 |
| DELETE | /api/tenant/delete/:id | sys:tenant:delete | 软删 |
| DELETE | /api/tenant/batchDelete | sys:tenant:batchDelete | 批量删除 |
| POST | /api/tenant/account | sys:tenant:add | 创建租户账号(开户) |
| GET | /api/tenant/users/:tenantId | sys:tenant:users | 查询租户用户 |
## 编码规则(T%04d,自增 ID 回填)
```text
code 为 NOT NULL + 唯一键,插入时无法预知 ID →
1) 先写临时占位编码(T%010d,纳秒取模防撞)
2) Insert 后按 LastInsertId 修正为 "T"+4 位ID(如 T0001)
Add 与 Account 均遵循此模式;显式传 code 时查重后直接使用不回填
```
## 开户事务(tenant.Account,真实流程)
```text
bcrypt 在事务外预计算(短事务纪律)→ WithTrans 内三段原子写:
① Insert 租户(status=1,hooks 填审计列)
② 修正 code = T%04d(tenantId)
③ 以 tenantCtx(WithTenantID(txCtx, tenantId))Insert 初始管理员:
code = "{code}-000001"(首用户序号),tenant_id 显式传入,
password/salt 为预计算值
返回 {tenantId, code, userId}
角色分配未含在开户事务内(按需另行调用)
事务纪律:事务内不做 HTTP/文件 IO,bcrypt 等 CPU 计算事务外预计算
```
## 状态与缓存
```text
tenantFilter:列表为平台专属视图(超管可见全部,带每租户用户数统计)
fillUserCount:每租户用户数(对照 max_user_num 判断容量)
clearCache:状态/过期变更后 Del tenant:info:{id}
(Tenant 中间件 60s 摘要缓存——变更后最多 60s 生效,Del 后即时生效)
```
## 关联约束
+ 用户数上限:user.Add 的 checkTenantUserMax 对照 max_user_num(1007)
+ 租户用户列表:GET /api/tenant/users/:tenantId
+ 租户删除前应清空/迁移该租户用户与业务数据(软删租户不级联)
## 管理建议
+ expire_time 为 nil 表示永不过期;到期前 30 天关注续期
+ max_user_num 控制容量;调大即时生效(user.Add 实时校验)
+ 禁用租户(status=2)立即拦截该租户全部用户请求(等 60s 缓存或 Del 即时)
+ 开户后记得为初始管理员分配角色(Account 不含角色分配)
@@ -0,0 +1,90 @@
# 6.1 用户管理
:::tip 概述
用户模块(goframe_user,租户级表)是机制最全的模块:唯一校验(按行现租户)、租户用户上限、角色全量重写、城市编解码、Excel 导入导出、数据权限接线、批量名称回填。路由前缀 /api/user。
:::
## 接口清单
| 方法 | 路由 | 权限 | 说明 |
|------|------|------|------|
| GET | /api/user/page | sys:user:page | 分页(关键词/状态/部门/角色/职级/岗位筛选) |
| GET | /api/user/list | sys:user:list | 全量列表(下拉选择用) |
| GET | /api/user/detail/:id | sys:user:detail | 详情(含角色 ID 数组与四级区划编码) |
| POST | /api/user/add | sys:user:add | 新增(返回新记录 ID) |
| PUT | /api/user/update | sys:user:update | 修改(密码留空不改) |
| PUT | /api/user/status | sys:user:status | 状态切换 |
| PUT | /api/user/resetPwd | sys:user:resetPwd | 重置密码(logType=reset) |
| DELETE | /api/user/delete/:id | sys:user:delete | 软删(清理 user_role) |
| DELETE | /api/user/batchDelete | sys:user:batchDelete | 批量删除 |
| POST | /api/user/import | sys:user:import | Excel 导入 |
| GET | /api/user/export | sys:user:export | Excel 导出 |
| GET | /api/user/document/:id | sys:user:detail | 打印信息 |
## 核心机制
### 唯一校验(手写,按行现租户)
```text
usernameExists / mobileExists 按 (username|mobile, tenant_id, 排除自身) 查;
Update 时先读被改行的 tenant_id 再校验——校验范围必须等于
uk_tenant_username / uk_tenant_mobile 索引范围(含 del_flag 由 dao.Ctx 恒过滤保证)。
历史教训:用 GetTenantID(超管=默认租户)会漏报 1062——必须按行现租户。
```
### 角色同步(AssignRoles)
```text
AssignRoles(userId, roleIds):全量重写 user_role(先删后插)
→ clearIdentityCache(userId)
Add/Update 均经此方法;Update 改 dept_id 也在该路径清缓存——部门变更即时生效
(数据权限无需等 300s 缓存过期)
```
### 租户用户上限(checkTenantUserMax)
统计租户现有用户数 vs tenant.max_user_num,超限返回 1007。在 user.Add 生效。
### 用户编码(genUserCode)
```text
前缀=租户 code(平台/超管为 "SYS"),序号=该租户现有用户数+1
格式 "{prefix}-%06d",如 T0001-000001
```
### 数据权限接线
userSpec 声明 `DataScope: ApplyDataScope("dept_id")`——user 是当前唯一接线模块(全库仅 user 表有 dept_id 列)。
### 列表名称回填(userExpandNames)
列表行批量补 dept/level/position/role 名称与城市文本(namesById/dictName 批查防 N+1)。
## 导入导出
```text
Import(POST /api/user/import):
excel.ReadSheet → 表头映射 → 逐行:唯一校验 / 租户上限 / 默认密码 bcrypt
(事务外预计算)/ 角色与城市解析 → 批量插入 → 返回成功/失败统计
城市列经 ResolveCityCodes(名称串) 转编码入库
Export(GET /api/user/export):
与列表同筛选(userFilter + DataScope,applyFilters 同链)
→ ChunkQuery 500/页 → excel.WriteSheet
导出可见集 = 列表可见集(导出=所见)
```
## 城市编解码接入
Add/Update/Import/Detail 均接入包级函数:写侧 `ResolveCityCodes`(名称串 → 四级编码入库),读侧 `ResolveCityNames`(编码 → 名称还原)。机制见 [6.9 行政区划管理](./city)。
## 字段与安全边界
```text
password/salt:bcrypt 双重加盐;接口永不返回
密码留空 = 不修改(Update 语义)
删除守卫:userDeleteGuard / 关联清理(user_role)
SerializeMaps:status → user_status({field}Text 字典填充)
FileFields: avatar + FileSaveDir: "user"(两段式上传自动转正)
```