# 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](eai_agentplatform_app/backend-go/internal/model/user.go) 的 `User` 中追加: ```go PositionID *uint `gorm:"index" json:"position_id"` // 所属岗位,可空(历史用户/管理员可为空) ``` ### 4.4 改动:`exam_paper` 加岗位列 在 [exam_paper.go](eai_agentplatform_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](eai_agentplatform_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 是否需要对映射去重约束。