fix: remove legacy knowledge card tables and compatibility code
This commit is contained in:
@@ -36,11 +36,10 @@ multitable:
|
||||
scenarios: 0
|
||||
scenario_fields: 0
|
||||
scenario_rules: 0
|
||||
sop_versions: 0
|
||||
sops: 0
|
||||
sop_nodes: 0
|
||||
sop_edges: 0
|
||||
knowledge_items: 0
|
||||
knowledge_relations: 0
|
||||
knowledge_versions: 0
|
||||
runs: 0
|
||||
feedback: 0
|
||||
|
||||
@@ -30,11 +30,10 @@ multitable:
|
||||
scenarios: 66
|
||||
scenario_fields: 67
|
||||
scenario_rules: 68
|
||||
sop_versions: 69
|
||||
sops: 69
|
||||
sop_nodes: 70
|
||||
sop_edges: 71
|
||||
knowledge_items: 72
|
||||
knowledge_relations: 73
|
||||
knowledge_versions: 74
|
||||
runs: 75
|
||||
feedback: 76
|
||||
|
||||
@@ -36,11 +36,10 @@ multitable:
|
||||
scenarios: 46
|
||||
scenario_fields: 47
|
||||
scenario_rules: 63
|
||||
sop_versions: 48
|
||||
sops: 48
|
||||
sop_nodes: 49
|
||||
sop_edges: 50
|
||||
knowledge_items: 64
|
||||
knowledge_relations: 65
|
||||
knowledge_versions: 51
|
||||
runs: 52
|
||||
feedback: 53
|
||||
|
||||
@@ -79,13 +79,11 @@ type MultiTableTables struct {
|
||||
Scenarios uint64 `yaml:"scenarios" env:"APP_MULTITABLE_TABLES_SCENARIOS"`
|
||||
ScenarioFields uint64 `yaml:"scenario_fields" env:"APP_MULTITABLE_TABLES_SCENARIO_FIELDS"`
|
||||
ScenarioRules uint64 `yaml:"scenario_rules" env:"APP_MULTITABLE_TABLES_SCENARIO_RULES"`
|
||||
SOPVersions uint64 `yaml:"sop_versions" env:"APP_MULTITABLE_TABLES_SOP_VERSIONS"`
|
||||
SOPs uint64 `yaml:"sops" env:"APP_MULTITABLE_TABLES_SOPS"`
|
||||
SOPNodes uint64 `yaml:"sop_nodes" env:"APP_MULTITABLE_TABLES_SOP_NODES"`
|
||||
SOPEdges uint64 `yaml:"sop_edges" env:"APP_MULTITABLE_TABLES_SOP_EDGES"`
|
||||
KnowledgeItems uint64 `yaml:"knowledge_items" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS"`
|
||||
KnowledgeRelations uint64 `yaml:"knowledge_relations" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS"`
|
||||
// KnowledgeVersions is retained only for replaying historical projection events.
|
||||
KnowledgeVersions uint64 `yaml:"knowledge_versions" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_VERSIONS"`
|
||||
Runs uint64 `yaml:"runs" env:"APP_MULTITABLE_TABLES_RUNS"`
|
||||
Feedback uint64 `yaml:"feedback" env:"APP_MULTITABLE_TABLES_FEEDBACK"`
|
||||
}
|
||||
@@ -188,7 +186,7 @@ func (c Config) Validate() error {
|
||||
c.MultiTable.Tables.Scenarios,
|
||||
c.MultiTable.Tables.ScenarioFields,
|
||||
c.MultiTable.Tables.ScenarioRules,
|
||||
c.MultiTable.Tables.SOPVersions,
|
||||
c.MultiTable.Tables.SOPs,
|
||||
c.MultiTable.Tables.SOPNodes,
|
||||
c.MultiTable.Tables.SOPEdges,
|
||||
c.MultiTable.Tables.KnowledgeItems,
|
||||
|
||||
@@ -87,10 +87,9 @@ func TestLoadMultiTableEnvironmentConfiguration(t *testing.T) {
|
||||
"APP_MULTITABLE_TABLES_SCENARIOS": "46",
|
||||
"APP_MULTITABLE_TABLES_SCENARIO_FIELDS": "47",
|
||||
"APP_MULTITABLE_TABLES_SCENARIO_RULES": "54",
|
||||
"APP_MULTITABLE_TABLES_SOP_VERSIONS": "48",
|
||||
"APP_MULTITABLE_TABLES_SOPS": "48",
|
||||
"APP_MULTITABLE_TABLES_SOP_NODES": "49",
|
||||
"APP_MULTITABLE_TABLES_SOP_EDGES": "50",
|
||||
"APP_MULTITABLE_TABLES_KNOWLEDGE_VERSIONS": "51",
|
||||
"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS": "55",
|
||||
"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS": "56",
|
||||
"APP_MULTITABLE_TABLES_RUNS": "52",
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||
@@ -43,6 +44,17 @@ func Open(cfg config.DatabaseConfig, log *zap.Logger) (*gorm.DB, error) {
|
||||
// existing columns, which keeps production data safe during application
|
||||
// upgrades.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
// The application schema intentionally has no foreign keys. Remove any
|
||||
// constraints left by older installations before dropping legacy tables.
|
||||
if err := dropAllForeignKeys(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dropUnusedLegacyTables(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := prepareSingleVersionSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.AutoMigrate(
|
||||
&model.Tenant{},
|
||||
&model.User{},
|
||||
@@ -52,11 +64,8 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.ScenarioField{},
|
||||
&model.ScenarioRule{},
|
||||
&model.SOP{},
|
||||
&model.SOPVersion{},
|
||||
&model.SOPNode{},
|
||||
&model.SOPEdge{},
|
||||
&model.KnowledgeCard{},
|
||||
&model.KnowledgeCardVersion{},
|
||||
&model.KnowledgeItem{},
|
||||
&model.KnowledgeRelation{},
|
||||
&model.SOPRun{},
|
||||
@@ -69,6 +78,154 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
)
|
||||
}
|
||||
|
||||
// dropUnusedLegacyTables removes schema objects that are no longer part of
|
||||
// the scenario knowledge graph. Drop the version table before its parent so
|
||||
// this remains safe for databases that still have a historical constraint.
|
||||
func dropUnusedLegacyTables(db *gorm.DB) error {
|
||||
for _, table := range []string{"knowledge_card_versions", "knowledge_cards"} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec("DROP TABLE " + quoteIdentifier(table)).Error; err != nil {
|
||||
return fmt.Errorf("drop unused table %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareSingleVersionSchema performs the data-preserving part of the SOP
|
||||
// version removal. GORM does not rename or drop columns, so existing installs
|
||||
// need their current published version copied onto the single SOP graph.
|
||||
func prepareSingleVersionSchema(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("sops") {
|
||||
return nil
|
||||
}
|
||||
// Foreign keys are intentionally not part of the application schema. Drop
|
||||
// historical constraints before GORM rebuilds any tables during migration;
|
||||
// old version references may contain rows that no longer have a parent.
|
||||
if err := dropAllForeignKeys(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasColumn("sops", "start_node_key") {
|
||||
if err := db.Exec("ALTER TABLE sops ADD COLUMN start_node_key VARCHAR(64) NOT NULL DEFAULT 'start'").Error; err != nil {
|
||||
return fmt.Errorf("add sops.start_node_key: %w", err)
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("sop_versions") {
|
||||
if err := db.Exec("UPDATE sops s JOIN sop_versions sv ON sv.id = (SELECT MAX(sv2.id) FROM sop_versions sv2 WHERE sv2.sop_id = s.id AND sv2.status = 'published') SET s.start_node_key = COALESCE(NULLIF(sv.start_node_key, ''), NULLIF(s.start_node_key, ''), 'start')").Error; err != nil {
|
||||
return fmt.Errorf("copy SOP start nodes: %w", err)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"sop_nodes", "sop_edges", "sop_runs"} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
if !db.Migrator().HasColumn(table, "sop_id") {
|
||||
if err := db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN sop_id BIGINT UNSIGNED NULL", table)).Error; err != nil {
|
||||
return fmt.Errorf("add %s.sop_id: %w", table, err)
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("sop_versions") && db.Migrator().HasColumn(table, "sop_version_id") {
|
||||
if err := db.Exec(fmt.Sprintf("UPDATE %s x JOIN sop_versions sv ON sv.id = x.sop_version_id SET x.sop_id = sv.sop_id", table)).Error; err != nil {
|
||||
return fmt.Errorf("copy %s SOP references: %w", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("sop_versions") {
|
||||
if err := db.Exec("DELETE n FROM sop_nodes n JOIN sop_versions sv ON sv.id = n.sop_version_id WHERE sv.status <> 'published' OR sv.id <> (SELECT MAX(sv2.id) FROM sop_versions sv2 WHERE sv2.sop_id = sv.sop_id AND sv2.status = 'published')").Error; err != nil {
|
||||
return fmt.Errorf("remove historical SOP nodes: %w", err)
|
||||
}
|
||||
if err := db.Exec("DELETE e FROM sop_edges e JOIN sop_versions sv ON sv.id = e.sop_version_id WHERE sv.status <> 'published' OR sv.id <> (SELECT MAX(sv2.id) FROM sop_versions sv2 WHERE sv2.sop_id = sv.sop_id AND sv2.status = 'published')").Error; err != nil {
|
||||
return fmt.Errorf("remove historical SOP edges: %w", err)
|
||||
}
|
||||
}
|
||||
if err := removeLegacySOPVersionColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeLegacySOPVersionColumns completes the single-version migration. The
|
||||
// old columns are not represented by the GORM models, so AutoMigrate cannot
|
||||
// remove them. Leaving a NOT NULL sop_version_id behind makes inserts fail.
|
||||
func removeLegacySOPVersionColumns(db *gorm.DB) error {
|
||||
for _, table := range []string{"sop_nodes", "sop_edges", "sop_runs"} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
continue
|
||||
}
|
||||
if !db.Migrator().HasColumn(table, "sop_id") {
|
||||
continue
|
||||
}
|
||||
var missing int64
|
||||
if err := db.Table(table).Where("sop_id IS NULL").Count(&missing).Error; err != nil {
|
||||
return fmt.Errorf("check %s SOP references: %w", table, err)
|
||||
}
|
||||
if missing > 0 {
|
||||
return fmt.Errorf("cannot remove %s.sop_version_id: %d rows have no sop_id", table, missing)
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf("ALTER TABLE %s MODIFY COLUMN sop_id BIGINT UNSIGNED NOT NULL", quoteIdentifier(table))).Error; err != nil {
|
||||
return fmt.Errorf("make %s.sop_id required: %w", table, err)
|
||||
}
|
||||
if !db.Migrator().HasColumn(table, "sop_version_id") {
|
||||
continue
|
||||
}
|
||||
if err := dropIndexesForColumn(db, table, "sop_version_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf("ALTER TABLE %s DROP COLUMN sop_version_id", quoteIdentifier(table))).Error; err != nil {
|
||||
return fmt.Errorf("drop %s.sop_version_id: %w", table, err)
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasTable("sop_versions") {
|
||||
if err := db.Exec("DROP TABLE sop_versions").Error; err != nil {
|
||||
return fmt.Errorf("drop sop_versions: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropIndexesForColumn(db *gorm.DB, table, column string) error {
|
||||
var indexes []struct {
|
||||
Name string `gorm:"column:index_name"`
|
||||
}
|
||||
if err := db.Raw("SELECT DISTINCT INDEX_NAME AS index_name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? AND INDEX_NAME <> 'PRIMARY'", table, column).Scan(&indexes).Error; err != nil {
|
||||
return fmt.Errorf("list %s.%s indexes: %w", table, column, err)
|
||||
}
|
||||
for _, index := range indexes {
|
||||
if index.Name == "" {
|
||||
continue
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf("ALTER TABLE %s DROP INDEX %s", quoteIdentifier(table), quoteIdentifier(index.Name))).Error; err != nil {
|
||||
return fmt.Errorf("drop index %s.%s: %w", table, index.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dropAllForeignKeys(db *gorm.DB) error {
|
||||
var constraints []struct {
|
||||
TableName string `gorm:"column:table_name"`
|
||||
ConstraintName string `gorm:"column:constraint_name"`
|
||||
}
|
||||
if err := db.Raw("SELECT TABLE_NAME AS table_name, CONSTRAINT_NAME AS constraint_name FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_TYPE = 'FOREIGN KEY'").Scan(&constraints).Error; err != nil {
|
||||
return fmt.Errorf("list foreign keys: %w", err)
|
||||
}
|
||||
for _, constraint := range constraints {
|
||||
if constraint.TableName == "" || constraint.ConstraintName == "" {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("ALTER TABLE %s DROP FOREIGN KEY %s", quoteIdentifier(constraint.TableName), quoteIdentifier(constraint.ConstraintName))
|
||||
if err := db.Exec(query).Error; err != nil {
|
||||
return fmt.Errorf("drop foreign key %s.%s: %w", constraint.TableName, constraint.ConstraintName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func quoteIdentifier(value string) string {
|
||||
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
|
||||
}
|
||||
|
||||
type zapGORMLogger struct {
|
||||
log *zap.Logger
|
||||
level gormlogger.LogLevel
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@ package knowledge
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// Handler exposes only the scenario knowledge-graph API. Legacy knowledge-card
|
||||
// tables are retained as read-only storage for historical run rendering.
|
||||
// Handler exposes the scenario knowledge-graph API.
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
@@ -96,23 +96,13 @@ type SOP struct {
|
||||
Description string `json:"description" gorm:"type:text;not null"`
|
||||
Status string `json:"status" gorm:"size:24;not null"`
|
||||
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
||||
}
|
||||
|
||||
type SOPVersion struct {
|
||||
Base
|
||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
|
||||
Version int `json:"version" gorm:"not null"`
|
||||
Status string `json:"status" gorm:"size:24;not null"`
|
||||
StartNodeKey string `json:"start_node_key" gorm:"size:64;not null"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
||||
}
|
||||
|
||||
type SOPNode struct {
|
||||
Base
|
||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
|
||||
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
|
||||
NodeKey string `json:"node_key" gorm:"size:64;not null"`
|
||||
Type string `json:"type" gorm:"size:32;not null"`
|
||||
Title string `json:"title" gorm:"size:128;not null"`
|
||||
@@ -125,31 +115,13 @@ type SOPNode struct {
|
||||
type SOPEdge struct {
|
||||
Base
|
||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
|
||||
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
|
||||
SourceNodeKey string `json:"source_node_key" gorm:"size:64;not null"`
|
||||
TargetNodeKey string `json:"target_node_key" gorm:"size:64;not null"`
|
||||
Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"`
|
||||
Priority int `json:"priority" gorm:"not null"`
|
||||
}
|
||||
|
||||
type KnowledgeCard struct {
|
||||
Base
|
||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
|
||||
Title string `json:"title" gorm:"size:128;not null"`
|
||||
Status string `json:"status" gorm:"size:24;not null"`
|
||||
CreatedBy uint64 `json:"created_by" gorm:"not null"`
|
||||
}
|
||||
|
||||
type KnowledgeCardVersion struct {
|
||||
Base
|
||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||
KnowledgeCardID uint64 `json:"knowledge_card_id" gorm:"not null;index"`
|
||||
Version int `json:"version" gorm:"not null"`
|
||||
Content datatypes.JSON `json:"content" gorm:"type:json;not null"`
|
||||
Status string `json:"status" gorm:"size:24;not null"`
|
||||
}
|
||||
|
||||
type KnowledgeItem struct {
|
||||
Base
|
||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||
@@ -177,7 +149,6 @@ type SOPRun struct {
|
||||
Base
|
||||
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
|
||||
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
|
||||
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
|
||||
OperatorID uint64 `json:"operator_id" gorm:"not null;index"`
|
||||
ExternalRef string `json:"external_ref" gorm:"size:191;not null;index"`
|
||||
CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"`
|
||||
|
||||
@@ -150,46 +150,33 @@ func (p *Projector) sop(ctx context.Context, event model.MultiTableOutbox) error
|
||||
if err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&sop).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
versions := make([]model.SOPVersion, 0)
|
||||
if err := p.db.Where("sop_id = ? AND tenant_id = ? AND status IN ?", sop.ID, sop.TenantID, []string{"published", "superseded", "offline"}).Find(&versions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, version := range versions {
|
||||
if err := p.sopVersion(ctx, sop, version); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Projector) sopVersion(ctx context.Context, sop model.SOP, version model.SOPVersion) error {
|
||||
nodes := make([]model.SOPNode, 0)
|
||||
edges := make([]model.SOPEdge, 0)
|
||||
if err := p.db.Where("sop_version_id = ?", version.ID).Order("id").Find(&nodes).Error; err != nil {
|
||||
if err := p.db.Where("sop_id = ? AND tenant_id = ?", sop.ID, sop.TenantID).Order("id").Find(&nodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.db.Where("sop_version_id = ?", version.ID).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
if err := p.db.Where("sop_id = ? AND tenant_id = ?", sop.ID, sop.TenantID).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
state := statusFor(version.Status)
|
||||
state := statusFor(sop.Status)
|
||||
if sop.Status == "archived" {
|
||||
state = "已归档"
|
||||
}
|
||||
if err := p.client.Upsert(ctx, p.tables.SOPVersions, id(version.ID), common(version.ID, version.TenantID, state, version.UpdatedAt, map[string]interface{}{
|
||||
"SOP来源ID": id(sop.ID), "场景来源ID": id(sop.ScenarioID), "SOP名称": sop.Name, "版本号": version.Version, "版本状态": version.Status, "开始节点": version.StartNodeKey, "发布时间": formatTime(version.PublishedAt), "节点数": len(nodes), "路径数": len(edges),
|
||||
if err := p.client.Upsert(ctx, p.tables.SOPs, id(sop.ID), common(sop.ID, sop.TenantID, state, sop.UpdatedAt, map[string]interface{}{
|
||||
"场景来源ID": id(sop.ScenarioID), "SOP名称": sop.Name, "SOP状态": sop.Status, "开始节点": sop.StartNodeKey, "节点数": len(nodes), "路径数": len(edges),
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if err := p.client.Upsert(ctx, p.tables.SOPNodes, id(node.ID), common(node.ID, node.TenantID, state, node.UpdatedAt, map[string]interface{}{
|
||||
"SOP版本来源ID": id(version.ID), "节点标识": node.NodeKey, "节点类型": node.Type, "标题": node.Title, "标准话术或操作提示": clip(node.Content), "配置": jsonText(node.Config), "排序": node.PositionY,
|
||||
"SOP来源ID": id(sop.ID), "节点标识": node.NodeKey, "节点类型": node.Type, "标题": node.Title, "标准话术或操作提示": clip(node.Content), "配置": jsonText(node.Config), "排序": node.PositionY,
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, edge := range edges {
|
||||
if err := p.client.Upsert(ctx, p.tables.SOPEdges, id(edge.ID), common(edge.ID, edge.TenantID, state, edge.UpdatedAt, map[string]interface{}{
|
||||
"SOP版本来源ID": id(version.ID), "起点节点标识": edge.SourceNodeKey, "终点节点标识": edge.TargetNodeKey, "条件": jsonText(edge.Condition), "优先级": edge.Priority,
|
||||
"SOP来源ID": id(sop.ID), "起点节点标识": edge.SourceNodeKey, "终点节点标识": edge.TargetNodeKey, "条件": jsonText(edge.Condition), "优先级": edge.Priority,
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -264,7 +251,7 @@ func (p *Projector) run(ctx context.Context, event model.MultiTableOutbox) error
|
||||
_ = json.Unmarshal(row.Answers, &answers)
|
||||
answerCount = len(answers)
|
||||
return p.client.Upsert(ctx, p.tables.Runs, id(row.ID), common(row.ID, row.TenantID, "正常", row.UpdatedAt, map[string]interface{}{
|
||||
"SOP来源ID": id(row.SOPID), "SOP版本来源ID": id(row.SOPVersionID), "场景名称": row.ScenarioName, "执行人ID": id(row.OperatorID), "执行状态": row.Status, "执行结果": row.Result, "最终结果": jsonText(row.FinalResult), "已采集字段数": answerCount, "开始时间": formatTime(&row.StartedAt), "完成时间": formatTime(row.CompletedAt),
|
||||
"SOP来源ID": id(row.SOPID), "场景名称": row.ScenarioName, "执行人ID": id(row.OperatorID), "执行状态": row.Status, "执行结果": row.Result, "最终结果": jsonText(row.FinalResult), "已采集字段数": answerCount, "开始时间": formatTime(&row.StartedAt), "完成时间": formatTime(row.CompletedAt),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ type DetailEvent struct {
|
||||
NodeType string `json:"node_type"`
|
||||
NodeContent string `json:"node_content"`
|
||||
NodeConfig datatypes.JSON `json:"-"`
|
||||
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
|
||||
Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
@@ -48,11 +47,10 @@ func (h *Handler) Detail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
events := make([]DetailEvent, 0)
|
||||
if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content, n.config AS node_config").Joins("LEFT JOIN sop_nodes n ON n.sop_version_id = ? AND n.node_key = e.node_key", header.SOPVersionID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil {
|
||||
if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content, n.config AS node_config").Joins("LEFT JOIN sop_nodes n ON n.sop_id = ? AND n.node_key = e.node_key", header.SOPID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败")
|
||||
return
|
||||
}
|
||||
knowledgeCache := map[string]*KnowledgeView{}
|
||||
answers := map[string]interface{}{}
|
||||
_ = json.Unmarshal(header.Answers, &answers)
|
||||
derived := map[string]interface{}{}
|
||||
@@ -65,16 +63,15 @@ func (h *Handler) Detail(c *gin.Context) {
|
||||
if events[i].NodeType != "knowledge" {
|
||||
continue
|
||||
}
|
||||
cacheKey := string(events[i].NodeConfig)
|
||||
if cached, ok := knowledgeCache[cacheKey]; ok {
|
||||
events[i].Knowledge = cached
|
||||
continue
|
||||
}
|
||||
var config knowledgeNodeConfig
|
||||
_ = json.Unmarshal(events[i].NodeConfig, &config)
|
||||
if config.KnowledgeSelector != nil {
|
||||
if config.KnowledgeSelector == nil {
|
||||
continue
|
||||
}
|
||||
var node model.SOPNode
|
||||
if err := h.db.Where("sop_version_id = ? AND node_key = ?", header.SOPVersionID, events[i].NodeKey).First(&node).Error; err == nil {
|
||||
if err := h.db.Where("sop_id = ? AND node_key = ?", header.SOPID, events[i].NodeKey).First(&node).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
outputs, loadErr := loadKnowledgeOutputs(h.db, node, principal.TenantID, context, *config.KnowledgeSelector)
|
||||
if loadErr != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录知识快照不可用")
|
||||
@@ -82,16 +79,6 @@ func (h *Handler) Detail(c *gin.Context) {
|
||||
}
|
||||
events[i].Outputs = outputs
|
||||
}
|
||||
continue
|
||||
}
|
||||
knowledge, err := loadKnowledgeView(h.db, model.SOPNode{Type: events[i].NodeType, Config: events[i].NodeConfig}, principal.TenantID, answers)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的历史知识内容不存在")
|
||||
return
|
||||
}
|
||||
events[i].Knowledge = &knowledge
|
||||
knowledgeCache[cacheKey] = &knowledge
|
||||
}
|
||||
feedback := make([]DetailFeedback, 0)
|
||||
if err := h.db.Table("sop_feedback f").Select("f.*, u.display_name AS user_name").Joins("JOIN users u ON u.id = f.user_id").Where("f.run_id = ? AND f.tenant_id = ?", id, principal.TenantID).Order("f.created_at").Scan(&feedback).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行反馈失败")
|
||||
|
||||
@@ -51,7 +51,7 @@ func (h *Handler) AvailableSOPs(c *gin.Context) {
|
||||
ScenarioName string `json:"scenario_name"`
|
||||
}
|
||||
items := make([]item, 0)
|
||||
query := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published")
|
||||
query := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name").Joins("JOIN scenarios sc ON sc.id = s.scenario_id")
|
||||
query = access.ScopeScenarios(query, p, "sc")
|
||||
err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
|
||||
if err != nil {
|
||||
@@ -89,8 +89,8 @@ func (h *Handler) Start(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
var version model.SOPVersion
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
|
||||
var sop model.SOP
|
||||
if err := h.db.Where("id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").First(&sop).Error; err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP")
|
||||
return
|
||||
}
|
||||
@@ -99,11 +99,7 @@ func (h *Handler) Start(c *gin.Context) {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
|
||||
return
|
||||
}
|
||||
var sopItem model.SOP
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", input.SOPID, p.TenantID).First(&sopItem).Error; err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
var sopItem = sop
|
||||
if input.Input == nil {
|
||||
input.Input = map[string]interface{}{}
|
||||
}
|
||||
@@ -138,7 +134,7 @@ func (h *Handler) Start(c *gin.Context) {
|
||||
if externalRef == "" {
|
||||
externalRef = "run-" + uuid.NewString()
|
||||
}
|
||||
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, SOPVersionID: version.ID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON(initialAnswers), Input: datatypes.JSON(initialAnswers), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), Result: "", StartedAt: time.Now()}
|
||||
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(initialAnswers), Input: datatypes.JSON(initialAnswers), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), Result: "", StartedAt: time.Now()}
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&run).Error; err != nil {
|
||||
return err
|
||||
@@ -208,7 +204,7 @@ func (h *Handler) List(c *gin.Context) {
|
||||
items := make([]listItem, 0)
|
||||
// The run payload contains several large JSON snapshots. The history list only
|
||||
// needs scalar metadata; excluding blobs keeps MySQL's sort buffer bounded.
|
||||
listColumns := "r.id, r.created_at, r.updated_at, r.tenant_id, r.sop_id, r.sop_version_id, r.operator_id, r.external_ref, r.current_node_key, r.status, r.result, r.started_at, r.completed_at, s.name AS sop_name, sc.name AS scenario_name, u.display_name AS operator_name"
|
||||
listColumns := "r.id, r.created_at, r.updated_at, r.tenant_id, r.sop_id, r.operator_id, r.external_ref, r.current_node_key, r.status, r.result, r.started_at, r.completed_at, s.name AS sop_name, sc.name AS scenario_name, u.display_name AS operator_name"
|
||||
if err := query.Select(listColumns).Order("r.created_at DESC, r.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
||||
return
|
||||
@@ -268,7 +264,7 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
return errors.New("当前步骤已经变化,请刷新后重试")
|
||||
}
|
||||
var currentNode model.SOPNode
|
||||
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPVersionID, updated.CurrentNodeKey, p.TenantID).First(¤tNode).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).First(¤tNode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
fields := make([]model.ScenarioField, 0)
|
||||
@@ -289,7 +285,7 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
return err
|
||||
}
|
||||
var edges []model.SOPEdge
|
||||
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
sortEdges(edges)
|
||||
@@ -313,7 +309,7 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
return err
|
||||
}
|
||||
var next model.SOPNode
|
||||
if err := tx.Where("sop_version_id = ? AND node_key = ?", updated.SOPVersionID, nextKey).First(&next).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, nextKey, p.TenantID).First(&next).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{"current_node_key": nextKey, "answers": datatypes.JSON(answerBytes)}
|
||||
@@ -492,7 +488,7 @@ func (h *Handler) Feedback(c *gin.Context) {
|
||||
|
||||
func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
|
||||
var node model.SOPNode
|
||||
if err := h.db.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", item.SOPVersionID, item.CurrentNodeKey, item.TenantID).First(&node).Error; err != nil {
|
||||
if err := h.db.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", item.SOPID, item.CurrentNodeKey, item.TenantID).First(&node).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,30 +7,12 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
knowledgecontent "git.iwork-ai.com/xdc/iqudo-top1/internal/knowledge"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var knowledgePlaceholderPattern = regexp.MustCompile(`\{\{\s*(?:input|derived|form)\.([A-Za-z][A-Za-z0-9_]*)\s*\}\}`)
|
||||
|
||||
type KnowledgeView struct {
|
||||
CardID uint64 `json:"card_id"`
|
||||
CardVersionID uint64 `json:"card_version_id"`
|
||||
Version int `json:"version"`
|
||||
Title string `json:"title"`
|
||||
StandardCopy string `json:"standard_copy"`
|
||||
ForbiddenCopy string `json:"forbidden_copy"`
|
||||
RiskNote string `json:"risk_note"`
|
||||
Copies []KnowledgeCopy `json:"copies"`
|
||||
}
|
||||
|
||||
type KnowledgeCopy struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type NodeView struct {
|
||||
NodeKey string `json:"node_key"`
|
||||
Type string `json:"type"`
|
||||
@@ -39,7 +21,6 @@ type NodeView struct {
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
Fields []PublicFieldView `json:"fields,omitempty"`
|
||||
Presentation *NodePresentation `json:"presentation,omitempty"`
|
||||
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
||||
Outputs []KnowledgeGroup `json:"outputs"`
|
||||
Collection *KnowledgeCollectionView `json:"collection,omitempty"`
|
||||
}
|
||||
@@ -70,8 +51,6 @@ type KnowledgeItemView struct {
|
||||
}
|
||||
|
||||
type knowledgeNodeConfig struct {
|
||||
KnowledgeCardID uint64 `json:"knowledge_card_id"`
|
||||
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
|
||||
KnowledgeSelector *knowledgeSelector `json:"knowledge_selector"`
|
||||
KnowledgeCollection *knowledgeCollectionConfig `json:"knowledge_collection"`
|
||||
}
|
||||
@@ -168,11 +147,6 @@ func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64, ans
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
knowledge, err := loadKnowledgeView(db, node, tenantID, answers)
|
||||
if err != nil {
|
||||
return view, err
|
||||
}
|
||||
view.Knowledge = &knowledge
|
||||
return view, nil
|
||||
}
|
||||
|
||||
@@ -272,7 +246,7 @@ func loadPublicFieldsByKeys(db *gorm.DB, node model.SOPNode, tenantID uint64, ke
|
||||
return []PublicFieldView{}, nil
|
||||
}
|
||||
var fields []model.ScenarioField
|
||||
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id").Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).Find(&fields).Error; err != nil {
|
||||
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).Find(&fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byKey := map[string]model.ScenarioField{}
|
||||
@@ -310,7 +284,7 @@ func loadPublicNodeFields(db *gorm.DB, node model.SOPNode, tenantID uint64) ([]P
|
||||
return nil, nil
|
||||
}
|
||||
var fields []model.ScenarioField
|
||||
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id").Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).Find(&fields).Error; err != nil {
|
||||
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).Find(&fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byKey := make(map[string]model.ScenarioField, len(fields))
|
||||
@@ -330,7 +304,7 @@ func loadPublicNodeFields(db *gorm.DB, node model.SOPNode, tenantID uint64) ([]P
|
||||
|
||||
func loadKnowledgeOutputs(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) {
|
||||
var sopRow struct{ ScenarioID uint64 }
|
||||
if err := db.Table("sop_nodes n").Select("s.scenario_id").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where("n.id = ? AND n.tenant_id = ?", node.ID, tenantID).Scan(&sopRow).Error; err != nil {
|
||||
if err := db.Table("sop_nodes n").Select("s.scenario_id").Joins("JOIN sops s ON s.id = n.sop_id").Where("n.id = ? AND n.tenant_id = ?", node.ID, tenantID).Scan(&sopRow).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadKnowledgeOutputsForScenario(db, sopRow.ScenarioID, tenantID, context, selector)
|
||||
@@ -534,43 +508,3 @@ func stringSlice(value interface{}) []string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64, answers map[string]interface{}) (KnowledgeView, error) {
|
||||
var config knowledgeNodeConfig
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||
return KnowledgeView{}, err
|
||||
}
|
||||
type row struct {
|
||||
model.KnowledgeCardVersion
|
||||
Title string `gorm:"column:title"`
|
||||
}
|
||||
var version row
|
||||
query := db.Table("knowledge_card_versions kv").Select("kv.*, kc.title").
|
||||
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
|
||||
Where("kv.tenant_id = ? AND kc.tenant_id = ? AND kv.knowledge_card_id = ?", tenantID, tenantID, config.KnowledgeCardID)
|
||||
if config.KnowledgeCardVersionID != 0 {
|
||||
query = query.Where("kv.id = ?", config.KnowledgeCardVersionID)
|
||||
} else {
|
||||
query = query.Where("kv.status = ?", "published").Order("kv.version DESC")
|
||||
}
|
||||
if err := query.First(&version).Error; err != nil {
|
||||
return KnowledgeView{}, err
|
||||
}
|
||||
content, err := knowledgecontent.ParseContent(version.Content)
|
||||
if err != nil {
|
||||
return KnowledgeView{}, err
|
||||
}
|
||||
rendered := knowledgecontent.Render(content, answers)
|
||||
copies := make([]KnowledgeCopy, 0, len(rendered))
|
||||
for _, copy := range rendered {
|
||||
copies = append(copies, KnowledgeCopy{ID: copy.ID, Title: copy.Title, Content: copy.Content})
|
||||
}
|
||||
standardCopy := ""
|
||||
if len(copies) > 0 {
|
||||
standardCopy = copies[0].Content
|
||||
}
|
||||
return KnowledgeView{
|
||||
CardID: version.KnowledgeCardID, CardVersionID: version.ID, Version: version.Version, Title: version.Title,
|
||||
StandardCopy: standardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote, Copies: copies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestNodeViewDoesNotExposeInternalSnapshotFields(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"id", "tenant_id", "sop_version_id", "config", "position_x", "position_y"} {
|
||||
for _, key := range []string{"id", "tenant_id", "sop_id", "sop_version_id", "config", "position_x", "position_y"} {
|
||||
if _, exists := value[key]; exists {
|
||||
t.Fatalf("public node contains internal field %s: %s", key, raw)
|
||||
}
|
||||
|
||||
@@ -65,11 +65,6 @@ func (h *Handler) PublicStart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
var version model.SOPVersion
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", sop.ID, scenario.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
|
||||
response.Error(c, http.StatusNotFound, "SOP_NOT_FOUND", "场景没有可执行的 SOP")
|
||||
return
|
||||
}
|
||||
fields := make([]model.ScenarioField, 0)
|
||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", scenario.ID, scenario.TenantID).Order("sort_order, id").Find(&fields).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景契约失败")
|
||||
@@ -96,7 +91,7 @@ func (h *Handler) PublicStart(c *gin.Context) {
|
||||
if externalRef == "" {
|
||||
externalRef = "run-" + uuid.NewString()
|
||||
}
|
||||
run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, SOPVersionID: version.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), StartedAt: time.Now()}
|
||||
run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), StartedAt: time.Now()}
|
||||
token := uuid.NewString() + uuid.NewString()
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&run).Error; err != nil {
|
||||
@@ -162,7 +157,7 @@ func (h *Handler) PublicSubmit(c *gin.Context) {
|
||||
return errPublicNodeChanged
|
||||
}
|
||||
var node model.SOPNode
|
||||
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
fields := make([]model.ScenarioField, 0)
|
||||
@@ -212,7 +207,7 @@ func (h *Handler) PublicNext(c *gin.Context) {
|
||||
return errPublicRunCompleted
|
||||
}
|
||||
var node model.SOPNode
|
||||
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
|
||||
@@ -305,11 +300,11 @@ func (h *Handler) PublicReset(c *gin.Context) {
|
||||
if err := lockPublicRun(tx, session, &run); err != nil {
|
||||
return err
|
||||
}
|
||||
var version model.SOPVersion
|
||||
if err := tx.Where("id = ? AND tenant_id = ?", run.SOPVersionID, run.TenantID).First(&version).Error; err != nil {
|
||||
var sop model.SOP
|
||||
if err := tx.Where("id = ? AND tenant_id = ?", run.SOPID, run.TenantID).First(&sop).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
run.CurrentNodeKey, run.Status, run.Result, run.FinalResult, run.CompletedAt, run.Answers = version.StartNodeKey, "running", "", nil, nil, run.Input
|
||||
run.CurrentNodeKey, run.Status, run.Result, run.FinalResult, run.CompletedAt, run.Answers = sop.StartNodeKey, "running", "", nil, nil, run.Input
|
||||
if err := tx.Model(&run).Updates(map[string]interface{}{"current_node_key": run.CurrentNodeKey, "status": run.Status, "result": run.Result, "final_result": nil, "completed_at": nil, "answers": run.Input, "outputs": datatypes.JSON([]byte(`[]`))}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -327,7 +322,7 @@ func (h *Handler) PublicReset(c *gin.Context) {
|
||||
|
||||
func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error {
|
||||
var edges []model.SOPEdge
|
||||
if err := tx.Where("sop_version_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).Order("priority,id").Find(&edges).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).Order("priority,id").Find(&edges).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
answers := map[string]interface{}{}
|
||||
@@ -353,7 +348,7 @@ func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error {
|
||||
return errors.New("没有满足条件的下一节点")
|
||||
}
|
||||
var next model.SOPNode
|
||||
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, nextKey, run.TenantID).First(&next).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, nextKey, run.TenantID).First(&next).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{"current_node_key": nextKey}
|
||||
@@ -398,7 +393,7 @@ func (h *Handler) respondPublicMutationError(c *gin.Context, err error, fallback
|
||||
|
||||
func (h *Handler) respondPublicRun(c *gin.Context, run model.SOPRun, token string) {
|
||||
var node model.SOPNode
|
||||
if err := h.db.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
|
||||
if err := h.db.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,8 +56,7 @@ func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, con
|
||||
if len(keys) > 0 {
|
||||
if err := db.Table("scenario_fields sf").Select("sf.*").
|
||||
Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").
|
||||
Joins("JOIN sop_versions sv ON sv.sop_id = s.id").
|
||||
Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).
|
||||
Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).
|
||||
Find(&fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func (h *Handler) fieldReferencedByReleasedSOP(scenarioID uint64, fieldKey string, tenantID uint64) bool {
|
||||
statuses := []string{"published", "offline", "superseded"}
|
||||
var nodes []model.SOPNode
|
||||
err := h.db.Table("sop_nodes n").Select("n.*").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where("s.scenario_id = ? AND n.tenant_id = ? AND sv.status IN ?", scenarioID, tenantID, statuses).Scan(&nodes).Error
|
||||
err := h.db.Table("sop_nodes n").Select("n.*").Joins("JOIN sops s ON s.id = n.sop_id").Where("s.scenario_id = ? AND n.tenant_id = ? AND s.status IN ?", scenarioID, tenantID, statuses).Scan(&nodes).Error
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func (h *Handler) fieldReferencedByReleasedSOP(scenarioID uint64, fieldKey strin
|
||||
}
|
||||
}
|
||||
var edges []model.SOPEdge
|
||||
err = h.db.Table("sop_edges e").Select("e.*").Joins("JOIN sop_versions sv ON sv.id = e.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where("s.scenario_id = ? AND e.tenant_id = ? AND sv.status IN ?", scenarioID, tenantID, statuses).Scan(&edges).Error
|
||||
err = h.db.Table("sop_edges e").Select("e.*").Joins("JOIN sops s ON s.id = e.sop_id").Where("s.scenario_id = ? AND e.tenant_id = ? AND s.status IN ?", scenarioID, tenantID, statuses).Scan(&edges).Error
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -96,27 +96,22 @@ func (h *Handler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var item model.SOP
|
||||
var version model.SOPVersion
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "published", CreatedBy: p.UserID}
|
||||
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "published", CreatedBy: p.UserID, StartNodeKey: "start"}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "published", StartNodeKey: "start", CreatedBy: p.UserID}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
nodes := []model.SOPNode{
|
||||
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "start", Type: "start", Title: "开始", Content: "", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 0},
|
||||
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "opening", Type: "message", Title: "开场", Content: "您好,我先了解一下具体情况。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 120},
|
||||
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "finish", Type: "finish", Title: "结束", Content: "本次沟通已完成。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 240},
|
||||
{TenantID: p.TenantID, SOPID: item.ID, NodeKey: "start", Type: "start", Title: "开始", Content: "", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 0},
|
||||
{TenantID: p.TenantID, SOPID: item.ID, NodeKey: "opening", Type: "message", Title: "开场", Content: "您好,我先了解一下具体情况。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 120},
|
||||
{TenantID: p.TenantID, SOPID: item.ID, NodeKey: "finish", Type: "finish", Title: "结束", Content: "本次沟通已完成。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 240},
|
||||
}
|
||||
if err := tx.Create(&nodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
edges := []model.SOPEdge{
|
||||
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||
{TenantID: p.TenantID, SOPID: item.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||
{TenantID: p.TenantID, SOPID: item.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||
}
|
||||
if err := tx.Create(&edges).Error; err != nil {
|
||||
return err
|
||||
@@ -128,7 +123,7 @@ func (h *Handler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "create", "sop", item.ID, input)
|
||||
response.Created(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}})
|
||||
response.Created(c, gin.H{"sop": item, "edit": editState{StartNodeKey: item.StartNodeKey}})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
@@ -142,10 +137,9 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var item model.SOP
|
||||
var version model.SOPVersion
|
||||
var nodes []model.SOPNode
|
||||
var edges []model.SOPEdge
|
||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||
item, nodes, edges, err := h.load(id, p.TenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
@@ -154,7 +148,7 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}, "nodes": nodes, "edges": edges})
|
||||
response.OK(c, gin.H{"sop": item, "edit": editState{StartNodeKey: item.StartNodeKey}, "nodes": nodes, "edges": edges})
|
||||
}
|
||||
|
||||
func (h *Handler) SaveGraph(c *gin.Context) {
|
||||
@@ -172,12 +166,12 @@ func (h *Handler) SaveGraph(c *gin.Context) {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
|
||||
return
|
||||
}
|
||||
item, version, _, _, err := h.loadLatest(id, p.TenantID)
|
||||
item, _, _, err := h.load(id, p.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
nodes, edges := toModels(p.TenantID, version.ID, input)
|
||||
nodes, edges := toModels(p.TenantID, id, input)
|
||||
problems, validationErr := h.validateForPublish(item, input.StartNodeKey, nodes, edges, p.TenantID)
|
||||
if validationErr != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
|
||||
@@ -188,10 +182,10 @@ func (h *Handler) SaveGraph(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPNode{}).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.SOPNode{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&nodes).Error; err != nil {
|
||||
@@ -202,10 +196,7 @@ func (h *Handler) SaveGraph(c *gin.Context) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND id <> ?", id, p.TenantID, version.ID).Update("status", "superseded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&version).Updates(map[string]interface{}{"start_node_key": input.StartNodeKey, "status": "published"}).Error; err != nil {
|
||||
if err := tx.Model(&item).Updates(map[string]interface{}{"start_node_key": input.StartNodeKey, "status": "published"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
|
||||
@@ -232,15 +223,14 @@ func (h *Handler) Validate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var item model.SOP
|
||||
var version model.SOPVersion
|
||||
var nodes []model.SOPNode
|
||||
var edges []model.SOPEdge
|
||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||
item, nodes, edges, err := h.load(id, p.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
problems, err := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
|
||||
problems, err := h.validateForPublish(item, item.StartNodeKey, nodes, edges, p.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
|
||||
return
|
||||
@@ -248,24 +238,20 @@ func (h *Handler) Validate(c *gin.Context) {
|
||||
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
|
||||
}
|
||||
|
||||
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
|
||||
func (h *Handler) load(sopID, tenantID uint64) (model.SOP, []model.SOPNode, []model.SOPEdge, error) {
|
||||
var item model.SOP
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
|
||||
return item, model.SOPVersion{}, nil, nil, err
|
||||
}
|
||||
var version model.SOPVersion
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ?", sopID, tenantID).Order("version DESC").First(&version).Error; err != nil {
|
||||
return item, version, nil, nil, err
|
||||
return item, nil, nil, err
|
||||
}
|
||||
nodes := make([]model.SOPNode, 0)
|
||||
edges := make([]model.SOPEdge, 0)
|
||||
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
|
||||
return item, version, nil, nil, err
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ?", sopID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
|
||||
return item, nil, nil, err
|
||||
}
|
||||
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
return item, version, nil, nil, err
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ?", sopID, tenantID).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
return item, nil, nil, err
|
||||
}
|
||||
return item, version, nodes, edges, nil
|
||||
return item, nodes, edges, nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
|
||||
@@ -284,14 +270,14 @@ func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes
|
||||
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, KnowledgeItems: knowledgeItems, KnowledgeRelations: knowledgeRelations}), nil
|
||||
}
|
||||
|
||||
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
|
||||
func toModels(tenantID, sopID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
|
||||
nodes := make([]model.SOPNode, 0, len(input.Nodes))
|
||||
for _, item := range input.Nodes {
|
||||
config := item.Config
|
||||
if len(config) == 0 || !json.Valid(config) {
|
||||
config = json.RawMessage(`{}`)
|
||||
}
|
||||
nodes = append(nodes, model.SOPNode{TenantID: tenantID, SOPVersionID: versionID, NodeKey: item.NodeKey, Type: item.Type, Title: item.Title, Content: item.Content, Config: datatypes.JSON(config), PositionX: item.PositionX, PositionY: item.PositionY})
|
||||
nodes = append(nodes, model.SOPNode{TenantID: tenantID, SOPID: sopID, NodeKey: item.NodeKey, Type: item.Type, Title: item.Title, Content: item.Content, Config: datatypes.JSON(config), PositionX: item.PositionX, PositionY: item.PositionY})
|
||||
}
|
||||
edges := make([]model.SOPEdge, 0, len(input.Edges))
|
||||
for _, item := range input.Edges {
|
||||
@@ -299,7 +285,7 @@ func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []
|
||||
if len(condition) == 0 || !json.Valid(condition) {
|
||||
condition = json.RawMessage(`{}`)
|
||||
}
|
||||
edges = append(edges, model.SOPEdge{TenantID: tenantID, SOPVersionID: versionID, SourceNodeKey: item.SourceNodeKey, TargetNodeKey: item.TargetNodeKey, Condition: datatypes.JSON(condition), Priority: item.Priority})
|
||||
edges = append(edges, model.SOPEdge{TenantID: tenantID, SOPID: sopID, SourceNodeKey: item.SourceNodeKey, TargetNodeKey: item.TargetNodeKey, Condition: datatypes.JSON(condition), Priority: item.Priority})
|
||||
}
|
||||
return nodes, edges
|
||||
}
|
||||
|
||||
127
migrations/20260819_drop_sop_versions.sql
Normal file
127
migrations/20260819_drop_sop_versions.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- Destructive cleanup for the SOP single-version migration.
|
||||
-- Run only after the backfill script completed and all sop_id values are set.
|
||||
-- The statements discover constraint/index names because deployments may use
|
||||
-- different GORM naming conventions.
|
||||
|
||||
-- The application does not use database foreign keys. Remove every existing
|
||||
-- foreign key in the database first so no historical constraint remains and
|
||||
-- no constraint blocks the old SOP column cleanup.
|
||||
DROP PROCEDURE IF EXISTS drop_all_iqudo_foreign_keys;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE drop_all_iqudo_foreign_keys()
|
||||
BEGIN
|
||||
DECLARE finished INT DEFAULT 0;
|
||||
DECLARE table_name_value VARCHAR(64);
|
||||
DECLARE constraint_name_value VARCHAR(64);
|
||||
DECLARE foreign_keys CURSOR FOR
|
||||
SELECT TABLE_NAME, CONSTRAINT_NAME
|
||||
FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_TYPE = 'FOREIGN KEY';
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET finished = 1;
|
||||
|
||||
OPEN foreign_keys;
|
||||
drop_loop: LOOP
|
||||
FETCH foreign_keys INTO table_name_value, constraint_name_value;
|
||||
IF finished = 1 THEN
|
||||
LEAVE drop_loop;
|
||||
END IF;
|
||||
SET @sql = CONCAT('ALTER TABLE `', table_name_value, '` DROP FOREIGN KEY `', constraint_name_value, '`');
|
||||
PREPARE stmt_all_fk FROM @sql;
|
||||
EXECUTE stmt_all_fk;
|
||||
DEALLOCATE PREPARE stmt_all_fk;
|
||||
END LOOP;
|
||||
CLOSE foreign_keys;
|
||||
END//
|
||||
DELIMITER ;
|
||||
CALL drop_all_iqudo_foreign_keys();
|
||||
DROP PROCEDURE drop_all_iqudo_foreign_keys;
|
||||
|
||||
-- The following blocks are retained as idempotent guards for databases where
|
||||
-- a concurrent/manual migration changed the schema between the procedure and
|
||||
-- the SOP cleanup statements.
|
||||
-- Remove foreign keys on the old sop_version_id columns, if present.
|
||||
SET @fk = (
|
||||
SELECT kcu.CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE kcu
|
||||
WHERE kcu.CONSTRAINT_SCHEMA = DATABASE() AND kcu.TABLE_NAME = 'sop_nodes'
|
||||
AND kcu.COLUMN_NAME = 'sop_version_id' AND kcu.REFERENCED_TABLE_NAME = 'sop_versions' LIMIT 1
|
||||
);
|
||||
SET @sql = IF(@fk IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sop_nodes DROP FOREIGN KEY `', @fk, '`'));
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @fk = (
|
||||
SELECT kcu.CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE kcu
|
||||
WHERE kcu.CONSTRAINT_SCHEMA = DATABASE() AND kcu.TABLE_NAME = 'sop_edges'
|
||||
AND kcu.COLUMN_NAME = 'sop_version_id' AND kcu.REFERENCED_TABLE_NAME = 'sop_versions' LIMIT 1
|
||||
);
|
||||
SET @sql = IF(@fk IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sop_edges DROP FOREIGN KEY `', @fk, '`'));
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @fk = (
|
||||
SELECT kcu.CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE kcu
|
||||
WHERE kcu.CONSTRAINT_SCHEMA = DATABASE() AND kcu.TABLE_NAME = 'sop_runs'
|
||||
AND kcu.COLUMN_NAME = 'sop_version_id' AND kcu.REFERENCED_TABLE_NAME = 'sop_versions' LIMIT 1
|
||||
);
|
||||
SET @sql = IF(@fk IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sop_runs DROP FOREIGN KEY `', @fk, '`'));
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Drop indexes containing the old column, regardless of their names.
|
||||
SET @indexes = (
|
||||
SELECT GROUP_CONCAT(DISTINCT CONCAT('DROP INDEX `', INDEX_NAME, '`') SEPARATOR ', ')
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sop_nodes'
|
||||
AND COLUMN_NAME = 'sop_version_id' AND INDEX_NAME <> 'PRIMARY'
|
||||
);
|
||||
SET @sql = IF(@indexes IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sop_nodes ', @indexes));
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @indexes = (
|
||||
SELECT GROUP_CONCAT(DISTINCT CONCAT('DROP INDEX `', INDEX_NAME, '`') SEPARATOR ', ')
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sop_edges'
|
||||
AND COLUMN_NAME = 'sop_version_id' AND INDEX_NAME <> 'PRIMARY'
|
||||
);
|
||||
SET @sql = IF(@indexes IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sop_edges ', @indexes));
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @indexes = (
|
||||
SELECT GROUP_CONCAT(DISTINCT CONCAT('DROP INDEX `', INDEX_NAME, '`') SEPARATOR ', ')
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sop_runs'
|
||||
AND COLUMN_NAME = 'sop_version_id' AND INDEX_NAME <> 'PRIMARY'
|
||||
);
|
||||
SET @sql = IF(@indexes IS NULL, 'SELECT 1', CONCAT('ALTER TABLE sop_runs ', @indexes));
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Add replacement indexes once.
|
||||
SET @sql = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sop_nodes' AND INDEX_NAME = 'uk_sop_nodes_key'),
|
||||
'SELECT 1', 'ALTER TABLE sop_nodes ADD UNIQUE KEY uk_sop_nodes_key (sop_id, node_key)'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sop_edges' AND INDEX_NAME = 'idx_sop_edges_source'),
|
||||
'SELECT 1', 'ALTER TABLE sop_edges ADD INDEX idx_sop_edges_source (sop_id, source_node_key, priority)'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Drop old columns only when they still exist.
|
||||
SET @sql = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sop_nodes' AND COLUMN_NAME = 'sop_version_id'),
|
||||
'ALTER TABLE sop_nodes DROP COLUMN sop_version_id', 'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sop_edges' AND COLUMN_NAME = 'sop_version_id'),
|
||||
'ALTER TABLE sop_edges DROP COLUMN sop_version_id', 'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = IF(
|
||||
EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sop_runs' AND COLUMN_NAME = 'sop_version_id'),
|
||||
'ALTER TABLE sop_runs DROP COLUMN sop_version_id', 'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
DROP TABLE IF EXISTS sop_versions;
|
||||
134
migrations/20260819_remove_sop_versions.sql
Normal file
134
migrations/20260819_remove_sop_versions.sql
Normal file
@@ -0,0 +1,134 @@
|
||||
-- Run this once on an existing MySQL database before deploying the
|
||||
-- single-version SOP application. It is intentionally explicit: the Go
|
||||
-- service only performs the data-preserving backfill and does not drop data.
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE sops ADD COLUMN start_node_key VARCHAR(64) NOT NULL DEFAULT ''start''',
|
||||
'SELECT 1')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'sops' AND column_name = 'start_node_key'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE sops s
|
||||
JOIN (
|
||||
SELECT sop_id, MAX(id) AS version_id
|
||||
FROM sop_versions
|
||||
WHERE status = 'published'
|
||||
GROUP BY sop_id
|
||||
) current ON current.sop_id = s.id
|
||||
JOIN sop_versions sv ON sv.id = current.version_id
|
||||
SET s.start_node_key = COALESCE(NULLIF(sv.start_node_key, ''), NULLIF(s.start_node_key, ''), 'start');
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE sop_nodes ADD COLUMN sop_id BIGINT UNSIGNED NULL',
|
||||
'SELECT 1')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'sop_nodes' AND column_name = 'sop_id'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE sop_edges ADD COLUMN sop_id BIGINT UNSIGNED NULL',
|
||||
'SELECT 1')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'sop_edges' AND column_name = 'sop_id'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE sop_runs ADD COLUMN sop_id BIGINT UNSIGNED NULL',
|
||||
'SELECT 1')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'sop_runs' AND column_name = 'sop_id'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE sop_nodes n JOIN sop_versions sv ON sv.id = n.sop_version_id
|
||||
SET n.sop_id = sv.sop_id WHERE n.sop_id IS NULL;
|
||||
UPDATE sop_edges e JOIN sop_versions sv ON sv.id = e.sop_version_id
|
||||
SET e.sop_id = sv.sop_id WHERE e.sop_id IS NULL;
|
||||
UPDATE sop_runs r JOIN sop_versions sv ON sv.id = r.sop_version_id
|
||||
SET r.sop_id = sv.sop_id WHERE r.sop_id IS NULL;
|
||||
|
||||
-- A single-version SOP can only have one graph. Keep the latest published
|
||||
-- version's nodes and edges; remove historical graph rows after the backup.
|
||||
DELETE n
|
||||
FROM sop_nodes n
|
||||
JOIN sop_versions sv ON sv.id = n.sop_version_id
|
||||
JOIN (
|
||||
SELECT sop_id, MAX(id) AS version_id
|
||||
FROM sop_versions
|
||||
WHERE status = 'published'
|
||||
GROUP BY sop_id
|
||||
) current ON current.sop_id = sv.sop_id
|
||||
WHERE sv.id <> current.version_id;
|
||||
|
||||
DELETE e
|
||||
FROM sop_edges e
|
||||
JOIN sop_versions sv ON sv.id = e.sop_version_id
|
||||
JOIN (
|
||||
SELECT sop_id, MAX(id) AS version_id
|
||||
FROM sop_versions
|
||||
WHERE status = 'published'
|
||||
GROUP BY sop_id
|
||||
) current ON current.sop_id = sv.sop_id
|
||||
WHERE sv.id <> current.version_id;
|
||||
|
||||
ALTER TABLE sop_nodes MODIFY COLUMN sop_id BIGINT UNSIGNED NOT NULL;
|
||||
ALTER TABLE sop_edges MODIFY COLUMN sop_id BIGINT UNSIGNED NOT NULL;
|
||||
ALTER TABLE sop_runs MODIFY COLUMN sop_id BIGINT UNSIGNED NOT NULL;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE sop_nodes ADD INDEX idx_sop_nodes_sop_id (sop_id)',
|
||||
'SELECT 1')
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'sop_nodes' AND index_name = 'idx_sop_nodes_sop_id'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE sop_edges ADD INDEX idx_sop_edges_sop_id (sop_id)',
|
||||
'SELECT 1')
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'sop_edges' AND index_name = 'idx_sop_edges_sop_id'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE sop_runs ADD INDEX idx_sop_runs_sop_id (sop_id)',
|
||||
'SELECT 1')
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'sop_runs' AND index_name = 'idx_sop_runs_sop_id'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Verify the backfill before the destructive cleanup below.
|
||||
SELECT COUNT(*) AS nodes_without_sop FROM sop_nodes WHERE sop_id IS NULL;
|
||||
SELECT COUNT(*) AS edges_without_sop FROM sop_edges WHERE sop_id IS NULL;
|
||||
SELECT COUNT(*) AS runs_without_sop FROM sop_runs WHERE sop_id IS NULL;
|
||||
|
||||
-- After the three counts above are all zero, remove the old version fields.
|
||||
-- The old columns/table are intentionally retained by this backfill step.
|
||||
-- Run 20260819_drop_sop_versions.sql only after all three counts above are 0.
|
||||
4
migrations/20260820_drop_legacy_knowledge_cards.sql
Normal file
4
migrations/20260820_drop_legacy_knowledge_cards.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- The scenario knowledge graph uses knowledge_items and knowledge_relations.
|
||||
-- These legacy knowledge-card tables are no longer used by the application.
|
||||
DROP TABLE IF EXISTS knowledge_card_versions;
|
||||
DROP TABLE IF EXISTS knowledge_cards;
|
||||
@@ -293,45 +293,33 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
|
||||
if err := tx.Model(&sop).Updates(map[string]interface{}{
|
||||
"description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。",
|
||||
"status": "published",
|
||||
"start_node_key": "start",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var version model.SOPVersion
|
||||
err = tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Order("version DESC").First(&version).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
version = model.SOPVersion{TenantID: tenantID, SOPID: sop.ID, Version: 1, Status: "published", StartNodeKey: "start", CreatedBy: userID}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
var alreadySeeded int64
|
||||
if err := tx.Model(&model.SOPNode{}).Where(
|
||||
"sop_version_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?",
|
||||
version.ID, "start", "pet-doctor-v16-start-presentation-optional-pet",
|
||||
"sop_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?",
|
||||
sop.ID, "start", "pet-doctor-v16-start-presentation-optional-pet",
|
||||
).Count(&alreadySeeded).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if alreadySeeded > 0 {
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ?", sop.ID, version.ID).Update("status", "superseded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&version).Updates(map[string]interface{}{"status": "published", "start_node_key": "start"}).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
nodes := petNodes()
|
||||
edges := petEdges()
|
||||
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPNode{}).Error; err != nil {
|
||||
if err := tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Delete(&model.SOPNode{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for index, definition := range nodes {
|
||||
configJSON, _ := json.Marshal(definition.Config)
|
||||
node := model.SOPNode{
|
||||
TenantID: tenantID, SOPVersionID: version.ID, NodeKey: definition.Key, Type: definition.Type,
|
||||
TenantID: tenantID, SOPID: sop.ID, NodeKey: definition.Key, Type: definition.Type,
|
||||
Title: definition.Title, Content: definition.Content, Config: datatypes.JSON(configJSON), PositionX: 0, PositionY: index * 120,
|
||||
}
|
||||
if err := tx.Create(&node).Error; err != nil {
|
||||
@@ -341,21 +329,13 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
|
||||
for _, definition := range edges {
|
||||
conditionJSON, _ := json.Marshal(definition.Condition)
|
||||
edge := model.SOPEdge{
|
||||
TenantID: tenantID, SOPVersionID: version.ID, SourceNodeKey: definition.Source, TargetNodeKey: definition.Target,
|
||||
TenantID: tenantID, SOPID: sop.ID, SourceNodeKey: definition.Source, TargetNodeKey: definition.Target,
|
||||
Condition: datatypes.JSON(conditionJSON), Priority: definition.Priority,
|
||||
}
|
||||
if err := tx.Create(&edge).Error; err != nil {
|
||||
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ?", sop.ID, version.ID).Update("status", "superseded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&version).Updates(map[string]interface{}{
|
||||
"status": "published", "start_node_key": "start",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药"})
|
||||
return tx.Create(&model.AuditLog{
|
||||
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),
|
||||
|
||||
1
web/dist/assets/ExecuteView-B07inVPa.css
vendored
Normal file
1
web/dist/assets/ExecuteView-B07inVPa.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView-CAUSQPH9.js
vendored
1
web/dist/assets/ExecuteView-CAUSQPH9.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView-D6wWGKCU.css
vendored
1
web/dist/assets/ExecuteView-D6wWGKCU.css
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView-DJveuZjr.js
vendored
Normal file
1
web/dist/assets/ExecuteView-DJveuZjr.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/RunDetailView-BFxue3EE.js
vendored
1
web/dist/assets/RunDetailView-BFxue3EE.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/RunDetailView-D7a859PI.css
vendored
1
web/dist/assets/RunDetailView-D7a859PI.css
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/RunDetailView-D7ydL_xd.css
vendored
Normal file
1
web/dist/assets/RunDetailView-D7ydL_xd.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/RunDetailView-q8rVi8Ga.js
vendored
Normal file
1
web/dist/assets/RunDetailView-q8rVi8Ga.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
web/dist/index.html
vendored
2
web/dist/index.html
vendored
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#202825" />
|
||||
<title>销冠 SOP 平台</title>
|
||||
<script type="module" crossorigin src="/assets/index-Cu-5ZSv-.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B4FSNTVt.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/client-Bk7n1O1W.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/config-provider-B-t02-Z0.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/auth-D6Tj4g1X.js">
|
||||
|
||||
@@ -2,9 +2,7 @@ export interface BaseEntity { id: number; created_at: string; updated_at: string
|
||||
export interface User { user_id: number; tenant_id: number; role_code: string; permissions: string[]; username: string; display_name: string }
|
||||
export interface Scenario extends BaseEntity { tenant_id: number; scenario_key:string; public_key:string; allowed_origins:string[]; name: string; industry: string; role_name: string; goal: string; trigger_text: string; visibility: string; status: string; created_by: number; input_schema:Record<string,unknown>; output_schema:Record<string,unknown>; result_schema:Record<string,unknown> }
|
||||
export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; source_path:string; field_type: string; required: boolean; options: unknown[]; validation: Record<string, unknown>; sort_order: number }
|
||||
export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string }
|
||||
export interface KnowledgeCopy { id:string; title:string; content:string }
|
||||
export interface KnowledgeSnapshot { card_id:number; card_version_id:number; version:number; title:string; standard_copy:string; forbidden_copy:string; risk_note:string; copies:KnowledgeCopy[] }
|
||||
export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string; start_node_key?: string }
|
||||
export interface KnowledgeItemView { key:string; name:string; type:string; content:Record<string,any> }
|
||||
export interface KnowledgeGroup extends KnowledgeItemView { relations:Record<string,KnowledgeItemView[]>; suggested?:boolean }
|
||||
export interface PublicFieldView { key:string; name:string; type:string; required:boolean; options:string[]; validation:Record<string,unknown> }
|
||||
@@ -13,10 +11,10 @@ export interface KnowledgeCollectionStep { field_key:string; name:string; requir
|
||||
export interface KnowledgeCollection { context_fields:PublicFieldView[]; context_title?:string; context_hint?:string; selection_title?:string; selection_hint?:string; steps:KnowledgeCollectionStep[] }
|
||||
export interface PresentationField { key:string; label:string; kind:'text'|'image'; value:unknown }
|
||||
export interface NodePresentation { summary:PresentationField[]; items:Array<{fields:PresentationField[]}>; opening?:{title:string; content:string} }
|
||||
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number; presentation?:NodePresentation; knowledge?:KnowledgeSnapshot; outputs?:KnowledgeGroup[]; collection?:KnowledgeCollection }
|
||||
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number; presentation?:NodePresentation; outputs?:KnowledgeGroup[]; collection?:KnowledgeCollection }
|
||||
export interface SOPEdge extends BaseEntity { source_node_key: string; target_node_key: string; condition: Record<string, any>; priority: number }
|
||||
export interface SOPRun extends BaseEntity { sop_id: number; sop_version_id: number; operator_id:number; external_ref?:string; current_node_key: string; status: string; answers: Record<string, any>; input:Record<string,any>; derived:Record<string,any>; outputs:unknown[]; result: string; final_result?:Record<string,unknown>|null; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; operator_name?:string }
|
||||
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string; knowledge?:KnowledgeSnapshot; outputs?:KnowledgeGroup[] }
|
||||
export interface SOPRun extends BaseEntity { sop_id: number; operator_id:number; external_ref?:string; current_node_key: string; status: string; answers: Record<string, any>; input:Record<string,any>; derived:Record<string,any>; outputs:unknown[]; result: string; final_result?:Record<string,unknown>|null; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; operator_name?:string }
|
||||
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string; outputs?:KnowledgeGroup[] }
|
||||
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
|
||||
export interface RunDetail extends SOPRun { scenario_name:string; operator_name:string }
|
||||
export interface Role extends BaseEntity { name:string; code:string; permissions:string[] }
|
||||
|
||||
@@ -125,13 +125,6 @@ onMounted(load)
|
||||
<p>{{ startPresentation.opening.content }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="active.node.knowledge" class="knowledge-snapshot">
|
||||
<div class="knowledge-head"><b>{{ active.node.knowledge.title }}</b><span>历史知识</span></div>
|
||||
<div v-for="copy in active.node.knowledge.copies" :key="copy.id" class="standard-copy"><small>{{ copy.title }}</small><p>{{ copy.content }}</p></div>
|
||||
<div v-if="!active.node.knowledge.copies?.length" class="standard-copy"><small>标准话术</small><p>{{ active.node.knowledge.standard_copy }}</p></div>
|
||||
<div v-if="active.node.knowledge.forbidden_copy" class="knowledge-note forbidden"><small>禁用表达</small><p>{{ active.node.knowledge.forbidden_copy }}</p></div>
|
||||
<div v-if="active.node.knowledge.risk_note" class="knowledge-note risk"><small>风险提醒</small><p>{{ active.node.knowledge.risk_note }}</p></div>
|
||||
</section>
|
||||
<section v-if="visibleKnowledgeGroups.length" class="knowledge-groups">
|
||||
<article v-for="group in visibleKnowledgeGroups" :key="group.key" class="knowledge-group">
|
||||
<div class="knowledge-head"><a-checkbox v-if="active.node.collection&&collectionStep(0)" :checked="isSelected(collectionStep(0).field_key,group.name)" @change="toggleKnowledge(collectionStep(0),group.name,$event.target.checked)"><b>{{ group.name }}</b></a-checkbox><b v-else>{{ group.name }}</b><span>{{ group.suggested?'系统推断':group.type }}</span></div>
|
||||
@@ -180,7 +173,7 @@ onMounted(load)
|
||||
|
||||
<style scoped>
|
||||
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.run-actions>div{display:flex;align-items:center;gap:9px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px}
|
||||
.knowledge-snapshot{margin:0 0 24px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.knowledge-head{display:flex;align-items:center;justify-content:space-between;padding:12px 0}.knowledge-head span{color:var(--muted);font-size:11px}.standard-copy,.knowledge-note{padding:14px 16px;border-left:3px solid var(--green);background:#f2f7f5}.standard-copy small,.knowledge-note small{font-size:10px;font-weight:750}.standard-copy p,.knowledge-note p{margin:6px 0 0;line-height:1.7}.knowledge-note{margin-top:8px;border-left-color:#c4573f;background:#fbf3f0}.knowledge-note.risk{border-left-color:#bd7b24;background:#fbf6eb}
|
||||
.knowledge-head{display:flex;align-items:center;justify-content:space-between;padding:12px 0}.knowledge-head span{color:var(--muted);font-size:11px}.standard-copy{padding:14px 16px;border-left:3px solid var(--green);background:#f2f7f5}.standard-copy small{font-size:10px;font-weight:750}.standard-copy p{margin:6px 0 0;line-height:1.7}
|
||||
.knowledge-groups{display:grid;gap:14px;margin-bottom:24px}.knowledge-group{padding:0 16px 16px;border:1px solid var(--line);border-left:3px solid var(--green);background:#fff}.relation-block{margin-top:10px}.relation-block>small{display:block;margin-bottom:6px;color:var(--muted);font-weight:700}.relation-block .standard-copy{margin-top:6px}.relation-block .standard-copy b{font-size:12px}
|
||||
.start-presentation{display:grid;gap:22px;margin-bottom:28px}.start-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.start-summary>div{min-width:0;display:flex;flex-direction:column;gap:5px;padding:14px 16px}.start-summary>div:nth-child(odd){border-right:1px solid var(--line)}.start-summary small,.order-product small{color:var(--muted);font-size:10px}.start-summary b,.order-product b{overflow-wrap:anywhere;font-size:13px}.presentation-heading{min-height:36px;display:flex;align-items:center;justify-content:space-between;gap:12px}.presentation-heading>span{color:var(--muted);font-size:11px}.order-products{display:grid;gap:8px}.order-product{min-height:82px;display:grid;grid-template-columns:72px minmax(0,1fr);gap:14px;align-items:center;padding:10px 0;border-bottom:1px solid var(--line)}.order-product.no-image{grid-template-columns:1fr}.order-product img{width:72px;height:72px;object-fit:cover;border:1px solid var(--line);border-radius:4px;background:#f4f6f5}.order-product>div{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(100px,1fr);gap:12px}.order-product span{min-width:0;display:flex;flex-direction:column;gap:5px}.opening-copy{padding:16px 18px;border-left:3px solid var(--green);background:#edf6f2}.opening-copy .presentation-heading{min-height:28px}.opening-copy p{margin:9px 0 0;color:#21302a;font-size:16px;line-height:1.8}.opening-copy :deep(.ant-btn){color:var(--green)}
|
||||
.collection-heading{display:flex;justify-content:space-between;gap:16px;margin:26px 0 16px;padding-bottom:10px;border-bottom:1px solid var(--line)}.collection-heading span{color:var(--muted);font-size:11px}
|
||||
|
||||
@@ -39,7 +39,6 @@ onMounted(load)
|
||||
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='finish'||event.node_type==='escalate'?'red':event.action==='answer'?'blue':'green'">
|
||||
<div class="event-head"><b>{{ event.node_title||event.node_key }}</b><a-tag>{{ actionText(event.action) }}</a-tag><time>{{ new Date(event.created_at).toLocaleString('zh-CN') }}</time></div>
|
||||
<p v-if="event.action==='enter'&&event.node_content">{{ event.node_content }}</p>
|
||||
<div v-if="event.action==='enter'&&event.knowledge" class="event-knowledge"><div><b>{{ event.knowledge.title }}</b><small>历史知识</small></div><p>{{ event.knowledge.standard_copy }}</p><p v-if="event.knowledge.risk_note" class="risk-note">{{ event.knowledge.risk_note }}</p></div>
|
||||
<div v-for="group in event.outputs||[]" :key="group.key" class="event-knowledge"><div><b>{{ group.name }}</b><small>{{ group.type }}</small></div><div v-for="(items,relation) in group.relations" :key="relation"><p><b>{{ relation }}</b></p><p v-for="item in items" :key="item.key">{{ item.content.template || item.content.standard_copy || item.name }}</p></div></div>
|
||||
<div v-if="answerEntries(event).length" class="event-answers"><span v-for="[key,value] in answerEntries(event)" :key="key"><small>{{ fieldMap.get(key)||key }}</small><b>{{ valueText(value) }}</b></span></div>
|
||||
</a-timeline-item>
|
||||
|
||||
Reference in New Issue
Block a user