feat: simplify SOP to immediate-effect configuration
This commit is contained in:
197
internal/knowledge/content.go
Normal file
197
internal/knowledge/content.go
Normal 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)
|
||||
}
|
||||
}
|
||||
44
internal/knowledge/content_test.go
Normal file
44
internal/knowledge/content_test.go
Normal 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
196
internal/knowledge/graph.go
Normal 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
|
||||
}
|
||||
13
internal/knowledge/graph_test.go
Normal file
13
internal/knowledge/graph_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user