- 后端:新增 /api/audio/transcribe 接口,调用 Ollama whisper 进行语音识别 - 前端:新增 AudioTranscribePage.vue 页面,支持 MP3/WAV/M4A/OGG/FLAC 等格式 - 注册路由、工具卡片、智能助手欢迎语更新 Co-Authored-By: Claude Code <noreply@anthropic.com>
16 KiB
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.mdV1.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. 设计原则(强制)
- 保持极简:只加「岗位 + 映射」这一条能力链,不为对齐 pj006 而堆表。
- 复用现有机制:考试组卷复用
exam.go的pickQuestions;路由复用RequireAdmin;迁移复用 GORMAutoMigrate。 - 级别词汇对齐 pj006 的 L1-L4,但不建独立级别表——
required_level用普通字符串字段 + Go 常量,降低复杂度。 - 单岗位优先:一个用户一个岗位(
user.position_id可空),满足绝大多数内部员工场景;多岗位需求出现时再升级为映射表。 - 不破坏现有数据:所有新列可空、新表独立,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(岗位主表)
// 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" }
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(岗位知识要求,核心映射)
一条记录 = 「某岗位在某个知识域(可细化到课程/产品)上要求达到某级别」。
// 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" }
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 的 User 中追加:
PositionID *uint `gorm:"index" json:"position_id"` // 所属岗位,可空(历史用户/管理员可为空)
4.4 改动:exam_paper 加岗位列
在 exam_paper.go 的 ExamPaper 中追加:
PositionID *uint `gorm:"index" json:"position_id"` // 关联岗位考试,可空;设置后按岗位知识映射抽题
4.5 级别常量(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 组内追加):
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(整表覆盖保存映射)
{
"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
{ "position_id": 5 } // 设岗位
{ "position_id": null } // 清除岗位
6. 岗位驱动考试组卷(核心算法改动)
修改 exam.go 的 pickQuestions,在原有 domain 抽题之前,先判断 p.PositionID 是否为空:
// 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 追加校验
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(本次落地,核心闭环)
- 新增
Position/PositionKnowledgemodel + 追加User.PositionID/ExamPaper.PositionID; db.goAutoMigrate 追加 2 张表;- 岗位 CRUD + 映射保存 API(admin);
- 用户设岗位 API + 用户管理页岗位下拉;
pickQuestions岗位过滤分支 +CreatePaper/UpdatePaper校验;- 前端「岗位管理」页 + 映射编辑面板 + 考试配置「关联岗位」下拉。
P1(随后)
- 学员端「我的岗位 + 应学清单」
GET /api/my/position(按岗位映射返回应学课程/产品/知识域); - 考试成绩按岗位聚合(
ExamRecord冗余position_id快照,或 joinuser.position_id); - 岗位考试蓝图
position_exam_blueprint(维度/题型/题量/权重,对齐 pj006),驱动更精细组卷。
P2(视需要,另行立项)
- 错题本 + 学习档案(依赖学习进度表);
- 简答题题型 + LLM 评分(复用现有 LLM 底座);
- L1-L4 能力洋葱模型(若确需能力档案再上);
- 后端 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. 风险与注意事项
- 岗位映射为空导致抽全题:已在
CreatePaper/UpdatePaper加校验拦截(§6.1)。 - course_id 与 domain 双重过滤语义:岗位绑定了具体课程时,
course_id IN ? OR course_id IS NULL会把「未绑定课程但域匹配」的题也纳入,需与产品确认是否符合预期(倾向:岗位考试以课程为强约束时,改为course_id IN ?严格匹配)。 - 级别字段暂不参与抽题:P0 中
required_level只做展示与后续 P1 蓝图使用,不参与 P0 组卷过滤(避免一开始就复杂化)。 - 多岗位需求:若未来一人多岗,
user.position_id单列不够,届时升级为user_position映射表(参考 pj006user_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 是否需要对映射去重约束。