init: 数字员工平台初始代码
包含前端(Vue3 + VueFlow 画布)、后端(Go)、文档体系。 - 工作台画布:节点拖放、连线模式、右键菜单、AI 助手 - 后端:连接器 API、专员种子数据 - 导航:左侧导航、工坊、市场、控制台
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
# pj0231 岗位与知识对应能力 —— 落地设计稿
|
||||
|
||||
> 版本:V0.1(草案) | 日期:2026-08-16
|
||||
> 上位文档:《对比分析_pj0231_vs_pj006-zhilianyuan2.md》§5
|
||||
> 目标:补齐 pj0231 缺失的「岗位 → 知识 → 考试」能力链,对齐 pj006 的岗位驱动思想,但**保持 pj0231 的极简单租户定位,不照搬 pj006 的洋葱模型与多租户机制**。
|
||||
>
|
||||
> **状态:已落地。** 本设计的 P0(岗位 → 知识映射 → 用户设岗 → 岗位驱动组卷)已在 V1.4 实现,`position` / `position_knowledge` / `position_exam_blueprint` 等表与接口见 `docs/changelog.md` V1.4–V1.5。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状痛点
|
||||
|
||||
对比分析确认 pj0231 存在**结构性缺失**:
|
||||
|
||||
- 用户只有 `role`(employee/admin),**没有岗位概念**;
|
||||
- 题库只有粗粒度 `domain`(company / product / sales)+ 可选 `course_id`,**没有「某岗位该掌握哪些知识/课程/产品」的对应关系**;
|
||||
- 考试组卷只能按 `domain` 抽题,**无法做到「按岗位应学范围出题」**;
|
||||
- 因此「该岗位该学什么、该考什么」完全靠人工记忆,无法系统化。
|
||||
|
||||
### 1.2 本次目标
|
||||
|
||||
落地一条**最小的岗位驱动闭环**:
|
||||
|
||||
```
|
||||
岗位(Position) ──绑定──> 用户(User)
|
||||
│
|
||||
└──映射──> 岗位知识要求(PositionKnowledge)
|
||||
├── 知识域 domain(company/product/sales)
|
||||
├── 课程 course_id(可选)
|
||||
└── 产品 product_id(可选)
|
||||
|
||||
考试(ExamPaper.position_id) ──> 按岗位知识要求自动抽题组卷
|
||||
```
|
||||
|
||||
### 1.3 非目标(明确不做,见 §9)
|
||||
|
||||
- 不引入 pj006 的 `competency_layers / levels / details` 三层洋葱模型;
|
||||
- 不引入多租户 `org` 组织模型;
|
||||
- 不做 LLM 自动生成岗位能力模型初稿;
|
||||
- 不做学习进度 / 学情分析(依赖学习进度表,另行立项)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计原则(强制)
|
||||
|
||||
1. **保持极简**:只加「岗位 + 映射」这一条能力链,不为对齐 pj006 而堆表。
|
||||
2. **复用现有机制**:考试组卷复用 `exam.go` 的 `pickQuestions`;路由复用 `RequireAdmin`;迁移复用 GORM `AutoMigrate`。
|
||||
3. **级别词汇对齐 pj006 的 L1-L4**,但**不建独立级别表**——`required_level` 用普通字符串字段 + Go 常量,降低复杂度。
|
||||
4. **单岗位优先**:一个用户一个岗位(`user.position_id` 可空),满足绝大多数内部员工场景;多岗位需求出现时再升级为映射表。
|
||||
5. **不破坏现有数据**:所有新列可空、新表独立,AutoMigrate 幂等。
|
||||
|
||||
---
|
||||
|
||||
## 3. 现状盘点(改动点清单)
|
||||
|
||||
| 现有资产 | 位置 | 改动 |
|
||||
|---|---|---|
|
||||
| `User` | `internal/model/user.go` | **加列** `PositionID *uint`(可空) |
|
||||
| `ExamPaper` | `internal/model/exam_paper.go` | **加列** `PositionID *uint`(可空) |
|
||||
| `Question` | `internal/model/question.go` | 不改 |
|
||||
| `Course` / `Product` | `internal/model/` | 不改 |
|
||||
| 迁移 | `internal/store/db.go` `AutoMigrate` | 追加 2 张新表 |
|
||||
| 路由 | `internal/api/router.go` | 新增岗位相关端点(admin 组) |
|
||||
| 抽题 | `internal/api/exam.go` `pickQuestions` | 增加「岗位知识映射」过滤分支 |
|
||||
| 种子 | `internal/store/seed.go` | 可选:预置默认岗位 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型设计
|
||||
|
||||
### 4.1 新增表:`position`(岗位主表)
|
||||
|
||||
```go
|
||||
// internal/model/position.go
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Position 岗位表
|
||||
type Position struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Code string `gorm:"size:32;uniqueIndex;not null" json:"code"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Position) TableName() string { return "position" }
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS position (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_position_status ON position(status);
|
||||
```
|
||||
|
||||
### 4.2 新增表:`position_knowledge`(岗位知识要求,核心映射)
|
||||
|
||||
一条记录 = 「某岗位在某个知识域(可细化到课程/产品)上要求达到某级别」。
|
||||
|
||||
```go
|
||||
// internal/model/position_knowledge.go
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// PositionKnowledge 岗位知识要求(岗位 ↔ 知识域/课程/产品 映射)
|
||||
// domain 必填;course_id / product_id 可选(三者共同圈定岗位应学范围)。
|
||||
type PositionKnowledge struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PositionID uint `gorm:"not null;index" json:"position_id"`
|
||||
Domain string `gorm:"size:16;not null;index" json:"domain"` // company / product / sales
|
||||
CourseID *uint `gorm:"index" json:"course_id"` // 绑定具体课程,可空
|
||||
ProductID *uint `gorm:"index" json:"product_id"` // 绑定具体产品,可空
|
||||
RequiredLevel string `gorm:"size:16;not null;default:L1" json:"required_level"` // L1/L2/L3/L4
|
||||
Weight float64 `gorm:"not null;default:1" json:"weight"`
|
||||
IsMandatory bool `gorm:"not null;default:true" json:"is_mandatory"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (PositionKnowledge) TableName() string { return "position_knowledge" }
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS position_knowledge (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
position_id INTEGER NOT NULL,
|
||||
domain TEXT NOT NULL, -- company / product / sales
|
||||
course_id INTEGER, -- 可空
|
||||
product_id INTEGER, -- 可空
|
||||
required_level TEXT NOT NULL DEFAULT 'L1', -- L1/L2/L3/L4
|
||||
weight REAL NOT NULL DEFAULT 1,
|
||||
is_mandatory INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pk_position ON position_knowledge(position_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pk_domain ON position_knowledge(domain);
|
||||
```
|
||||
|
||||
> 说明:不做 `UNIQUE(position_id, domain, course_id, product_id)` 的唯一约束,允许同一岗位同一域多条记录(如按产品细分)。如后续需要防重复,可在 service 层做去重校验。
|
||||
|
||||
### 4.3 改动:`user` 加岗位列
|
||||
|
||||
在 [user.go](eaisalestrain_app/backend-go/internal/model/user.go) 的 `User` 中追加:
|
||||
|
||||
```go
|
||||
PositionID *uint `gorm:"index" json:"position_id"` // 所属岗位,可空(历史用户/管理员可为空)
|
||||
```
|
||||
|
||||
### 4.4 改动:`exam_paper` 加岗位列
|
||||
|
||||
在 [exam_paper.go](eaisalestrain_app/backend-go/internal/model/exam_paper.go) 的 `ExamPaper` 中追加:
|
||||
|
||||
```go
|
||||
PositionID *uint `gorm:"index" json:"position_id"` // 关联岗位考试,可空;设置后按岗位知识映射抽题
|
||||
```
|
||||
|
||||
### 4.5 级别常量(Go)
|
||||
|
||||
```go
|
||||
// internal/model/position.go 或独立 constants 处
|
||||
const (
|
||||
LevelL1 = "L1" // 入门级:概念/定义/标准流程
|
||||
LevelL2 = "L2" // 稳定执行级:标准应用/独立执行
|
||||
LevelL3 = "L3" // 复杂处理级:跨场景迁移/带教
|
||||
LevelL4 = "L4" // 体系策略级:方案设计/组织约束
|
||||
)
|
||||
|
||||
var validLevels = map[string]bool{LevelL1: true, LevelL2: true, LevelL3: true, LevelL4: true}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 设计
|
||||
|
||||
### 5.1 端点总表
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/positions` | admin | 岗位列表 |
|
||||
| POST | `/api/positions` | admin | 新建岗位 |
|
||||
| PUT | `/api/positions/:id` | admin | 编辑岗位 |
|
||||
| DELETE | `/api/positions/:id` | admin | 停用岗位(软删除 status=inactive) |
|
||||
| GET | `/api/positions/:id/knowledge` | admin | 某岗位知识映射列表 |
|
||||
| PUT | `/api/positions/:id/knowledge` | admin | 保存映射(整表覆盖) |
|
||||
| PUT | `/api/users/:id/position` | admin | 设置/清除用户岗位 |
|
||||
| GET | `/api/my/position` | 员工 | 我的岗位 + 应学范围(P1) |
|
||||
|
||||
路由注册(`router.go` admin 组内追加):
|
||||
|
||||
```go
|
||||
admin.GET("/positions", ListPositions)
|
||||
admin.POST("/positions", CreatePosition)
|
||||
admin.PUT("/positions/:id", UpdatePosition)
|
||||
admin.DELETE("/positions/:id", DeletePosition)
|
||||
admin.GET("/positions/:id/knowledge", ListPositionKnowledge)
|
||||
admin.PUT("/positions/:id/knowledge", SavePositionKnowledge)
|
||||
admin.PUT("/users/:id/position", SetUserPosition)
|
||||
```
|
||||
|
||||
### 5.2 关键请求/响应
|
||||
|
||||
**PUT `/api/positions/:id/knowledge`(整表覆盖保存映射)**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "domain": "product", "course_id": null, "product_id": 3, "required_level": "L2", "weight": 1, "is_mandatory": true },
|
||||
{ "domain": "sales", "course_id": 2, "product_id": null, "required_level": "L3", "weight": 1.5, "is_mandatory": true },
|
||||
{ "domain": "company", "course_id": null, "product_id": null, "required_level": "L1", "weight": 1, "is_mandatory": false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
响应:保存后的完整映射列表(含 `id`)。
|
||||
|
||||
**PUT `/api/users/:id/position`**
|
||||
|
||||
```json
|
||||
{ "position_id": 5 } // 设岗位
|
||||
{ "position_id": null } // 清除岗位
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 岗位驱动考试组卷(核心算法改动)
|
||||
|
||||
修改 [exam.go](eaisalestrain_app/backend-go/internal/api/exam.go) 的 `pickQuestions`,在原有 `domain` 抽题之前,先判断 `p.PositionID` 是否为空:
|
||||
|
||||
```go
|
||||
// pickQuestions 按 paper 抽题:优先按岗位知识映射圈定题池,否则回退 domain 抽题
|
||||
func pickQuestions(p model.ExamPaper) ([]model.Question, error) {
|
||||
pool := store.DB.Where("status = ?", "active")
|
||||
|
||||
if p.PositionID != nil {
|
||||
// 岗位考试:圈定岗位应学范围
|
||||
var pks []model.PositionKnowledge
|
||||
if err := store.DB.Where("position_id = ?", *p.PositionID).Find(&pks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domains := make([]string, 0, len(pks))
|
||||
courseIDs := make([]uint, 0, len(pks))
|
||||
for _, pk := range pks {
|
||||
domains = append(domains, pk.Domain)
|
||||
if pk.CourseID != nil {
|
||||
courseIDs = append(courseIDs, *pk.CourseID)
|
||||
}
|
||||
}
|
||||
domains = dedupe(domains)
|
||||
if len(domains) > 0 {
|
||||
pool = pool.Where("domain IN ?", domains)
|
||||
}
|
||||
if len(courseIDs) > 0 {
|
||||
// 岗位明确绑定了课程:题目要么属于这些课程,要么未绑定课程但域匹配
|
||||
pool = pool.Where("course_id IN ? OR course_id IS NULL", courseIDs)
|
||||
}
|
||||
} else {
|
||||
// 原有逻辑:按 exam_paper.domain 抽题
|
||||
domains := splitDomains(p.Domain)
|
||||
if len(domains) > 0 {
|
||||
pool = pool.Where("domain IN ?", domains)
|
||||
}
|
||||
}
|
||||
|
||||
var qs []model.Question
|
||||
if err := pool.Order("id ASC").Find(&qs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(qs) < p.QuestionCount {
|
||||
return nil, fmt.Errorf("题库题目不足:需要 %d,可用 %d", p.QuestionCount, len(qs))
|
||||
}
|
||||
if p.Randomize {
|
||||
rand.Shuffle(len(qs), func(i, j int) { qs[i], qs[j] = qs[j], qs[i] })
|
||||
}
|
||||
return qs[:p.QuestionCount], nil
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:
|
||||
> - `p.PositionID == nil` 时走原逻辑,**对现有考试零影响**;
|
||||
> - 岗位映射为空(`len(pks)==0`)时 `domains` 为空,`pool` 不过滤域,会抽到全部题——需要在 `CreatePaper/UpdatePaper` 校验:**岗位考试必须存在至少一条知识映射**,否则拒绝创建/更新。
|
||||
|
||||
### 6.1 `CreatePaper` / `UpdatePaper` 追加校验
|
||||
|
||||
```go
|
||||
if p.PositionID != nil {
|
||||
var n int64
|
||||
store.DB.Model(&model.PositionKnowledge{}).Where("position_id = ?", *p.PositionID).Count(&n)
|
||||
if n == 0 {
|
||||
web.Fail(c, web.NewBadRequest("岗位考试必须先配置岗位知识映射"))
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端设计
|
||||
|
||||
### 7.1 导航位置
|
||||
|
||||
管理员「系统管理」下拉新增 **「岗位管理」**,位于「用户账号管理」之前:
|
||||
|
||||
```
|
||||
系统管理【管理员】─┬─ 岗位管理 ← 新增
|
||||
├─ 用户账号管理
|
||||
├─ 全部考试成绩
|
||||
└─ 系统参数配置
|
||||
```
|
||||
|
||||
### 7.2 页面清单
|
||||
|
||||
| 页面 | 路由 | 说明 |
|
||||
|---|---|---|
|
||||
| 岗位列表 | `/admin/positions` | 表格:编码/名称/描述/状态;新建/编辑/停用 |
|
||||
| 岗位知识映射 | 岗位列表行内抽屉或独立页 | 勾选知识域 + 绑定课程/产品下拉 + 选 L1-L4 + 权重 + 必学开关 |
|
||||
| 用户管理改造 | 现有 `UserManage.vue` | 用户行加「岗位」下拉(可选岗位/清空) |
|
||||
| 考试配置改造 | 现有题库管理/考试配置 | 考试表单加「关联岗位」下拉(可选,空=普通考试) |
|
||||
|
||||
### 7.3 岗位知识映射编辑面板交互(要点)
|
||||
|
||||
- 上方:岗位基本信息(只读展示)。
|
||||
- 主体:映射条目列表,每行 = `domain 下拉` + `课程下拉(可空)` + `产品下拉(可空)` + `L1-L4 下拉` + `权重` + `必学开关` + 删除。
|
||||
- 底部:「新增一行」+「保存」(整体 PUT 覆盖)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 分阶段实施
|
||||
|
||||
### P0(本次落地,核心闭环)
|
||||
|
||||
1. 新增 `Position` / `PositionKnowledge` model + 追加 `User.PositionID` / `ExamPaper.PositionID`;
|
||||
2. `db.go` AutoMigrate 追加 2 张表;
|
||||
3. 岗位 CRUD + 映射保存 API(admin);
|
||||
4. 用户设岗位 API + 用户管理页岗位下拉;
|
||||
5. `pickQuestions` 岗位过滤分支 + `CreatePaper/UpdatePaper` 校验;
|
||||
6. 前端「岗位管理」页 + 映射编辑面板 + 考试配置「关联岗位」下拉。
|
||||
|
||||
### P1(随后)
|
||||
|
||||
1. 学员端「我的岗位 + 应学清单」`GET /api/my/position`(按岗位映射返回应学课程/产品/知识域);
|
||||
2. 考试成绩按岗位聚合(`ExamRecord` 冗余 `position_id` 快照,或 join `user.position_id`);
|
||||
3. 岗位考试蓝图 `position_exam_blueprint`(维度/题型/题量/权重,对齐 pj006),驱动更精细组卷。
|
||||
|
||||
### P2(视需要,另行立项)
|
||||
|
||||
1. 错题本 + 学习档案(依赖学习进度表);
|
||||
2. 简答题题型 + LLM 评分(复用现有 LLM 底座);
|
||||
3. L1-L4 能力洋葱模型(若确需能力档案再上);
|
||||
4. 后端 pytest 测试补齐。
|
||||
|
||||
---
|
||||
|
||||
## 9. 明确不做(边界)
|
||||
|
||||
- ❌ 不建 `competency_layers / competency_levels / competency_details` 三层洋葱模型;
|
||||
- ❌ 不引入多租户 `orgs` / per-org 分库;
|
||||
- ❌ 不做 LLM 自动生成岗位能力模型初稿;
|
||||
- ❌ 不做多岗位绑定(单岗位优先,`user.position_id` 单列);
|
||||
- ❌ 不做学习进度 / 学情分析 / 能力雷达图。
|
||||
|
||||
---
|
||||
|
||||
## 10. 迁移与回滚
|
||||
|
||||
- **迁移**:GORM `AutoMigrate` 自动建新表 + 给既有表加可空列,幂等、对现有数据零破坏。历史用户 `position_id` 为空,不影响登录与现有考试。
|
||||
- **回滚**:新表删除即可(`DROP TABLE position; DROP TABLE position_knowledge;`);加列不影响回滚(可留空)。
|
||||
- **部署**:单二进制,无额外依赖;`clonezilla` 交付前清数据即可。
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险与注意事项
|
||||
|
||||
1. **岗位映射为空导致抽全题**:已在 `CreatePaper/UpdatePaper` 加校验拦截(§6.1)。
|
||||
2. **course_id 与 domain 双重过滤语义**:岗位绑定了具体课程时,`course_id IN ? OR course_id IS NULL` 会把「未绑定课程但域匹配」的题也纳入,需与产品确认是否符合预期(倾向:岗位考试以课程为强约束时,改为 `course_id IN ?` 严格匹配)。
|
||||
3. **级别字段暂不参与抽题**:P0 中 `required_level` 只做展示与后续 P1 蓝图使用,不参与 P0 组卷过滤(避免一开始就复杂化)。
|
||||
4. **多岗位需求**:若未来一人多岗,`user.position_id` 单列不够,届时升级为 `user_position` 映射表(参考 pj006 `user_positions`)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 与 pj006 的差异对照(本稿的取舍)
|
||||
|
||||
| 项 | pj006 做法 | 本稿(pj0231)取舍 |
|
||||
|---|---|---|
|
||||
| 岗位能力模型 | 三层洋葱 + L1-L4 独立表 | 仅 `required_level` 字符串 + 常量,不建级别表 |
|
||||
| 岗位↔知识映射 | `position_knowledge_units`(挂知识单元) | `position_knowledge`(挂 domain/course/product,因 pj0231 无知识单元) |
|
||||
| 考试蓝图 | `position_exam_blueprints` | P0 用 `exam_paper.position_id` 直接驱动,蓝图列 P1 |
|
||||
| 用户绑定 | `user_positions` 独立表 | `user.position_id` 单列(极简) |
|
||||
| 多租户 | `orgs` + per-org 分库 | 不做(单企业) |
|
||||
|
||||
---
|
||||
|
||||
> 本稿为草案,落地前需与产品确认 §11.2 的课程/域双重过滤语义,以及 §4.2 是否需要对映射去重约束。
|
||||
Reference in New Issue
Block a user