fix: remove legacy knowledge card tables and compatibility code

This commit is contained in:
Eric 1549169735@qq.com
2026-08-20 21:58:04 +08:00
parent ebaf9b0b05
commit 6185e6cd49
36 changed files with 545 additions and 546 deletions

View File

@@ -36,11 +36,10 @@ multitable:
scenarios: 0 scenarios: 0
scenario_fields: 0 scenario_fields: 0
scenario_rules: 0 scenario_rules: 0
sop_versions: 0 sops: 0
sop_nodes: 0 sop_nodes: 0
sop_edges: 0 sop_edges: 0
knowledge_items: 0 knowledge_items: 0
knowledge_relations: 0 knowledge_relations: 0
knowledge_versions: 0
runs: 0 runs: 0
feedback: 0 feedback: 0

View File

@@ -30,11 +30,10 @@ multitable:
scenarios: 66 scenarios: 66
scenario_fields: 67 scenario_fields: 67
scenario_rules: 68 scenario_rules: 68
sop_versions: 69 sops: 69
sop_nodes: 70 sop_nodes: 70
sop_edges: 71 sop_edges: 71
knowledge_items: 72 knowledge_items: 72
knowledge_relations: 73 knowledge_relations: 73
knowledge_versions: 74
runs: 75 runs: 75
feedback: 76 feedback: 76

View File

@@ -36,11 +36,10 @@ multitable:
scenarios: 46 scenarios: 46
scenario_fields: 47 scenario_fields: 47
scenario_rules: 63 scenario_rules: 63
sop_versions: 48 sops: 48
sop_nodes: 49 sop_nodes: 49
sop_edges: 50 sop_edges: 50
knowledge_items: 64 knowledge_items: 64
knowledge_relations: 65 knowledge_relations: 65
knowledge_versions: 51
runs: 52 runs: 52
feedback: 53 feedback: 53

View File

@@ -79,13 +79,11 @@ type MultiTableTables struct {
Scenarios uint64 `yaml:"scenarios" env:"APP_MULTITABLE_TABLES_SCENARIOS"` Scenarios uint64 `yaml:"scenarios" env:"APP_MULTITABLE_TABLES_SCENARIOS"`
ScenarioFields uint64 `yaml:"scenario_fields" env:"APP_MULTITABLE_TABLES_SCENARIO_FIELDS"` ScenarioFields uint64 `yaml:"scenario_fields" env:"APP_MULTITABLE_TABLES_SCENARIO_FIELDS"`
ScenarioRules uint64 `yaml:"scenario_rules" env:"APP_MULTITABLE_TABLES_SCENARIO_RULES"` 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"` SOPNodes uint64 `yaml:"sop_nodes" env:"APP_MULTITABLE_TABLES_SOP_NODES"`
SOPEdges uint64 `yaml:"sop_edges" env:"APP_MULTITABLE_TABLES_SOP_EDGES"` SOPEdges uint64 `yaml:"sop_edges" env:"APP_MULTITABLE_TABLES_SOP_EDGES"`
KnowledgeItems uint64 `yaml:"knowledge_items" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS"` KnowledgeItems uint64 `yaml:"knowledge_items" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS"`
KnowledgeRelations uint64 `yaml:"knowledge_relations" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS"` 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"` Runs uint64 `yaml:"runs" env:"APP_MULTITABLE_TABLES_RUNS"`
Feedback uint64 `yaml:"feedback" env:"APP_MULTITABLE_TABLES_FEEDBACK"` 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.Scenarios,
c.MultiTable.Tables.ScenarioFields, c.MultiTable.Tables.ScenarioFields,
c.MultiTable.Tables.ScenarioRules, c.MultiTable.Tables.ScenarioRules,
c.MultiTable.Tables.SOPVersions, c.MultiTable.Tables.SOPs,
c.MultiTable.Tables.SOPNodes, c.MultiTable.Tables.SOPNodes,
c.MultiTable.Tables.SOPEdges, c.MultiTable.Tables.SOPEdges,
c.MultiTable.Tables.KnowledgeItems, c.MultiTable.Tables.KnowledgeItems,

View File

@@ -87,10 +87,9 @@ func TestLoadMultiTableEnvironmentConfiguration(t *testing.T) {
"APP_MULTITABLE_TABLES_SCENARIOS": "46", "APP_MULTITABLE_TABLES_SCENARIOS": "46",
"APP_MULTITABLE_TABLES_SCENARIO_FIELDS": "47", "APP_MULTITABLE_TABLES_SCENARIO_FIELDS": "47",
"APP_MULTITABLE_TABLES_SCENARIO_RULES": "54", "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_NODES": "49",
"APP_MULTITABLE_TABLES_SOP_EDGES": "50", "APP_MULTITABLE_TABLES_SOP_EDGES": "50",
"APP_MULTITABLE_TABLES_KNOWLEDGE_VERSIONS": "51",
"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS": "55", "APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS": "55",
"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS": "56", "APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS": "56",
"APP_MULTITABLE_TABLES_RUNS": "52", "APP_MULTITABLE_TABLES_RUNS": "52",

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config" "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 // existing columns, which keeps production data safe during application
// upgrades. // upgrades.
func AutoMigrate(db *gorm.DB) error { 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( return db.AutoMigrate(
&model.Tenant{}, &model.Tenant{},
&model.User{}, &model.User{},
@@ -52,11 +64,8 @@ func AutoMigrate(db *gorm.DB) error {
&model.ScenarioField{}, &model.ScenarioField{},
&model.ScenarioRule{}, &model.ScenarioRule{},
&model.SOP{}, &model.SOP{},
&model.SOPVersion{},
&model.SOPNode{}, &model.SOPNode{},
&model.SOPEdge{}, &model.SOPEdge{},
&model.KnowledgeCard{},
&model.KnowledgeCardVersion{},
&model.KnowledgeItem{}, &model.KnowledgeItem{},
&model.KnowledgeRelation{}, &model.KnowledgeRelation{},
&model.SOPRun{}, &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 { type zapGORMLogger struct {
log *zap.Logger log *zap.Logger
level gormlogger.LogLevel level gormlogger.LogLevel

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -2,8 +2,7 @@ package knowledge
import "gorm.io/gorm" import "gorm.io/gorm"
// Handler exposes only the scenario knowledge-graph API. Legacy knowledge-card // Handler exposes the scenario knowledge-graph API.
// tables are retained as read-only storage for historical run rendering.
type Handler struct { type Handler struct {
db *gorm.DB db *gorm.DB
} }

View File

@@ -96,23 +96,13 @@ type SOP struct {
Description string `json:"description" gorm:"type:text;not null"` Description string `json:"description" gorm:"type:text;not null"`
Status string `json:"status" gorm:"size:24;not null"` Status string `json:"status" gorm:"size:24;not null"`
CreatedBy uint64 `json:"created_by" gorm:"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"` 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 { type SOPNode struct {
Base Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"` 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"` NodeKey string `json:"node_key" gorm:"size:64;not null"`
Type string `json:"type" gorm:"size:32;not null"` Type string `json:"type" gorm:"size:32;not null"`
Title string `json:"title" gorm:"size:128;not null"` Title string `json:"title" gorm:"size:128;not null"`
@@ -125,31 +115,13 @@ type SOPNode struct {
type SOPEdge struct { type SOPEdge struct {
Base Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"` 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"` SourceNodeKey string `json:"source_node_key" gorm:"size:64;not null"`
TargetNodeKey string `json:"target_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"` Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"`
Priority int `json:"priority" gorm:"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 { type KnowledgeItem struct {
Base Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"` TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
@@ -177,7 +149,6 @@ type SOPRun struct {
Base Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"` TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
SOPID uint64 `json:"sop_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"` OperatorID uint64 `json:"operator_id" gorm:"not null;index"`
ExternalRef string `json:"external_ref" gorm:"size:191;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"` CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"`

View File

@@ -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 { if err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&sop).Error; err != nil {
return err 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) nodes := make([]model.SOPNode, 0)
edges := make([]model.SOPEdge, 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 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 return err
} }
state := statusFor(version.Status) state := statusFor(sop.Status)
if sop.Status == "archived" { if sop.Status == "archived" {
state = "已归档" state = "已归档"
} }
if err := p.client.Upsert(ctx, p.tables.SOPVersions, id(version.ID), common(version.ID, version.TenantID, state, version.UpdatedAt, map[string]interface{}{ if err := p.client.Upsert(ctx, p.tables.SOPs, id(sop.ID), common(sop.ID, sop.TenantID, state, sop.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), "场景来源ID": id(sop.ScenarioID), "SOP名称": sop.Name, "SOP状态": sop.Status, "开始节点": sop.StartNodeKey, "节点数": len(nodes), "路径数": len(edges),
})); err != nil { })); err != nil {
return err return err
} }
for _, node := range nodes { 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{}{ 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 { })); err != nil {
return err return err
} }
} }
for _, edge := range edges { 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{}{ 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 { })); err != nil {
return err return err
} }
@@ -264,7 +251,7 @@ func (p *Projector) run(ctx context.Context, event model.MultiTableOutbox) error
_ = json.Unmarshal(row.Answers, &answers) _ = json.Unmarshal(row.Answers, &answers)
answerCount = len(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{}{ 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),
})) }))
} }

View File

@@ -24,7 +24,6 @@ type DetailEvent struct {
NodeType string `json:"node_type"` NodeType string `json:"node_type"`
NodeContent string `json:"node_content"` NodeContent string `json:"node_content"`
NodeConfig datatypes.JSON `json:"-"` NodeConfig datatypes.JSON `json:"-"`
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"` Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"`
} }
@@ -48,11 +47,10 @@ func (h *Handler) Detail(c *gin.Context) {
return return
} }
events := make([]DetailEvent, 0) 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", "查询执行事件失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败")
return return
} }
knowledgeCache := map[string]*KnowledgeView{}
answers := map[string]interface{}{} answers := map[string]interface{}{}
_ = json.Unmarshal(header.Answers, &answers) _ = json.Unmarshal(header.Answers, &answers)
derived := map[string]interface{}{} derived := map[string]interface{}{}
@@ -65,16 +63,15 @@ func (h *Handler) Detail(c *gin.Context) {
if events[i].NodeType != "knowledge" { if events[i].NodeType != "knowledge" {
continue continue
} }
cacheKey := string(events[i].NodeConfig)
if cached, ok := knowledgeCache[cacheKey]; ok {
events[i].Knowledge = cached
continue
}
var config knowledgeNodeConfig var config knowledgeNodeConfig
_ = json.Unmarshal(events[i].NodeConfig, &config) _ = json.Unmarshal(events[i].NodeConfig, &config)
if config.KnowledgeSelector != nil { if config.KnowledgeSelector == nil {
continue
}
var node model.SOPNode 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) outputs, loadErr := loadKnowledgeOutputs(h.db, node, principal.TenantID, context, *config.KnowledgeSelector)
if loadErr != nil { if loadErr != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录知识快照不可用") response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录知识快照不可用")
@@ -82,16 +79,6 @@ func (h *Handler) Detail(c *gin.Context) {
} }
events[i].Outputs = outputs 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) 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 { 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", "查询执行反馈失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行反馈失败")

View File

@@ -51,7 +51,7 @@ func (h *Handler) AvailableSOPs(c *gin.Context) {
ScenarioName string `json:"scenario_name"` ScenarioName string `json:"scenario_name"`
} }
items := make([]item, 0) 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") 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 err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
if err != nil { if err != nil {
@@ -89,8 +89,8 @@ func (h *Handler) Start(c *gin.Context) {
return return
} }
} }
var version model.SOPVersion var sop model.SOP
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 { 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") response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP")
return return
} }
@@ -99,11 +99,7 @@ func (h *Handler) Start(c *gin.Context) {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
return return
} }
var sopItem model.SOP var sopItem = 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
}
if input.Input == nil { if input.Input == nil {
input.Input = map[string]interface{}{} input.Input = map[string]interface{}{}
} }
@@ -138,7 +134,7 @@ func (h *Handler) Start(c *gin.Context) {
if externalRef == "" { if externalRef == "" {
externalRef = "run-" + uuid.NewString() 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 { err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&run).Error; err != nil { if err := tx.Create(&run).Error; err != nil {
return err return err
@@ -208,7 +204,7 @@ func (h *Handler) List(c *gin.Context) {
items := make([]listItem, 0) items := make([]listItem, 0)
// The run payload contains several large JSON snapshots. The history list only // The run payload contains several large JSON snapshots. The history list only
// needs scalar metadata; excluding blobs keeps MySQL's sort buffer bounded. // 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 { 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", "查询执行记录失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
return return
@@ -268,7 +264,7 @@ func (h *Handler) Answer(c *gin.Context) {
return errors.New("当前步骤已经变化,请刷新后重试") return errors.New("当前步骤已经变化,请刷新后重试")
} }
var currentNode model.SOPNode var currentNode model.SOPNode
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPVersionID, updated.CurrentNodeKey, p.TenantID).First(&currentNode).Error; err != nil { if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).First(&currentNode).Error; err != nil {
return err return err
} }
fields := make([]model.ScenarioField, 0) fields := make([]model.ScenarioField, 0)
@@ -289,7 +285,7 @@ func (h *Handler) Answer(c *gin.Context) {
return err return err
} }
var edges []model.SOPEdge 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 return err
} }
sortEdges(edges) sortEdges(edges)
@@ -313,7 +309,7 @@ func (h *Handler) Answer(c *gin.Context) {
return err return err
} }
var next model.SOPNode 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 return err
} }
updates := map[string]interface{}{"current_node_key": nextKey, "answers": datatypes.JSON(answerBytes)} 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) { func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
var node model.SOPNode 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", "当前流程节点不存在") response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return return
} }

View File

@@ -7,30 +7,12 @@ import (
"sort" "sort"
"strings" "strings"
knowledgecontent "git.iwork-ai.com/xdc/iqudo-top1/internal/knowledge"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
) )
var knowledgePlaceholderPattern = regexp.MustCompile(`\{\{\s*(?:input|derived|form)\.([A-Za-z][A-Za-z0-9_]*)\s*\}\}`) 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 { type NodeView struct {
NodeKey string `json:"node_key"` NodeKey string `json:"node_key"`
Type string `json:"type"` Type string `json:"type"`
@@ -39,7 +21,6 @@ type NodeView struct {
Config json.RawMessage `json:"config,omitempty"` Config json.RawMessage `json:"config,omitempty"`
Fields []PublicFieldView `json:"fields,omitempty"` Fields []PublicFieldView `json:"fields,omitempty"`
Presentation *NodePresentation `json:"presentation,omitempty"` Presentation *NodePresentation `json:"presentation,omitempty"`
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
Outputs []KnowledgeGroup `json:"outputs"` Outputs []KnowledgeGroup `json:"outputs"`
Collection *KnowledgeCollectionView `json:"collection,omitempty"` Collection *KnowledgeCollectionView `json:"collection,omitempty"`
} }
@@ -70,8 +51,6 @@ type KnowledgeItemView struct {
} }
type knowledgeNodeConfig struct { type knowledgeNodeConfig struct {
KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
KnowledgeSelector *knowledgeSelector `json:"knowledge_selector"` KnowledgeSelector *knowledgeSelector `json:"knowledge_selector"`
KnowledgeCollection *knowledgeCollectionConfig `json:"knowledge_collection"` 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 return view, nil
} }
knowledge, err := loadKnowledgeView(db, node, tenantID, answers)
if err != nil {
return view, err
}
view.Knowledge = &knowledge
return view, nil return view, nil
} }
@@ -272,7 +246,7 @@ func loadPublicFieldsByKeys(db *gorm.DB, node model.SOPNode, tenantID uint64, ke
return []PublicFieldView{}, nil return []PublicFieldView{}, nil
} }
var fields []model.ScenarioField 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 return nil, err
} }
byKey := map[string]model.ScenarioField{} byKey := map[string]model.ScenarioField{}
@@ -310,7 +284,7 @@ func loadPublicNodeFields(db *gorm.DB, node model.SOPNode, tenantID uint64) ([]P
return nil, nil return nil, nil
} }
var fields []model.ScenarioField 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 return nil, err
} }
byKey := make(map[string]model.ScenarioField, len(fields)) 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) { func loadKnowledgeOutputs(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) {
var sopRow struct{ ScenarioID uint64 } 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 nil, err
} }
return loadKnowledgeOutputsForScenario(db, sopRow.ScenarioID, tenantID, context, selector) return loadKnowledgeOutputsForScenario(db, sopRow.ScenarioID, tenantID, context, selector)
@@ -534,43 +508,3 @@ func stringSlice(value interface{}) []string {
} }
return result 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
}

View File

@@ -14,7 +14,7 @@ func TestNodeViewDoesNotExposeInternalSnapshotFields(t *testing.T) {
if err := json.Unmarshal(raw, &value); err != nil { if err := json.Unmarshal(raw, &value); err != nil {
t.Fatal(err) 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 { if _, exists := value[key]; exists {
t.Fatalf("public node contains internal field %s: %s", key, raw) t.Fatalf("public node contains internal field %s: %s", key, raw)
} }

View File

@@ -65,11 +65,6 @@ func (h *Handler) PublicStart(c *gin.Context) {
return 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) 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 { 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", "查询场景契约失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景契约失败")
@@ -96,7 +91,7 @@ func (h *Handler) PublicStart(c *gin.Context) {
if externalRef == "" { if externalRef == "" {
externalRef = "run-" + uuid.NewString() 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() token := uuid.NewString() + uuid.NewString()
err = h.db.Transaction(func(tx *gorm.DB) error { err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&run).Error; err != nil { if err := tx.Create(&run).Error; err != nil {
@@ -162,7 +157,7 @@ func (h *Handler) PublicSubmit(c *gin.Context) {
return errPublicNodeChanged return errPublicNodeChanged
} }
var node model.SOPNode 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 return err
} }
fields := make([]model.ScenarioField, 0) fields := make([]model.ScenarioField, 0)
@@ -212,7 +207,7 @@ func (h *Handler) PublicNext(c *gin.Context) {
return errPublicRunCompleted return errPublicRunCompleted
} }
var node model.SOPNode 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 return err
} }
if node.Type == "question" || node.Type == "choice" || node.Type == "form" { 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 { if err := lockPublicRun(tx, session, &run); err != nil {
return err return err
} }
var version model.SOPVersion var sop model.SOP
if err := tx.Where("id = ? AND tenant_id = ?", run.SOPVersionID, run.TenantID).First(&version).Error; err != nil { if err := tx.Where("id = ? AND tenant_id = ?", run.SOPID, run.TenantID).First(&sop).Error; err != nil {
return err 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 { 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 return err
} }
@@ -327,7 +322,7 @@ func (h *Handler) PublicReset(c *gin.Context) {
func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error { func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error {
var edges []model.SOPEdge 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 return err
} }
answers := map[string]interface{}{} answers := map[string]interface{}{}
@@ -353,7 +348,7 @@ func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error {
return errors.New("没有满足条件的下一节点") return errors.New("没有满足条件的下一节点")
} }
var next model.SOPNode 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 return err
} }
updates := map[string]interface{}{"current_node_key": nextKey} 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) { func (h *Handler) respondPublicRun(c *gin.Context, run model.SOPRun, token string) {
var node model.SOPNode 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", "当前流程节点不存在") response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return return
} }

View File

@@ -56,8 +56,7 @@ func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, con
if len(keys) > 0 { if len(keys) > 0 {
if err := db.Table("scenario_fields sf").Select("sf.*"). if err := db.Table("scenario_fields sf").Select("sf.*").
Joins("JOIN sops s ON s.scenario_id = sf.scenario_id"). Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").
Joins("JOIN sop_versions sv ON sv.sop_id = s.id"). Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).
Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).
Find(&fields).Error; err != nil { Find(&fields).Error; err != nil {
return nil, err return nil, err
} }

View File

@@ -9,7 +9,7 @@ import (
func (h *Handler) fieldReferencedByReleasedSOP(scenarioID uint64, fieldKey string, tenantID uint64) bool { func (h *Handler) fieldReferencedByReleasedSOP(scenarioID uint64, fieldKey string, tenantID uint64) bool {
statuses := []string{"published", "offline", "superseded"} statuses := []string{"published", "offline", "superseded"}
var nodes []model.SOPNode 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 { if err != nil {
return true return true
} }
@@ -19,7 +19,7 @@ func (h *Handler) fieldReferencedByReleasedSOP(scenarioID uint64, fieldKey strin
} }
} }
var edges []model.SOPEdge 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 { if err != nil {
return true return true
} }

View File

@@ -96,27 +96,22 @@ func (h *Handler) Create(c *gin.Context) {
return return
} }
var item model.SOP var item model.SOP
var version model.SOPVersion
err := h.db.Transaction(func(tx *gorm.DB) error { 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 { if err := tx.Create(&item).Error; err != nil {
return err 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{ 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, SOPID: item.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, SOPID: item.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: "finish", Type: "finish", Title: "结束", Content: "本次沟通已完成。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 240},
} }
if err := tx.Create(&nodes).Error; err != nil { if err := tx.Create(&nodes).Error; err != nil {
return err return err
} }
edges := []model.SOPEdge{ edges := []model.SOPEdge{
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", 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, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", 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 { if err := tx.Create(&edges).Error; err != nil {
return err return err
@@ -128,7 +123,7 @@ func (h *Handler) Create(c *gin.Context) {
return return
} }
_ = audit.Record(h.db, p, "create", "sop", item.ID, input) _ = 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) { func (h *Handler) Get(c *gin.Context) {
@@ -142,10 +137,9 @@ func (h *Handler) Get(c *gin.Context) {
return return
} }
var item model.SOP var item model.SOP
var version model.SOPVersion
var nodes []model.SOPNode var nodes []model.SOPNode
var edges []model.SOPEdge 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 err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在") response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
@@ -154,7 +148,7 @@ func (h *Handler) Get(c *gin.Context) {
} }
return 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) { 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", "流程配置不完整") response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
return return
} }
item, version, _, _, err := h.loadLatest(id, p.TenantID) item, _, _, err := h.load(id, p.TenantID)
if err != nil { if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在") response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return 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) problems, validationErr := h.validateForPublish(item, input.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil { if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败") response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
@@ -188,10 +182,10 @@ func (h *Handler) SaveGraph(c *gin.Context) {
return return
} }
err = h.db.Transaction(func(tx *gorm.DB) error { 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 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 return err
} }
if err := tx.Create(&nodes).Error; err != nil { if err := tx.Create(&nodes).Error; err != nil {
@@ -202,10 +196,7 @@ func (h *Handler) SaveGraph(c *gin.Context) {
return err 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 { 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(&version).Updates(map[string]interface{}{"start_node_key": input.StartNodeKey, "status": "published"}).Error; err != nil {
return err return err
} }
if err := tx.Model(&item).Update("status", "published").Error; err != nil { if err := tx.Model(&item).Update("status", "published").Error; err != nil {
@@ -232,15 +223,14 @@ func (h *Handler) Validate(c *gin.Context) {
return return
} }
var item model.SOP var item model.SOP
var version model.SOPVersion
var nodes []model.SOPNode var nodes []model.SOPNode
var edges []model.SOPEdge 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 err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在") response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return 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 { if err != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败") response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return return
@@ -248,24 +238,20 @@ func (h *Handler) Validate(c *gin.Context) {
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems}) 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 var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil { if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
return item, model.SOPVersion{}, nil, nil, err return item, 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
} }
nodes := make([]model.SOPNode, 0) nodes := make([]model.SOPNode, 0)
edges := make([]model.SOPEdge, 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 { if err := h.db.Where("sop_id = ? AND tenant_id = ?", sopID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
return item, version, nil, nil, err 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 { if err := h.db.Where("sop_id = ? AND tenant_id = ?", sopID, tenantID).Order("priority, id").Find(&edges).Error; err != nil {
return item, version, nil, nil, err 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) { 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 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)) nodes := make([]model.SOPNode, 0, len(input.Nodes))
for _, item := range input.Nodes { for _, item := range input.Nodes {
config := item.Config config := item.Config
if len(config) == 0 || !json.Valid(config) { if len(config) == 0 || !json.Valid(config) {
config = json.RawMessage(`{}`) 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)) edges := make([]model.SOPEdge, 0, len(input.Edges))
for _, item := range 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) { if len(condition) == 0 || !json.Valid(condition) {
condition = json.RawMessage(`{}`) 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 return nodes, edges
} }

View 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;

View 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.

View 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;

View File

@@ -293,45 +293,33 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
if err := tx.Model(&sop).Updates(map[string]interface{}{ if err := tx.Model(&sop).Updates(map[string]interface{}{
"description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。", "description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。",
"status": "published", "status": "published",
"start_node_key": "start",
}).Error; err != nil { }).Error; err != nil {
return err 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 var alreadySeeded int64
if err := tx.Model(&model.SOPNode{}).Where( if err := tx.Model(&model.SOPNode{}).Where(
"sop_version_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?", "sop_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?",
version.ID, "start", "pet-doctor-v16-start-presentation-optional-pet", sop.ID, "start", "pet-doctor-v16-start-presentation-optional-pet",
).Count(&alreadySeeded).Error; err != nil { ).Count(&alreadySeeded).Error; err != nil {
return err return err
} }
if alreadySeeded > 0 { 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 nil
return err
}
return tx.Model(&version).Updates(map[string]interface{}{"status": "published", "start_node_key": "start"}).Error
} }
nodes := petNodes() nodes := petNodes()
edges := petEdges() 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 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 return err
} }
for index, definition := range nodes { for index, definition := range nodes {
configJSON, _ := json.Marshal(definition.Config) configJSON, _ := json.Marshal(definition.Config)
node := model.SOPNode{ 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, Title: definition.Title, Content: definition.Content, Config: datatypes.JSON(configJSON), PositionX: 0, PositionY: index * 120,
} }
if err := tx.Create(&node).Error; err != nil { 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 { for _, definition := range edges {
conditionJSON, _ := json.Marshal(definition.Condition) conditionJSON, _ := json.Marshal(definition.Condition)
edge := model.SOPEdge{ 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, Condition: datatypes.JSON(conditionJSON), Priority: definition.Priority,
} }
if err := tx.Create(&edge).Error; err != nil { if err := tx.Create(&edge).Error; err != nil {
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err) 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": "宠物医生问诊问药"}) payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药"})
return tx.Create(&model.AuditLog{ return tx.Create(&model.AuditLog{
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload), TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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
View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#202825" /> <meta name="theme-color" content="#202825" />
<title>销冠 SOP 平台</title> <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/client-Bk7n1O1W.js">
<link rel="modulepreload" crossorigin href="/assets/config-provider-B-t02-Z0.js"> <link rel="modulepreload" crossorigin href="/assets/config-provider-B-t02-Z0.js">
<link rel="modulepreload" crossorigin href="/assets/auth-D6Tj4g1X.js"> <link rel="modulepreload" crossorigin href="/assets/auth-D6Tj4g1X.js">

View File

@@ -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 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 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 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 SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string; start_node_key?: 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 KnowledgeItemView { key:string; name:string; type:string; content:Record<string,any> } 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 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> } 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 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 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 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 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 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; knowledge?:KnowledgeSnapshot; outputs?:KnowledgeGroup[] } 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 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 RunDetail extends SOPRun { scenario_name:string; operator_name:string }
export interface Role extends BaseEntity { name:string; code:string; permissions:string[] } export interface Role extends BaseEntity { name:string; code:string; permissions:string[] }

View File

@@ -125,13 +125,6 @@ onMounted(load)
<p>{{ startPresentation.opening.content }}</p> <p>{{ startPresentation.opening.content }}</p>
</div> </div>
</section> </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"> <section v-if="visibleKnowledgeGroups.length" class="knowledge-groups">
<article v-for="group in visibleKnowledgeGroups" :key="group.key" class="knowledge-group"> <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> <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> <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} .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} .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)} .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} .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}

View File

@@ -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'"> <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> <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> <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-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> <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> </a-timeline-item>