feat: simplify SOP to immediate-effect configuration

This commit is contained in:
Eric 1549169735@qq.com
2026-08-18 16:27:02 +08:00
parent c5ab886b70
commit 8de48fb05e
150 changed files with 6764 additions and 1626 deletions

View File

@@ -0,0 +1,197 @@
package knowledge
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
)
var contentKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,63}$`)
var placeholderPattern = regexp.MustCompile(`\{\{\s*((?:input|card)\.[A-Za-z][A-Za-z0-9_]*)\s*\}\}`)
type CardField struct {
Key string `json:"key"`
Name string `json:"name"`
Value interface{} `json:"value"`
SourceField string `json:"source_field,omitempty"`
}
type CopyTemplate struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
type Content struct {
Fields []CardField `json:"fields,omitempty"`
CopyTemplates []CopyTemplate `json:"copy_templates,omitempty"`
StandardCopy string `json:"standard_copy,omitempty"`
ForbiddenCopy string `json:"forbidden_copy,omitempty"`
RiskNote string `json:"risk_note,omitempty"`
}
type RenderedCopy struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
func ParseContent(raw []byte) (Content, error) {
var content Content
if err := json.Unmarshal(raw, &content); err != nil {
return Content{}, errors.New("知识卡内容必须是 JSON 对象")
}
return normalizeAndValidate(content)
}
func ParseContentMap(value map[string]interface{}) (Content, error) {
raw, err := json.Marshal(value)
if err != nil {
return Content{}, err
}
return ParseContent(raw)
}
func ValidateReferences(content Content, scenarioFieldKeys map[string]bool) error {
cardFieldKeys := make(map[string]bool, len(content.Fields))
for _, field := range content.Fields {
cardFieldKeys[field.Key] = true
if field.SourceField != "" && !scenarioFieldKeys[field.SourceField] {
return fmt.Errorf("知识字段“%s”关联的场景字段“%s”不存在", field.Name, field.SourceField)
}
}
for _, template := range content.CopyTemplates {
for _, match := range placeholderPattern.FindAllStringSubmatch(template.Content, -1) {
if len(match) != 2 {
continue
}
parts := strings.SplitN(match[1], ".", 2)
if len(parts) != 2 {
continue
}
switch parts[0] {
case "input":
if !scenarioFieldKeys[parts[1]] {
return fmt.Errorf("话术“%s”引用的场景字段“%s”不存在", template.Title, parts[1])
}
case "card":
if !cardFieldKeys[parts[1]] {
return fmt.Errorf("话术“%s”引用的知识字段“%s”不存在", template.Title, parts[1])
}
}
}
}
return nil
}
func Render(content Content, answers map[string]interface{}) []RenderedCopy {
values := make(map[string]string, len(answers)+len(content.Fields)*2)
for key, value := range answers {
values["input."+key] = displayValue(value)
}
for _, field := range content.Fields {
value := field.Value
if field.SourceField != "" {
if answer, ok := answers[field.SourceField]; ok && hasValue(answer) {
value = answer
}
}
values["card."+field.Key] = displayValue(value)
}
copies := make([]RenderedCopy, 0, len(content.CopyTemplates))
for _, template := range content.CopyTemplates {
copies = append(copies, RenderedCopy{ID: template.ID, Title: template.Title, Content: placeholderPattern.ReplaceAllStringFunc(template.Content, func(token string) string {
matches := placeholderPattern.FindStringSubmatch(token)
if len(matches) != 2 || values[matches[1]] == "" {
return "未提供"
}
return values[matches[1]]
})})
}
return copies
}
func PrimaryCopy(content Content) string {
if len(content.CopyTemplates) > 0 {
return content.CopyTemplates[0].Content
}
return content.StandardCopy
}
func normalizeAndValidate(content Content) (Content, error) {
content.StandardCopy = strings.TrimSpace(content.StandardCopy)
content.ForbiddenCopy = strings.TrimSpace(content.ForbiddenCopy)
content.RiskNote = strings.TrimSpace(content.RiskNote)
if len(content.CopyTemplates) == 0 && content.StandardCopy != "" {
content.CopyTemplates = []CopyTemplate{{ID: "default", Title: "标准话术", Content: content.StandardCopy}}
}
if len(content.CopyTemplates) == 0 {
return Content{}, errors.New("请至少填写一条话术模板")
}
seenFields := map[string]bool{}
for i := range content.Fields {
field := &content.Fields[i]
field.Key = strings.TrimSpace(field.Key)
field.Name = strings.TrimSpace(field.Name)
field.SourceField = strings.TrimSpace(field.SourceField)
if !contentKeyPattern.MatchString(field.Key) || field.Name == "" {
return Content{}, errors.New("知识字段需要有效的字段标识和字段名称")
}
if seenFields[field.Key] {
return Content{}, fmt.Errorf("知识字段标识“%s”重复", field.Key)
}
if field.SourceField != "" && !contentKeyPattern.MatchString(field.SourceField) {
return Content{}, fmt.Errorf("知识字段“%s”的关联字段标识不正确", field.Name)
}
seenFields[field.Key] = true
}
seenTemplates := map[string]bool{}
for i := range content.CopyTemplates {
template := &content.CopyTemplates[i]
template.ID = strings.TrimSpace(template.ID)
template.Title = strings.TrimSpace(template.Title)
template.Content = strings.TrimSpace(template.Content)
if !contentKeyPattern.MatchString(template.ID) || template.Title == "" || template.Content == "" {
return Content{}, errors.New("每条话术需要有效的标识、标题和内容")
}
if seenTemplates[template.ID] {
return Content{}, fmt.Errorf("话术标识“%s”重复", template.ID)
}
seenTemplates[template.ID] = true
}
if content.StandardCopy == "" {
content.StandardCopy = content.CopyTemplates[0].Content
}
return content, nil
}
func hasValue(value interface{}) bool {
if value == nil {
return false
}
return strings.TrimSpace(displayValue(value)) != ""
}
func displayValue(value interface{}) string {
switch typed := value.(type) {
case nil:
return ""
case bool:
if typed {
return "是"
}
return "否"
case []interface{}:
items := make([]string, 0, len(typed))
for _, item := range typed {
items = append(items, displayValue(item))
}
return strings.Join(items, "、")
case []string:
return strings.Join(typed, "、")
default:
return fmt.Sprint(value)
}
}

View File

@@ -0,0 +1,44 @@
package knowledge
import "testing"
func TestParseContentAcceptsLegacyStandardCopy(t *testing.T) {
content, err := ParseContent([]byte(`{"standard_copy":"请说明情况","risk_note":"必要时转诊"}`))
if err != nil {
t.Fatal(err)
}
if len(content.CopyTemplates) != 1 || content.CopyTemplates[0].ID != "default" || content.StandardCopy != "请说明情况" {
t.Fatalf("unexpected legacy content: %#v", content)
}
}
func TestValidateReferencesRejectsUnknownScenarioField(t *testing.T) {
content, err := ParseContent([]byte(`{"fields":[{"key":"symptom","name":"症状","source_field":"pet_symptom"}],"copy_templates":[{"id":"copy","title":"说明","content":"{{card.symptom}}"}]}`))
if err != nil {
t.Fatal(err)
}
if err := ValidateReferences(content, map[string]bool{"pet_name": true}); err == nil {
t.Fatal("ValidateReferences() error = nil, want unknown source field error")
}
}
func TestValidateReferencesRejectsUnknownTemplateField(t *testing.T) {
content, err := ParseContent([]byte(`{"fields":[{"key":"symptom","name":"症状"}],"copy_templates":[{"id":"copy","title":"说明","content":"{{input.pet_name}} {{card.disease}}"}]}`))
if err != nil {
t.Fatal(err)
}
if err := ValidateReferences(content, map[string]bool{"pet_name": true}); err == nil {
t.Fatal("ValidateReferences() error = nil, want unknown knowledge field error")
}
}
func TestRenderResolvesInputAndCardFields(t *testing.T) {
content, err := ParseContent([]byte(`{"fields":[{"key":"symptom","name":"症状","value":"未明确","source_field":"symptom"},{"key":"disease","name":"可能疾病","value":"需要医生评估"}],"copy_templates":[{"id":"copy","title":"说明","content":"{{input.pet_name}}出现{{card.symptom}}{{card.disease}}{{input.pet_age}}岁。"}]}`))
if err != nil {
t.Fatal(err)
}
copies := Render(content, map[string]interface{}{"pet_name": "团子", "symptom": "呕吐", "pet_age": 3})
if len(copies) != 1 || copies[0].Content != "团子出现呕吐需要医生评估。3岁。" {
t.Fatalf("rendered copies = %#v", copies)
}
}

196
internal/knowledge/graph.go Normal file
View File

@@ -0,0 +1,196 @@
package knowledge
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strconv"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
)
var graphKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,63}$`)
type GraphInput struct {
Items []GraphItemInput `json:"items"`
Relations []GraphRelationInput `json:"relations"`
Symptoms []SymptomInput `json:"symptoms"`
}
type GraphItemInput struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Content map[string]interface{} `json:"content"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
type GraphRelationInput struct {
From string `json:"from"`
RelationType string `json:"relation_type"`
To string `json:"to"`
Condition map[string]interface{} `json:"condition"`
SortOrder int `json:"sort_order"`
}
type SymptomInput struct {
Key string `json:"key"`
Name string `json:"name"`
CopyTemplateIDs []string `json:"copy_template_ids"`
Diseases []GraphItemInput `json:"diseases"`
}
func (h *Handler) GetGraph(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
scenarioID, ok := graphScenarioID(c)
if !ok || !access.CanViewScenario(h.db, p, scenarioID) {
if ok {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
}
return
}
items := make([]model.KnowledgeItem, 0)
relations := make([]model.KnowledgeRelation, 0)
if err := h.db.Where("tenant_id = ? AND scenario_id = ? AND status <> ?", p.TenantID, scenarioID, "archived").Order("sort_order, id").Find(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识失败")
return
}
if err := h.db.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Order("sort_order, id").Find(&relations).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识关系失败")
return
}
response.OK(c, gin.H{"items": items, "relations": relations})
}
func (h *Handler) ReplaceGraph(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
scenarioID, ok := graphScenarioID(c)
if !ok || !access.CanEditScenario(h.db, p, scenarioID) {
if ok {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
}
return
}
var input GraphInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识 JSON 格式不正确")
return
}
items, relations, err := normalizeGraphInput(input)
if err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
err = h.db.Transaction(func(tx *gorm.DB) error {
var oldItems []model.KnowledgeItem
var oldRelations []model.KnowledgeRelation
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Find(&oldItems).Error; err != nil {
return err
}
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Find(&oldRelations).Error; err != nil {
return err
}
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Delete(&model.KnowledgeRelation{}).Error; err != nil {
return err
}
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Delete(&model.KnowledgeItem{}).Error; err != nil {
return err
}
ids := make(map[string]uint64, len(items))
for _, item := range items {
raw, _ := json.Marshal(item.Content)
row := model.KnowledgeItem{TenantID: p.TenantID, ScenarioID: scenarioID, ItemKey: item.Key, Name: item.Name, Type: item.Type, Content: datatypes.JSON(raw), Status: item.Status, SortOrder: item.SortOrder}
if err := tx.Create(&row).Error; err != nil {
return err
}
ids[item.Key] = row.ID
if err := audit.RecordTx(tx, p, "create", "knowledge_item", row.ID, gin.H{"scenario_id": scenarioID, "key": row.ItemKey}); err != nil {
return err
}
}
for _, relation := range relations {
raw, _ := json.Marshal(relation.Condition)
row := model.KnowledgeRelation{TenantID: p.TenantID, ScenarioID: scenarioID, FromKnowledgeID: ids[relation.From], RelationType: relation.RelationType, ToKnowledgeID: ids[relation.To], Condition: datatypes.JSON(raw), SortOrder: relation.SortOrder}
if err := tx.Create(&row).Error; err != nil {
return err
}
if err := audit.RecordTx(tx, p, "create", "knowledge_relation", row.ID, gin.H{"scenario_id": scenarioID, "from": relation.From, "relation_type": relation.RelationType, "to": relation.To}); err != nil {
return err
}
}
for _, relation := range oldRelations {
if err := audit.RecordTx(tx, p, "archive", "knowledge_relation", relation.ID, gin.H{"scenario_id": scenarioID, "from_knowledge_id": relation.FromKnowledgeID, "relation_type": relation.RelationType, "to_knowledge_id": relation.ToKnowledgeID, "sort_order": relation.SortOrder}); err != nil {
return err
}
}
for _, item := range oldItems {
if err := audit.RecordTx(tx, p, "archive", "knowledge_item", item.ID, gin.H{"scenario_id": scenarioID, "key": item.ItemKey, "name": item.Name, "type": item.Type, "status": item.Status, "sort_order": item.SortOrder}); err != nil {
return err
}
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存知识关系失败")
return
}
h.GetGraph(c)
}
func normalizeGraphInput(input GraphInput) ([]GraphItemInput, []GraphRelationInput, error) {
items := append([]GraphItemInput{}, input.Items...)
relations := append([]GraphRelationInput{}, input.Relations...)
for _, symptom := range input.Symptoms {
items = append(items, GraphItemInput{Key: symptom.Key, Name: symptom.Name, Type: "symptom", Status: "active"})
for _, disease := range symptom.Diseases {
disease.Type = "disease"
items = append(items, disease)
relations = append(relations, GraphRelationInput{From: symptom.Key, RelationType: "possible_disease", To: disease.Key})
}
for _, copyKey := range symptom.CopyTemplateIDs {
relations = append(relations, GraphRelationInput{From: symptom.Key, RelationType: "recommended_copy", To: copyKey})
}
}
seen := map[string]bool{}
unique := make([]GraphItemInput, 0, len(items))
for _, item := range items {
if !graphKeyPattern.MatchString(item.Key) || item.Name == "" || item.Type == "" {
return nil, nil, fmt.Errorf("知识 key、name 和 type 必须填写且格式正确")
}
if seen[item.Key] {
continue
}
seen[item.Key] = true
if item.Status == "" {
item.Status = "active"
}
if item.Content == nil {
item.Content = map[string]interface{}{}
}
unique = append(unique, item)
}
for _, relation := range relations {
if !seen[relation.From] || !seen[relation.To] || relation.RelationType == "" {
return nil, nil, fmt.Errorf("知识关系引用了不存在的 key")
}
}
return unique, relations, nil
}
func graphScenarioID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "场景 ID 不正确")
return 0, false
}
return id, true
}

View File

@@ -0,0 +1,13 @@
package knowledge
import "testing"
func TestNormalizeGraphInputSupportsMultipleDiseases(t *testing.T) {
items, relations, err := normalizeGraphInput(GraphInput{Symptoms: []SymptomInput{{Key: "poor_appetite", Name: "食欲下降", Diseases: []GraphItemInput{{Key: "gi", Name: "肠胃不适"}, {Key: "dental", Name: "口腔问题"}}}}})
if err != nil {
t.Fatal(err)
}
if len(items) != 3 || len(relations) != 2 {
t.Fatalf("items=%d relations=%d", len(items), len(relations))
}
}

View File

@@ -1,205 +1,13 @@
package knowledge
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
import "gorm.io/gorm"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Handler struct{ db *gorm.DB }
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
type input struct {
ScenarioID uint64 `json:"scenario_id" binding:"required"`
Title string `json:"title" binding:"required,max=128"`
Content map[string]interface{} `json:"content" binding:"required"`
// Handler exposes only the scenario knowledge-graph API. Legacy knowledge-card
// tables are retained as read-only storage for historical run rendering.
type Handler struct {
db *gorm.DB
}
type updateInput struct {
Title string `json:"title" binding:"required,max=128"`
Content map[string]interface{} `json:"content" binding:"required"`
}
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
type row struct {
model.KnowledgeCard
Content datatypes.JSON `json:"content"`
Version int `json:"version"`
}
items := make([]row, 0)
query := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content, kcv.version").Joins("JOIN scenarios sc ON sc.id = kc.scenario_id").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published")
query = access.ScopeScenarios(query, p, "sc")
err := query.Where("kc.tenant_id = ? AND kc.status <> ? AND sc.status <> ?", p.TenantID, "archived", "archived").Order("kc.updated_at DESC").Scan(&items).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Update(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
return
}
var body updateInput
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
return
}
if err := validateContent(body.Content); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
content, err := json.Marshal(body.Content)
if err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容格式不正确")
return
}
var card model.KnowledgeCard
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanEditScenario(h.db, p, card.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在或不可编辑")
return
}
var version model.KnowledgeCardVersion
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&card).Error; err != nil {
return err
}
var maxVersion int
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
return err
}
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}
version = model.KnowledgeCardVersion{TenantID: p.TenantID, KnowledgeCardID: id, Version: maxVersion + 1, Content: datatypes.JSON(content), Status: "published"}
if err := tx.Create(&version).Error; err != nil {
return err
}
return tx.Model(&card).Updates(map[string]interface{}{"title": body.Title, "status": "published"}).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新知识卡失败")
return
}
_ = audit.Record(h.db, p, "update", "knowledge_card", id, gin.H{"version": version.Version})
response.OK(c, gin.H{"id": id, "title": body.Title, "version": version.Version, "content": datatypes.JSON(content)})
}
func (h *Handler) Versions(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
return
}
var card model.KnowledgeCard
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanViewScenario(h.db, p, card.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
return
}
items := make([]model.KnowledgeCardVersion, 0)
if err := h.db.Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Order("version DESC").Find(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡版本失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Create(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
var body input
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
return
}
if err := validateContent(body.Content); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
if !access.CanEditScenario(h.db, p, body.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
return
}
content, _ := json.Marshal(body.Content)
var card model.KnowledgeCard
err := h.db.Transaction(func(tx *gorm.DB) error {
card = model.KnowledgeCard{TenantID: p.TenantID, ScenarioID: body.ScenarioID, Title: body.Title, Status: "published", CreatedBy: p.UserID}
if err := tx.Create(&card).Error; err != nil {
return err
}
return tx.Create(&model.KnowledgeCardVersion{TenantID: p.TenantID, KnowledgeCardID: card.ID, Version: 1, Content: datatypes.JSON(content), Status: "published"}).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建知识卡失败")
return
}
_ = audit.Record(h.db, p, "create", "knowledge_card", card.ID, body)
response.Created(c, card)
}
func (h *Handler) Delete(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
return
}
var card model.KnowledgeCard
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&card).Error; err != nil || !access.CanEditScenario(h.db, p, card.ScenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在或不可归档")
return
}
var references int64
err = h.db.Table("sop_nodes n").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where(
"n.tenant_id = ? AND s.scenario_id = ? AND sv.status IN ? AND n.type = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.knowledge_card_id')) = ?",
p.TenantID, card.ScenarioID, []string{"published", "offline", "superseded"}, "knowledge", strconv.FormatUint(id, 10),
).Count(&references).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "检查知识卡引用失败")
return
}
if references > 0 {
response.Error(c, http.StatusConflict, "KNOWLEDGE_IN_USE", "知识卡已被发布版本引用,不能归档")
return
}
result := h.db.Model(&model.KnowledgeCard{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived")
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
return
}
_ = audit.Record(h.db, p, "archive", "knowledge_card", id, nil)
response.OK(c, gin.H{"id": id})
}
func validateContent(content map[string]interface{}) error {
standard, ok := content["standard_copy"].(string)
if !ok || strings.TrimSpace(standard) == "" {
return errors.New("请填写标准话术")
}
for _, key := range []string{"forbidden_copy", "risk_note"} {
if value, exists := content[key]; exists {
if _, ok := value.(string); !ok {
return errors.New("知识卡文本字段格式不正确")
}
}
}
return nil
func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db}
}

View File

@@ -1,23 +0,0 @@
package knowledge
import "testing"
func TestValidateContent(t *testing.T) {
tests := []struct {
name string
content map[string]interface{}
wantErr bool
}{
{name: "valid", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": "风险提示"}},
{name: "missing standard copy", content: map[string]interface{}{"risk_note": "风险提示"}, wantErr: true},
{name: "blank standard copy", content: map[string]interface{}{"standard_copy": " "}, wantErr: true},
{name: "invalid risk note", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": 1}, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := validateContent(test.content); (err != nil) != test.wantErr {
t.Fatalf("validateContent() error = %v, wantErr %v", err, test.wantErr)
}
})
}
}