Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/internal/api/position.go
T
eaiadminandClaude Code 9157df40f3 fix: 布尔字段的「没传」与「传 false」被库默认值合并
三处同一形状的缺陷:模型的 bool 字段带 `gorm:"default:true"`,而 GORM 在
Create 时会跳过「带 default 标签的零值字段」,于是 Go 里的 false 被当成「没填」,
改由库默认值 true 生效。对象显式设的 false 被持久层改写成 true。

- skill_definition.exposed_to_user
- xapp_definition.exposed_to_user
- position_knowledge.is_mandatory

前两处有两个方向的病症:

1. 创建接口设不出 false —— 管理员建一个「不暴露给用户」的技能/应用,
   接口返回 200、看着成功,库里存的是 true。
2. **更新接口会静默撤下** —— 前端 admin 表单根本不发 exposed_to_user
   (只读不写,见 skillCatalog.js / xappCatalog.js),而 PUT 对每个字段
   无条件覆盖,于是管理员改个标题就把技能/应用从用户目录里摘了。
   这条是线上正在发生的,比第一条更狠。

position_knowledge 同样:前端的必学/选学 el-switch 怎么拨都存成必学。

修法是拿掉那层「持久层替对象拿主意」:

- 模型去掉 `default:true`,对象说了算,仓库原样落库;
- 请求 DTO 改 *bool,把「没传」和「传 false」分开 —— 三态只存在于线上,
  `not null` 的列没有「未设置」态,所以不进模型;
- 默认值归接口契约,放 handler:新建时没传按 true(与种子数据、前端
  `exposed_to_user !== false` 口径一致),更新时没传保持原值
  (前端不发这个字段,不能因为一次无关编辑就改变可见性)。

没选的两个方案:给 Create 加 Select("*") 是把 GORM 的零值规则泄漏进 HTTP
适配层,只盖住症状还得每处创建路径都记得;模型改 *bool 则是给一个
不存在的领域状态造了个位置,还让 JSON 多出 null。

验证(tmp_vfy_fix,临时程序,验完已删):真实路由 + 真实 HTTP,跑在数据库
副本上,39 条断言全绿。同一份断言在修复前跑出 6 条红(S1/X1/P2:显式 false
存成 true;S9/X9:更新不带该字段把 true 静默改成 false),确认断言确实在测
这个修复而不是装饰。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 02:17:19 +08:00

369 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"github.com/gin-gonic/gin"
"eai_agentplatform/backend/internal/middleware"
"eai_agentplatform/backend/internal/model"
"eai_agentplatform/backend/internal/repository"
"eai_agentplatform/backend/internal/web"
)
var (
positionRepo repository.PositionRepo
courseRepo repository.CourseRepo // productRepo 定义在 products.go
)
func init() {
positionRepo = repository.PositionRepo{}
courseRepo = repository.CourseRepo{}
}
// ============ 岗位 CRUD(管理员) ============
// ListPositions GET /api/positions?status= —— 岗位列表
func ListPositions(c *gin.Context) {
items := positionRepo.List(c.Query("status"))
web.OK(c, items)
}
// CreatePosition POST /api/positions (admin)
func CreatePosition(c *gin.Context) {
var p model.Position
if err := c.ShouldBindJSON(&p); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if p.Code == "" || p.Name == "" {
web.Fail(c, web.NewBadRequest("编号、名称为必填"))
return
}
if p.Status == "" {
p.Status = "active"
}
if positionRepo.CountByName(p.Code, nil) > 0 {
web.Fail(c, web.NewConflictError("岗位编号已存在"))
return
}
if !positionRepo.Insert(&p) {
web.Fail(c, web.NewBadRequest("创建岗位失败"))
return
}
web.OK(c, p)
}
// UpdatePosition PUT /api/positions/{id} (admin)
func UpdatePosition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
p, found := positionRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
var req model.Position
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.Name == "" {
web.Fail(c, web.NewBadRequest("名称为必填"))
return
}
if req.Status == "" {
req.Status = "active"
}
if req.Code != "" && req.Code != p.Code {
if positionRepo.CountByName(req.Code, &id) > 0 {
web.Fail(c, web.NewConflictError("岗位编号已存在"))
return
}
p.Code = req.Code
}
p.Name = req.Name
p.Description = req.Description
p.Status = req.Status
if !positionRepo.Update(&p) {
web.Fail(c, web.NewBadRequest("更新岗位失败"))
return
}
web.OK(c, p)
}
// DeletePosition DELETE /api/positions/{id} (admin) —— 软删除(status=inactive)
func DeletePosition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
if !positionRepo.Delete(id) {
web.Fail(c, web.NewBadRequest("停用岗位失败"))
return
}
web.OK(c, gin.H{"id": id, "status": "inactive"})
}
// ============ 岗位知识映射(管理员) ============
// positionKnowledgeReq 映射条目入参(与 model.PositionKnowledge 同构)
type positionKnowledgeReq struct {
Domain string `json:"domain"`
CourseID *uint `json:"course_id"`
ProductID *uint `json:"product_id"`
RequiredLevel string `json:"required_level"`
Weight float64 `json:"weight"`
// 三态:nil = 客户端没传,非 nil = 明确说了 true/false。
// 与 skillapi.definitionReq.ExposedToUser 同一处理:压成 bool 的话,
// 「没传」和「传 false」都会被当成「显式要求选学」,而后者必须写得进去。
IsMandatory *bool `json:"is_mandatory"`
}
// mandatoryOrTrue 解析 is_mandatory 的三态:没传按 true(必学)。
//
// 这个默认值原先由库列默认值(default:true)兜着,但 GORM 在 Create 时会跳过
// 带 default 标签的布尔零值字段,于是「显式传 false(选学)」被库默认值改写回
// true —— 前端那个必学/选学开关怎么拨都存成必学。默认值挪到这一层,归接口契约管。
func mandatoryOrTrue(v *bool) bool {
if v == nil {
return true
}
return *v
}
// ListPositionKnowledge GET /api/positions/{id}/knowledge —— 某岗位知识映射列表
func ListPositionKnowledge(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
items := positionRepo.Knowledge(id)
if items == nil {
items = []model.PositionKnowledge{}
}
web.OK(c, items)
}
// SavePositionKnowledge PUT /api/positions/{id}/knowledge —— 保存映射(整表覆盖)
func SavePositionKnowledge(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
_, found := positionRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
var req struct {
Items []positionKnowledgeReq `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
// 校验并规整映射条目
rows := make([]model.PositionKnowledge, 0, len(req.Items))
for _, it := range req.Items {
if !validDomains[it.Domain] {
web.Fail(c, web.NewBadRequest("知识域 domain 非法(仅 company/product/sales)"))
return
}
if it.RequiredLevel == "" {
it.RequiredLevel = model.LevelL1
}
if !model.ValidLevels[it.RequiredLevel] {
web.Fail(c, web.NewBadRequest("级别 required_level 非法(仅 L1/L2/L3/L4)"))
return
}
if it.Weight <= 0 {
it.Weight = 1
}
rows = append(rows, model.PositionKnowledge{
PositionID: id,
Domain: it.Domain,
CourseID: it.CourseID,
ProductID: it.ProductID,
RequiredLevel: it.RequiredLevel,
Weight: it.Weight,
IsMandatory: mandatoryOrTrue(it.IsMandatory),
})
}
// 整表覆盖:先删旧,再批量插入
if !positionRepo.ReplaceKnowledge(id, rows) {
web.Fail(c, web.NewBadRequest("保存岗位知识映射失败"))
return
}
out := positionRepo.Knowledge(id)
if out == nil {
out = []model.PositionKnowledge{}
}
web.OK(c, out)
}
// ============ 用户设岗(管理员) ============
// SetUserPosition PUT /api/users/{id}/position —— {position_id: 5 | null}
func SetUserPosition(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
u, found := userRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("用户不存在"))
return
}
var req struct {
PositionID *uint `json:"position_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
if req.PositionID != nil {
_, found := positionRepo.GetByID(*req.PositionID)
if !found {
web.Fail(c, web.NewBadRequest("岗位不存在或已停用"))
return
}
}
u.PositionID = req.PositionID
if !userRepo.Update(&u) {
web.Fail(c, web.NewBadRequest("设置用户岗位失败"))
return
}
web.OK(c, gin.H{"id": u.ID, "position_id": u.PositionID})
}
// ============ 学员:我的岗位应学清单 ============
// MyPosition GET /api/my/position —— 我的岗位 + 应学范围(课程/产品/域)
func MyPosition(c *gin.Context) {
u := middleware.CurrentUser(c)
if u == nil || u.PositionID == nil {
web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0})
return
}
pos, found := positionRepo.GetByID(*u.PositionID)
if !found || pos.Status != "active" {
web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0})
return
}
pks := positionRepo.Knowledge(pos.ID)
// 批量解析课程/产品名称
courseIDs := make([]uint, 0, len(pks))
productIDs := make([]uint, 0, len(pks))
for _, pk := range pks {
if pk.CourseID != nil {
courseIDs = append(courseIDs, *pk.CourseID)
}
if pk.ProductID != nil {
productIDs = append(productIDs, *pk.ProductID)
}
}
courseName := courseRepo.NamesByIDs(courseIDs)
productName := productRepo.NamesByIDs(productIDs)
list := make([]gin.H, 0, len(pks))
for _, pk := range pks {
list = append(list, gin.H{
"id": pk.ID,
"domain": pk.Domain,
"course_id": pk.CourseID,
"product_id": pk.ProductID,
"course_name": nameOrDash(courseName, pk.CourseID),
"product_name": nameOrDash(productName, pk.ProductID),
"required_level": pk.RequiredLevel,
"weight": pk.Weight,
"is_mandatory": pk.IsMandatory,
})
}
web.OK(c, gin.H{
"position": pos,
"knowledge": list,
"count": len(list),
})
}
// ============ 岗位考试蓝图(管理员) ============
type blueprintReq struct {
Domain string `json:"domain"`
Type string `json:"type"`
Count int `json:"count"`
}
// ListPositionBlueprint GET /api/positions/{id}/blueprint —— 某岗位考试蓝图列表
func ListPositionBlueprint(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
items := positionRepo.Blueprints(id)
if items == nil {
items = []model.PositionExamBlueprint{}
}
web.OK(c, items)
}
// SavePositionBlueprint PUT /api/positions/{id}/blueprint —— 保存蓝图(整表覆盖)
func SavePositionBlueprint(c *gin.Context) {
id, ok := parseID(c, "id")
if !ok {
return
}
_, found := positionRepo.GetByID(id)
if !found {
web.Fail(c, web.NewNotFoundError("岗位不存在"))
return
}
var req struct {
Items []blueprintReq `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
rows := make([]model.PositionExamBlueprint, 0, len(req.Items))
for _, it := range req.Items {
if !validQTypes[it.Type] {
web.Fail(c, web.NewBadRequest("题型 type 非法(仅 single/multiple/judge/essay)"))
return
}
if it.Domain != "" && !validDomains[it.Domain] {
web.Fail(c, web.NewBadRequest("知识域 domain 非法(仅 company/product/sales,或留空不限)"))
return
}
if it.Count <= 0 {
web.Fail(c, web.NewBadRequest("蓝图各条 count 必须大于 0"))
return
}
rows = append(rows, model.PositionExamBlueprint{
PositionID: id, Domain: it.Domain, Type: it.Type, Count: it.Count,
})
}
if !positionRepo.ReplaceBlueprints(id, rows) {
web.Fail(c, web.NewBadRequest("保存岗位考试蓝图失败"))
return
}
out := positionRepo.Blueprints(id)
if out == nil {
out = []model.PositionExamBlueprint{}
}
web.OK(c, out)
}
func nameOrDash(m map[uint]string, id *uint) string {
if id == nil {
return ""
}
if n, ok := m[*id]; ok {
return n
}
return ""
}