diff --git a/eai_agentplatform/backend-go/internal/api/position.go b/eai_agentplatform/backend-go/internal/api/position.go index e06d1e7..cd809e5 100644 --- a/eai_agentplatform/backend-go/internal/api/position.go +++ b/eai_agentplatform/backend-go/internal/api/position.go @@ -114,7 +114,22 @@ type positionKnowledgeReq struct { ProductID *uint `json:"product_id"` RequiredLevel string `json:"required_level"` Weight float64 `json:"weight"` - IsMandatory bool `json:"is_mandatory"` + // 三态: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 —— 某岗位知识映射列表 @@ -172,7 +187,7 @@ func SavePositionKnowledge(c *gin.Context) { ProductID: it.ProductID, RequiredLevel: it.RequiredLevel, Weight: it.Weight, - IsMandatory: it.IsMandatory, + IsMandatory: mandatoryOrTrue(it.IsMandatory), }) } // 整表覆盖:先删旧,再批量插入 diff --git a/eai_agentplatform/backend-go/internal/model/position_knowledge.go b/eai_agentplatform/backend-go/internal/model/position_knowledge.go index b78d7b7..efea7b0 100644 --- a/eai_agentplatform/backend-go/internal/model/position_knowledge.go +++ b/eai_agentplatform/backend-go/internal/model/position_knowledge.go @@ -5,16 +5,19 @@ 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"` + 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"` + // 有意不写 `default:true`。带上它,GORM 在 Create 时会跳过布尔零值 false, + // 改由库默认值 true 生效 —— 显式传 false(选学)会被静默改写回 true。 + // 「没传按必学」是接口契约,归 handler 兜(见 api/position.go 的 mandatoryOrTrue)。 + IsMandatory bool `gorm:"not null" json:"is_mandatory"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (PositionKnowledge) TableName() string { return "position_knowledge" } diff --git a/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go b/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go index 9b77681..3e92fed 100644 --- a/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go +++ b/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go @@ -36,7 +36,7 @@ func CreateSkillDefinition(c *gin.Context) { ObjectKind: req.ObjectKind, Source: req.Source, ObjectEntryRoute: req.ObjectEntryRoute, - ExposedToUser: req.ExposedToUser, + ExposedToUser: exposedOrTrue(req.ExposedToUser), StarterPromptsJSON: req.StarterPromptsJSON, PromptTemplate: req.PromptTemplate, InputSchemaJSON: req.InputSchemaJSON, @@ -83,7 +83,11 @@ func UpdateSkillDefinition(c *gin.Context) { item.ObjectKind = req.ObjectKind item.Source = req.Source item.ObjectEntryRoute = req.ObjectEntryRoute - item.ExposedToUser = req.ExposedToUser + // 没传就保持原值 —— 全量覆盖对其余字段成立,唯独这个不行:前端表单根本不发 + // exposed_to_user,若无脑覆盖成零值 false,管理员改个标题就会把技能从用户目录里撤下来。 + if req.ExposedToUser != nil { + item.ExposedToUser = *req.ExposedToUser + } item.StarterPromptsJSON = req.StarterPromptsJSON item.PromptTemplate = req.PromptTemplate item.InputSchemaJSON = req.InputSchemaJSON diff --git a/eai_agentplatform/backend-go/internal/skills/api/request.go b/eai_agentplatform/backend-go/internal/skills/api/request.go index bcf9e28..7c2dfe5 100644 --- a/eai_agentplatform/backend-go/internal/skills/api/request.go +++ b/eai_agentplatform/backend-go/internal/skills/api/request.go @@ -1,13 +1,17 @@ package skillapi type definitionReq struct { - Key string `json:"key"` - Label string `json:"label"` - Description string `json:"description"` - ObjectKind string `json:"object_kind"` - Source string `json:"source"` - ObjectEntryRoute string `json:"object_entry_route"` - ExposedToUser bool `json:"exposed_to_user"` + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description"` + ObjectKind string `json:"object_kind"` + Source string `json:"source"` + ObjectEntryRoute string `json:"object_entry_route"` + // 三态:nil = 客户端没传这个字段,非 nil = 客户端明确说了 true/false。 + // 用 *bool 而不是 bool,是因为「没传」和「传 false」是两件事: + // 新建时没传按默认 true,更新时没传保持原值(前端表单不发这个字段)。 + // 压成 bool 的话,false 身兼二职,二者都会被当成「显式要求隐藏」。 + ExposedToUser *bool `json:"exposed_to_user"` StarterPromptsJSON string `json:"starter_prompts_json"` PromptTemplate string `json:"prompt_template"` InputSchemaJSON string `json:"input_schema_json"` diff --git a/eai_agentplatform/backend-go/internal/skills/api/validation.go b/eai_agentplatform/backend-go/internal/skills/api/validation.go index 86a5867..3060b29 100644 --- a/eai_agentplatform/backend-go/internal/skills/api/validation.go +++ b/eai_agentplatform/backend-go/internal/skills/api/validation.go @@ -70,3 +70,16 @@ func validateDefinitionReq(req *definitionReq) *web.AppError { } return nil } + +// exposedOrTrue 解析 exposed_to_user 的三态,供**新建**用:没传按 true。 +// +// 这个默认值原先由库列默认值(default:true)兜着,但那条路会把显式传的 false +// 也一并吃掉,所以挪到这里 —— 默认值是接口契约,归这一层。 +// 口径与种子数据、前端 `item.exposed_to_user !== false` 三处一致:不表态就是可见, +// 只有明确传 false 才隐藏。 +func exposedOrTrue(v *bool) bool { + if v == nil { + return true + } + return *v +} diff --git a/eai_agentplatform/backend-go/internal/skills/model/skill_definition.go b/eai_agentplatform/backend-go/internal/skills/model/skill_definition.go index ebdd54c..567508f 100644 --- a/eai_agentplatform/backend-go/internal/skills/model/skill_definition.go +++ b/eai_agentplatform/backend-go/internal/skills/model/skill_definition.go @@ -4,16 +4,20 @@ import "time" // SkillDefinition 对外暴露的任务级能力定义。 type SkillDefinition struct { - ID uint `gorm:"primaryKey" json:"id"` - Key string `gorm:"size:64;uniqueIndex;not null" json:"key"` - Label string `gorm:"size:128;not null" json:"label"` - DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"` - EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"` - Description string `gorm:"type:text" json:"description"` - ObjectKind string `gorm:"column:object_kind;size:32;not null;default:skill;index" json:"object_kind"` - Source string `gorm:"size:32;not null;default:eai;index" json:"source"` - ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;default:''" json:"object_entry_route"` - ExposedToUser bool `gorm:"not null;default:true;index" json:"exposed_to_user"` + ID uint `gorm:"primaryKey" json:"id"` + Key string `gorm:"size:64;uniqueIndex;not null" json:"key"` + Label string `gorm:"size:128;not null" json:"label"` + DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"` + EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"` + Description string `gorm:"type:text" json:"description"` + ObjectKind string `gorm:"column:object_kind;size:32;not null;default:skill;index" json:"object_kind"` + Source string `gorm:"size:32;not null;default:eai;index" json:"source"` + ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;default:''" json:"object_entry_route"` + // 这里**有意不写** `default:true`。带上它,GORM 在 Create 时会跳过布尔零值字段 + // (false 被当成「没填」),改由库默认值 true 生效 —— 于是对象显式设的 false + // 被持久层改写成 true,创建接口永远设不出「不暴露给用户」的技能。 + // 「没传默认 true」是接口契约,归 handler 管(见 admin_handlers.go),不归库管。 + ExposedToUser bool `gorm:"not null;index" json:"exposed_to_user"` StarterPromptsJSON string `gorm:"type:text" json:"starter_prompts_json"` PromptTemplate string `gorm:"type:text" json:"prompt_template"` InputSchemaJSON string `gorm:"type:text" json:"input_schema_json"` diff --git a/eai_agentplatform/backend-go/internal/xapps/api/handlers.go b/eai_agentplatform/backend-go/internal/xapps/api/handlers.go index 2dddb04..09c4f7c 100644 --- a/eai_agentplatform/backend-go/internal/xapps/api/handlers.go +++ b/eai_agentplatform/backend-go/internal/xapps/api/handlers.go @@ -48,7 +48,9 @@ type definitionReq struct { PromptsJSON string `json:"prompts_json"` TagsJSON string `json:"tags_json"` InstallState string `json:"install_state"` - ExposedToUser bool `json:"exposed_to_user"` + // 三态:nil = 客户端没传,非 nil = 明确说了 true/false。理由同技能侧 + // skillapi.definitionReq.ExposedToUser:没传与传 false 是两件事。 + ExposedToUser *bool `json:"exposed_to_user"` State string `json:"state"` SortOrder int `json:"sort_order"` } @@ -89,6 +91,17 @@ func normalizeXAppMode(req *definitionReq) { } } +// exposedOrTrue 解析 exposed_to_user 的三态,供**新建**用:没传按 true。 +// 这个默认值原先由库列默认值兜着,但那条路会把显式传的 false 一并吃掉(GORM 的 +// default 标签会跳过布尔零值字段),所以挪到这一层 —— 默认值是接口契约。 +// 口径与种子数据、前端 `item.exposed_to_user !== false` 一致。 +func exposedOrTrue(v *bool) bool { + if v == nil { + return true + } + return *v +} + func validateDefinitionReq(req *definitionReq) *web.AppError { normalizeDefinitionReq(req) normalizeXAppMode(req) @@ -183,7 +196,7 @@ func CreateXAppDefinition(c *gin.Context) { PromptsJSON: req.PromptsJSON, TagsJSON: req.TagsJSON, InstallState: req.InstallState, - ExposedToUser: req.ExposedToUser, + ExposedToUser: exposedOrTrue(req.ExposedToUser), State: req.State, SortOrder: req.SortOrder, } @@ -233,7 +246,11 @@ func UpdateXAppDefinition(c *gin.Context) { item.PromptsJSON = req.PromptsJSON item.TagsJSON = req.TagsJSON item.InstallState = req.InstallState - item.ExposedToUser = req.ExposedToUser + // 没传就保持原值,理由同技能侧 admin_handlers.go:前端不发这个字段, + // 全覆盖会把管理员的一次普通编辑变成「从用户目录撤下」。 + if req.ExposedToUser != nil { + item.ExposedToUser = *req.ExposedToUser + } item.State = req.State item.SortOrder = req.SortOrder if !xAppDefinitionRepo.Update(&item) { diff --git a/eai_agentplatform/backend-go/internal/xapps/model/model.go b/eai_agentplatform/backend-go/internal/xapps/model/model.go index 91c63ee..e7dee04 100644 --- a/eai_agentplatform/backend-go/internal/xapps/model/model.go +++ b/eai_agentplatform/backend-go/internal/xapps/model/model.go @@ -5,30 +5,33 @@ import "time" // XAppDefinition 面向用户的一键应用目录定义。 // 它是“成品入口”这一层:决定应用怎么展示、默认挂哪个专员/技能、打开后走哪条路。 type XAppDefinition struct { - ID uint `gorm:"primaryKey" json:"id"` - Key string `gorm:"size:64;uniqueIndex;not null" json:"key"` - Label string `gorm:"size:128;not null" json:"label"` - DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"` - EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"` - Badge string `gorm:"size:32;default:''" json:"badge"` - Kind string `gorm:"size:64;default:''" json:"kind"` - MarketTag string `gorm:"column:market_tag;size:32;default:'';index" json:"market_tag"` - XAppMode string `gorm:"size:16;default:'';index" json:"xapp_mode"` - Tier string `gorm:"size:16;default:'';index" json:"tier"` - Source string `gorm:"size:32;not null;default:eai;index" json:"source"` // eai / custom / imported - Color string `gorm:"size:32;default:''" json:"color"` - IconText string `gorm:"column:icon_text;size:16;default:''" json:"icon_text"` - CoverTone string `gorm:"column:cover_tone;type:text" json:"cover_tone"` - Summary string `gorm:"type:text" json:"summary"` - Description string `gorm:"type:text" json:"description"` - OpenRoute string `gorm:"column:open_route;size:256;default:''" json:"open_route"` - SpecialistKey string `gorm:"column:specialist_key;size:64;default:'';index" json:"specialist_key"` - SkillKey string `gorm:"column:skill_key;size:64;default:'';index" json:"skill_key"` - DefaultPrompt string `gorm:"column:default_prompt;type:text" json:"default_prompt"` - PromptsJSON string `gorm:"column:prompts_json;type:text" json:"prompts_json"` - TagsJSON string `gorm:"column:tags_json;type:text" json:"tags_json"` - InstallState string `gorm:"column:install_state;size:16;not null;default:installed;index" json:"install_state"` - ExposedToUser bool `gorm:"column:exposed_to_user;not null;default:true;index" json:"exposed_to_user"` + ID uint `gorm:"primaryKey" json:"id"` + Key string `gorm:"size:64;uniqueIndex;not null" json:"key"` + Label string `gorm:"size:128;not null" json:"label"` + DisplayCode string `gorm:"column:display_code;size:16;default:'';index" json:"display_code"` + EAILogicCode string `gorm:"column:eailogic_code;size:32;default:'';index" json:"eailogic_code"` + Badge string `gorm:"size:32;default:''" json:"badge"` + Kind string `gorm:"size:64;default:''" json:"kind"` + MarketTag string `gorm:"column:market_tag;size:32;default:'';index" json:"market_tag"` + XAppMode string `gorm:"size:16;default:'';index" json:"xapp_mode"` + Tier string `gorm:"size:16;default:'';index" json:"tier"` + Source string `gorm:"size:32;not null;default:eai;index" json:"source"` // eai / custom / imported + Color string `gorm:"size:32;default:''" json:"color"` + IconText string `gorm:"column:icon_text;size:16;default:''" json:"icon_text"` + CoverTone string `gorm:"column:cover_tone;type:text" json:"cover_tone"` + Summary string `gorm:"type:text" json:"summary"` + Description string `gorm:"type:text" json:"description"` + OpenRoute string `gorm:"column:open_route;size:256;default:''" json:"open_route"` + SpecialistKey string `gorm:"column:specialist_key;size:64;default:'';index" json:"specialist_key"` + SkillKey string `gorm:"column:skill_key;size:64;default:'';index" json:"skill_key"` + DefaultPrompt string `gorm:"column:default_prompt;type:text" json:"default_prompt"` + PromptsJSON string `gorm:"column:prompts_json;type:text" json:"prompts_json"` + TagsJSON string `gorm:"column:tags_json;type:text" json:"tags_json"` + InstallState string `gorm:"column:install_state;size:16;not null;default:installed;index" json:"install_state"` + // 有意不写 `default:true`,理由同 skill_definition.ExposedToUser: + // 带 default 标签会让 GORM 在 Create 时跳过零值 false,由库默认值接管, + // 对象说了不算。「没传默认 true」由 handler 兜(见 xapps/api/handlers.go)。 + ExposedToUser bool `gorm:"column:exposed_to_user;not null;index" json:"exposed_to_user"` State string `gorm:"size:16;not null;default:active;index" json:"state"` // active / inactive SortOrder int `gorm:"not null;default:0;index" json:"sort_order"` CreatedAt time.Time `json:"created_at"`