feat: simplify SOP to immediate-effect configuration

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

View File

@@ -2,6 +2,8 @@ package audit
import (
"encoding/json"
"fmt"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
@@ -11,10 +13,6 @@ import (
)
func Record(db *gorm.DB, principal auth.Principal, action, resource string, resourceID uint64, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
fields := []zap.Field{
zap.Uint64("tenant_id", principal.TenantID),
zap.Uint64("user_id", principal.UserID),
@@ -22,7 +20,9 @@ func Record(db *gorm.DB, principal auth.Principal, action, resource string, reso
zap.String("resource", resource),
zap.Uint64("resource_id", resourceID),
}
if err := db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error; err != nil {
if err := db.Transaction(func(tx *gorm.DB) error {
return RecordTx(tx, principal, action, resource, resourceID, payload)
}); err != nil {
zap.L().Error("persist business event", append(fields, zap.Error(err))...)
return err
}
@@ -30,6 +30,43 @@ func Record(db *gorm.DB, principal auth.Principal, action, resource string, reso
return nil
}
// RecordTx persists the audit entry and its projection event in the caller's
// transaction. Callers that mutate business data should use this function so
// the mutation and Outbox event cannot diverge.
func RecordTx(tx *gorm.DB, principal auth.Principal, action, resource string, resourceID uint64, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
entry := model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}
if err := tx.Create(&entry).Error; err != nil {
return err
}
if !isProjected(resource, action) {
return nil
}
auditID := entry.ID
outbox := model.MultiTableOutbox{TenantID: principal.TenantID, AuditLogID: &auditID, Resource: resource, ResourceID: resourceID, Action: action, Payload: datatypes.JSON(data), DedupeKey: fmt.Sprintf("audit:%d", entry.ID), Status: "pending", AvailableAt: time.Now()}
return tx.Create(&outbox).Error
}
func isProjected(resource, action string) bool {
switch resource {
case "scenario":
return action == "create" || action == "update" || action == "archive"
case "scenario_field":
return action == "create" || action == "update" || action == "delete"
case "sop":
return action == "publish" || action == "offline"
case "scenario_rule", "knowledge_item", "knowledge_relation":
return action == "create" || action == "update" || action == "delete" || action == "archive"
case "sop_run":
return action == "start" || action == "finish" || action == "feedback"
default:
return false
}
}
func logBusinessEvent(fields []zap.Field) {
zap.L().Info("business event", fields...)
}

View File

@@ -181,8 +181,7 @@ func Seed(db *gorm.DB, cfg config.SeedConfig) error {
Permissions []string
}{
{Name: "管理员", Code: "admin", Permissions: []string{"*"}},
{Name: "SOP 编辑者", Code: "editor", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "scenario.edit", "sop.view", "sop.edit", "sop.submit_review", "knowledge.view", "knowledge.edit", "runs.view_all"}},
{Name: "审核者", Code: "reviewer", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "sop.view", "sop.review", "sop.publish", "knowledge.view", "runs.view_all"}},
{Name: "SOP 编辑者", Code: "editor", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "scenario.edit", "sop.view", "sop.edit", "knowledge.view", "knowledge.edit", "runs.view_all"}},
{Name: "一线执行者", Code: "operator", Permissions: []string{"dashboard.view", "scenario.view", "sop.execute", "knowledge.view", "runs.view_own", "runs.feedback"}},
}
roles := make(map[string]model.Role, len(roleDefinitions))

View File

@@ -12,11 +12,12 @@ import (
)
type Config struct {
App AppConfig `yaml:"app"`
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"`
Seed SeedConfig `yaml:"seed"`
App AppConfig `yaml:"app"`
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"`
Seed SeedConfig `yaml:"seed"`
MultiTable MultiTableConfig `yaml:"multitable"`
}
type AppConfig struct {
@@ -63,6 +64,32 @@ type SeedConfig struct {
AdminDisplayName string `yaml:"admin_display_name" env:"APP_SEED_ADMIN_DISPLAY_NAME"`
}
type MultiTableConfig struct {
Enabled bool `yaml:"enabled" env:"APP_MULTITABLE_ENABLED" env-default:"false"`
BaseURL string `yaml:"base_url" env:"APP_MULTITABLE_BASE_URL" env-default:"https://table.iwork-ai.com/open/v1"`
APIKey string `yaml:"api_key" env:"APP_MULTITABLE_API_KEY"`
SyncInterval time.Duration `yaml:"sync_interval" env:"APP_MULTITABLE_SYNC_INTERVAL" env-default:"5s"`
RequestTimeout time.Duration `yaml:"request_timeout" env:"APP_MULTITABLE_REQUEST_TIMEOUT" env-default:"10s"`
BatchSize int `yaml:"batch_size" env:"APP_MULTITABLE_BATCH_SIZE" env-default:"20"`
MaxAttempts int `yaml:"max_attempts" env:"APP_MULTITABLE_MAX_ATTEMPTS" env-default:"12"`
Tables MultiTableTables `yaml:"tables"`
}
type MultiTableTables struct {
Scenarios uint64 `yaml:"scenarios" env:"APP_MULTITABLE_TABLES_SCENARIOS"`
ScenarioFields uint64 `yaml:"scenario_fields" env:"APP_MULTITABLE_TABLES_SCENARIO_FIELDS"`
ScenarioRules uint64 `yaml:"scenario_rules" env:"APP_MULTITABLE_TABLES_SCENARIO_RULES"`
SOPVersions uint64 `yaml:"sop_versions" env:"APP_MULTITABLE_TABLES_SOP_VERSIONS"`
SOPNodes uint64 `yaml:"sop_nodes" env:"APP_MULTITABLE_TABLES_SOP_NODES"`
SOPEdges uint64 `yaml:"sop_edges" env:"APP_MULTITABLE_TABLES_SOP_EDGES"`
KnowledgeItems uint64 `yaml:"knowledge_items" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS"`
KnowledgeRelations uint64 `yaml:"knowledge_relations" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS"`
// KnowledgeVersions is retained only for replaying historical projection events.
KnowledgeVersions uint64 `yaml:"knowledge_versions" env:"APP_MULTITABLE_TABLES_KNOWLEDGE_VERSIONS"`
Runs uint64 `yaml:"runs" env:"APP_MULTITABLE_TABLES_RUNS"`
Feedback uint64 `yaml:"feedback" env:"APP_MULTITABLE_TABLES_FEEDBACK"`
}
type LoadOptions struct {
Environment string
ConfigDir string
@@ -138,5 +165,33 @@ func (c Config) Validate() error {
if c.App.Env == "prod" && c.Database.Password == "" {
return errors.New("database password is required in production")
}
if c.MultiTable.Enabled {
if !strings.HasPrefix(c.MultiTable.BaseURL, "https://") {
return errors.New("multitable.base_url must use https when multitable is enabled")
}
if strings.TrimSpace(c.MultiTable.APIKey) == "" {
return errors.New("APP_MULTITABLE_API_KEY is required when multitable is enabled")
}
if c.MultiTable.SyncInterval <= 0 || c.MultiTable.RequestTimeout <= 0 || c.MultiTable.BatchSize < 1 || c.MultiTable.MaxAttempts < 1 {
return errors.New("multitable retry and timeout settings must be positive")
}
ids := []uint64{
c.MultiTable.Tables.Scenarios,
c.MultiTable.Tables.ScenarioFields,
c.MultiTable.Tables.ScenarioRules,
c.MultiTable.Tables.SOPVersions,
c.MultiTable.Tables.SOPNodes,
c.MultiTable.Tables.SOPEdges,
c.MultiTable.Tables.KnowledgeItems,
c.MultiTable.Tables.KnowledgeRelations,
c.MultiTable.Tables.Runs,
c.MultiTable.Tables.Feedback,
}
for _, id := range ids {
if id == 0 {
return errors.New("all multitable table IDs are required when multitable is enabled")
}
}
}
return nil
}

View File

@@ -72,6 +72,34 @@ func TestLoadProductionRequiresEnvironmentSecrets(t *testing.T) {
}
}
func TestLoadMultiTableEnvironmentConfiguration(t *testing.T) {
dir := writeConfigs(t, "")
t.Setenv("APP_MULTITABLE_ENABLED", "true")
t.Setenv("APP_MULTITABLE_API_KEY", "test-key")
for key, value := range map[string]string{
"APP_MULTITABLE_TABLES_SCENARIOS": "46",
"APP_MULTITABLE_TABLES_SCENARIO_FIELDS": "47",
"APP_MULTITABLE_TABLES_SCENARIO_RULES": "54",
"APP_MULTITABLE_TABLES_SOP_VERSIONS": "48",
"APP_MULTITABLE_TABLES_SOP_NODES": "49",
"APP_MULTITABLE_TABLES_SOP_EDGES": "50",
"APP_MULTITABLE_TABLES_KNOWLEDGE_VERSIONS": "51",
"APP_MULTITABLE_TABLES_KNOWLEDGE_ITEMS": "55",
"APP_MULTITABLE_TABLES_KNOWLEDGE_RELATIONS": "56",
"APP_MULTITABLE_TABLES_RUNS": "52",
"APP_MULTITABLE_TABLES_FEEDBACK": "53",
} {
t.Setenv(key, value)
}
cfg, err := Load(LoadOptions{ConfigDir: dir})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.MultiTable.Enabled || cfg.MultiTable.Tables.Scenarios != 46 || cfg.MultiTable.Tables.ScenarioRules != 54 || cfg.MultiTable.Tables.KnowledgeItems != 55 || cfg.MultiTable.Tables.KnowledgeRelations != 56 || cfg.MultiTable.Tables.Feedback != 53 {
t.Fatalf("unexpected multitable configuration: %+v", cfg.MultiTable)
}
}
func writeConfigs(t *testing.T, profile string) string {
t.Helper()
dir := t.TempDir()

View File

@@ -20,13 +20,17 @@ import (
"gorm.io/gorm"
)
func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger, environment string) http.Handler {
func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, sdkFS fs.FS, log *zap.Logger, environment string) http.Handler {
if environment == "prod" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(middleware.CORS())
router.Use(middleware.RequestLogger(log.Named("http")), middleware.Recovery(log.Named("recovery")))
// 通过 HTTP 直接提供 SDK 产物,例如 /sdk/index.js 与 /sdk/index.iife.js。
router.StaticFS("/sdk", http.FS(sdkFS))
authHandler := auth.NewHandler(authService)
scenarioHandler := scenario.NewHandler(db)
sopHandler := sop.NewHandler(db)
@@ -41,6 +45,13 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
})
api.POST("/auth/login", authHandler.Login)
api.POST("/auth/refresh", authHandler.Refresh)
public := router.Group("/public")
public.POST("/scenarios/:scenarioKey/runs", runHandler.PublicStart)
public.GET("/runs/:id/current", runHandler.PublicCurrent)
public.POST("/runs/:id/submit", runHandler.PublicSubmit)
public.POST("/runs/:id/next", runHandler.PublicNext)
public.POST("/runs/:id/finish", runHandler.PublicFinish)
public.POST("/runs/:id/reset", runHandler.PublicReset)
protected := api.Group("")
protected.Use(middleware.Authenticate(authService))
@@ -58,23 +69,18 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
protected.POST("/scenarios/:id/fields", middleware.RequirePermission("scenario.edit"), scenarioHandler.CreateField)
protected.PUT("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.UpdateField)
protected.DELETE("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.DeleteField)
protected.GET("/scenarios/:id/contract", middleware.RequirePermission("scenario.view"), scenarioHandler.GetContract)
protected.PUT("/scenarios/:id/contract", middleware.RequirePermission("scenario.edit"), scenarioHandler.ReplaceContract)
protected.POST("/scenarios/:id/preview", middleware.RequirePermission("scenario.view"), runHandler.PreviewScenario)
protected.GET("/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
protected.GET("/scenarios/:id/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
protected.POST("/scenarios/:id/sops", middleware.RequirePermission("sop.edit"), sopHandler.Create)
protected.GET("/sops/:id", middleware.RequirePermission("sop.view"), sopHandler.Get)
protected.GET("/sops/:id/versions", middleware.RequirePermission("sop.view"), sopHandler.ListVersions)
protected.PUT("/sops/:id/draft", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
protected.PUT("/sops/:id/graph", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
protected.POST("/sops/:id/validate", middleware.RequireAnyPermission("sop.view", "sop.edit"), sopHandler.Validate)
protected.POST("/sops/:id/submit-review", middleware.RequirePermission("sop.submit_review"), sopHandler.SubmitReview)
protected.POST("/sops/:id/publish", middleware.RequirePermission("sop.publish"), sopHandler.Publish)
protected.POST("/sops/:id/reject", middleware.RequirePermission("sop.review"), sopHandler.Reject)
protected.POST("/sops/:id/offline", middleware.RequirePermission("sop.publish"), sopHandler.Offline)
protected.POST("/sops/:id/versions", middleware.RequirePermission("sop.edit"), sopHandler.CreateVersion)
protected.POST("/sops/:id/rollback", middleware.RequirePermission("sop.publish"), sopHandler.Rollback)
protected.GET("/reviews", middleware.RequirePermission("sop.review"), sopHandler.Reviews)
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
protected.GET("/available-sops", middleware.RequirePermission("sop.execute"), runHandler.AvailableSOPs)
protected.GET("/runs", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.List)
protected.GET("/runs/options", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Options)
protected.POST("/runs", middleware.RequirePermission("sop.execute"), runHandler.Start)
@@ -84,11 +90,8 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
protected.POST("/runs/:id/finish", middleware.RequirePermission("sop.execute"), runHandler.Finish)
protected.POST("/runs/:id/feedback", middleware.RequirePermission("runs.feedback"), runHandler.Feedback)
protected.GET("/knowledge-cards", middleware.RequirePermission("knowledge.view"), knowledgeHandler.List)
protected.POST("/knowledge-cards", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Create)
protected.GET("/knowledge-cards/:id/versions", middleware.RequirePermission("knowledge.view"), knowledgeHandler.Versions)
protected.PUT("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Update)
protected.DELETE("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Delete)
protected.GET("/scenarios/:id/knowledge-graph", middleware.RequirePermission("knowledge.view"), knowledgeHandler.GetGraph)
protected.PUT("/scenarios/:id/knowledge-graph", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.ReplaceGraph)
router.NoRoute(spaHandler(frontend))
return router

View File

@@ -0,0 +1,44 @@
package httpserver
import (
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestSDKFilesAreServedOverHTTP(t *testing.T) {
gin.SetMode(gin.TestMode)
frontend := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<html>ok</html>")},
}
sdkFS := fstest.MapFS{
"index.js": &fstest.MapFile{Data: []byte("export function create() {}")},
"index.iife.js": &fstest.MapFile{Data: []byte("var IqudooSalesScenario = {};")},
}
handler := New(nil, auth.NewService(nil, config.AuthConfig{}), frontend, sdkFS, zap.NewNop(), "test")
for _, path := range []string{"/sdk/index.js", "/sdk/index.iife.js"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s status = %d, want %d", path, rec.Code, http.StatusOK)
}
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("GET %s missing open CORS header", path)
}
if got := rec.Header().Get("Content-Type"); got != "text/javascript; charset=utf-8" {
t.Fatalf("GET %s Content-Type = %q, want text/javascript", path, got)
}
if rec.Body.Len() == 0 {
t.Fatalf("GET %s returned empty body", path)
}
}
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,22 @@ import (
"go.uber.org/zap"
)
// CORS 完全开放跨域访问:允许所有来源、方法和请求头,并直接响应预检请求。
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "*")
c.Header("Access-Control-Expose-Headers", "X-Request-ID, Content-Length")
c.Header("Access-Control-Max-Age", "86400")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func RequestLogger(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
started := time.Now()

View File

@@ -30,3 +30,34 @@ func TestRequestLoggerUsesStatusLevelAndDurationMilliseconds(t *testing.T) {
t.Fatalf("duration_ms is missing from request log")
}
}
func TestCORSAllowsAllOriginsAndAnswersPreflight(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(CORS())
router.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set("Origin", "https://crm.example.com")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("Access-Control-Allow-Origin = %q, want *", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "*" {
t.Fatalf("Access-Control-Allow-Headers = %q, want *", got)
}
preflight := httptest.NewRequest(http.MethodOptions, "/ping", nil)
preflight.Header.Set("Origin", "https://crm.example.com")
preflightRec := httptest.NewRecorder()
router.ServeHTTP(preflightRec, preflight)
if preflightRec.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want %d", preflightRec.Code, http.StatusNoContent)
}
if got := preflightRec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("preflight Access-Control-Allow-Origin = %q, want *", got)
}
}

View File

@@ -45,15 +45,21 @@ type TenantMember struct {
type Scenario struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
Name string `json:"name" gorm:"size:128;not null"`
Industry string `json:"industry" gorm:"size:64;not null"`
RoleName string `json:"role_name" gorm:"size:64;not null"`
Goal string `json:"goal" gorm:"type:text;not null"`
TriggerText string `json:"trigger_text" gorm:"type:text;not null"`
Visibility string `json:"visibility" gorm:"size:24;not null"`
Status string `json:"status" gorm:"size:24;not null"`
CreatedBy uint64 `json:"created_by" gorm:"not null"`
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
ScenarioKey string `json:"scenario_key" gorm:"size:128;not null;index"`
PublicKey string `json:"public_key" gorm:"size:128;not null;index"`
AllowedOrigins datatypes.JSON `json:"allowed_origins" gorm:"type:json;not null"`
Name string `json:"name" gorm:"size:128;not null"`
Industry string `json:"industry" gorm:"size:64;not null"`
RoleName string `json:"role_name" gorm:"size:64;not null"`
Goal string `json:"goal" gorm:"type:text;not null"`
TriggerText string `json:"trigger_text" gorm:"type:text;not null"`
Visibility string `json:"visibility" gorm:"size:24;not null"`
Status string `json:"status" gorm:"size:24;not null"`
CreatedBy uint64 `json:"created_by" gorm:"not null"`
InputSchema datatypes.JSON `json:"input_schema" gorm:"type:json;not null"`
OutputSchema datatypes.JSON `json:"output_schema" gorm:"type:json;not null"`
ResultSchema datatypes.JSON `json:"result_schema" gorm:"type:json;not null"`
}
type ScenarioField struct {
@@ -62,6 +68,7 @@ type ScenarioField struct {
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
FieldKey string `json:"field_key" gorm:"size:64;not null"`
FieldName string `json:"field_name" gorm:"size:128;not null"`
SourcePath string `json:"source_path" gorm:"size:255;not null"`
FieldType string `json:"field_type" gorm:"size:32;not null"`
Required bool `json:"required" gorm:"not null"`
Options datatypes.JSON `json:"options" gorm:"type:json;not null"`
@@ -69,6 +76,18 @@ type ScenarioField struct {
SortOrder int `json:"sort_order" gorm:"not null"`
}
type ScenarioRule struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
RuleKey string `json:"rule_key" gorm:"size:64;not null"`
Name string `json:"name" gorm:"size:128;not null"`
Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"`
Actions datatypes.JSON `json:"actions" gorm:"type:json;not null"`
Priority int `json:"priority" gorm:"not null"`
Status string `json:"status" gorm:"size:24;not null"`
}
type SOP struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
@@ -88,7 +107,6 @@ type SOPVersion struct {
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"`
ReviewedBy *uint64 `json:"reviewed_by"`
}
type SOPNode struct {
@@ -132,18 +150,47 @@ type KnowledgeCardVersion struct {
Status string `json:"status" gorm:"size:24;not null"`
}
type KnowledgeItem struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
ItemKey string `json:"key" gorm:"size:64;not null"`
Name string `json:"name" gorm:"size:128;not null"`
Type string `json:"type" gorm:"size:64;not null"`
Content datatypes.JSON `json:"content" gorm:"type:json;not null"`
Status string `json:"status" gorm:"size:24;not null"`
SortOrder int `json:"sort_order" gorm:"not null"`
}
type KnowledgeRelation struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
FromKnowledgeID uint64 `json:"from_knowledge_id" gorm:"not null;index"`
RelationType string `json:"relation_type" gorm:"size:64;not null"`
ToKnowledgeID uint64 `json:"to_knowledge_id" gorm:"not null;index"`
Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"`
SortOrder int `json:"sort_order" gorm:"not null"`
}
type SOPRun struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
OperatorID uint64 `json:"operator_id" gorm:"not null;index"`
CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"`
Status string `json:"status" gorm:"size:24;not null"`
Answers datatypes.JSON `json:"answers" gorm:"type:json;not null"`
Result string `json:"result" gorm:"size:64;not null"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
OperatorID uint64 `json:"operator_id" gorm:"not null;index"`
ExternalRef string `json:"external_ref" gorm:"size:191;not null;index"`
CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"`
Status string `json:"status" gorm:"size:24;not null"`
Answers datatypes.JSON `json:"answers" gorm:"type:json;not null"`
Input datatypes.JSON `json:"input" gorm:"type:json;not null"`
Derived datatypes.JSON `json:"derived" gorm:"type:json;not null"`
Outputs datatypes.JSON `json:"outputs" gorm:"type:json;not null"`
KnowledgeSnapshot datatypes.JSON `json:"knowledge_snapshot" gorm:"type:json;not null"`
Result string `json:"result" gorm:"size:64;not null"`
FinalResult datatypes.JSON `json:"final_result" gorm:"type:json"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
}
type SOPRunEvent struct {
@@ -155,6 +202,14 @@ type SOPRunEvent struct {
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
}
type PublicRunSession struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
RunID uint64 `json:"run_id" gorm:"not null;index"`
TokenHash string `json:"-" gorm:"size:64;not null;uniqueIndex"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null"`
}
type SOPFeedback struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
@@ -176,6 +231,23 @@ type AuditLog struct {
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
}
type MultiTableOutbox struct {
Base
TenantID uint64 `gorm:"not null;index"`
AuditLogID *uint64 `gorm:"uniqueIndex"`
Resource string `gorm:"size:64;not null;index"`
ResourceID uint64 `gorm:"not null;index"`
Action string `gorm:"size:64;not null"`
Payload datatypes.JSON `gorm:"type:json;not null"`
DedupeKey string `gorm:"size:191;not null;uniqueIndex"`
Status string `gorm:"size:24;not null;index"`
Attempts int `gorm:"not null"`
AvailableAt time.Time `gorm:"not null;index"`
LastError string `gorm:"type:text;not null"`
}
func (MultiTableOutbox) TableName() string { return "multitable_outbox" }
type RefreshToken struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`

View File

@@ -0,0 +1,137 @@
package multitable
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
)
type Client struct {
baseURL string
apiKey string
http *http.Client
mu sync.Mutex
sourceColumns map[uint64]string
}
func NewClient(baseURL, apiKey string, timeout time.Duration) *Client {
return &Client{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, http: &http.Client{Timeout: timeout}, sourceColumns: make(map[uint64]string)}
}
func (c *Client) Upsert(ctx context.Context, tableID uint64, sourceID string, data map[string]interface{}) error {
sourceColumn, err := c.sourceColumn(ctx, tableID)
if err != nil {
return err
}
filters, err := json.Marshal([]map[string]interface{}{{"col": sourceColumn, "op": "eq", "val": sourceID}})
if err != nil {
return fmt.Errorf("encode filters: %w", err)
}
endpoint := c.endpoint("tables", fmt.Sprint(tableID), "records")
u, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("parse records endpoint: %w", err)
}
query := u.Query()
query.Set("page", "1")
query.Set("pageSize", "2")
query.Set("filters", string(filters))
u.RawQuery = query.Encode()
var listed struct {
Items []struct {
ID uint64 `json:"id"`
} `json:"items"`
}
if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &listed); err != nil {
return err
}
body := map[string]interface{}{"data": data}
if len(listed.Items) == 0 {
return c.doJSON(ctx, http.MethodPost, endpoint, body, nil)
}
return c.doJSON(ctx, http.MethodPut, c.endpoint("tables", fmt.Sprint(tableID), "records", fmt.Sprint(listed.Items[0].ID)), body, nil)
}
func (c *Client) sourceColumn(ctx context.Context, tableID uint64) (string, error) {
c.mu.Lock()
if columnID, ok := c.sourceColumns[tableID]; ok {
c.mu.Unlock()
return columnID, nil
}
c.mu.Unlock()
var columns []struct {
ID uint64 `json:"id"`
Name string `json:"name"`
}
if err := c.doJSON(ctx, http.MethodGet, c.endpoint("tables", fmt.Sprint(tableID), "columns"), nil, &columns); err != nil {
return "", err
}
for _, column := range columns {
if column.Name == "来源ID" {
columnID := fmt.Sprint(column.ID)
c.mu.Lock()
c.sourceColumns[tableID] = columnID
c.mu.Unlock()
return columnID, nil
}
}
return "", fmt.Errorf("multitable table %d has no 来源ID column", tableID)
}
func (c *Client) endpoint(parts ...string) string {
return c.baseURL + "/" + path.Join(parts...)
}
func (c *Client) doJSON(ctx context.Context, method, endpoint string, input interface{}, output interface{}) error {
var body io.Reader
if input != nil {
encoded, err := json.Marshal(input)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
body = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("X-API-Key", c.apiKey)
req.Header.Set("Accept", "application/json")
if input != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("call multitable API: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return fmt.Errorf("read multitable response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("multitable API returned %d: %s", resp.StatusCode, truncate(string(data), 512))
}
if output != nil && len(data) > 0 {
if err := json.Unmarshal(data, output); err != nil {
return fmt.Errorf("decode multitable response: %w", err)
}
}
return nil
}
func truncate(value string, max int) string {
if len(value) <= max {
return value
}
return value[:max]
}

View File

@@ -0,0 +1,120 @@
package multitable
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func TestUpsertCreatesWhenSourceIDDoesNotExist(t *testing.T) {
var gotData map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if r.URL.Path == "/tables/46/columns" {
_ = json.NewEncoder(w).Encode([]map[string]interface{}{{"id": 1, "name": "来源ID"}})
return
}
if r.URL.Query().Get("filters") == "" {
t.Fatal("missing source ID filter")
}
var filters []map[string]interface{}
if err := json.Unmarshal([]byte(r.URL.Query().Get("filters")), &filters); err != nil || filters[0]["col"] != "1" {
t.Fatalf("filters = %s, want column ID 1", r.URL.Query().Get("filters"))
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}})
case http.MethodPost:
var body struct {
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
gotData = body.Data
w.WriteHeader(http.StatusCreated)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
err := NewClient(server.URL, "test-key", time.Second).Upsert(context.Background(), 46, "42", map[string]interface{}{"来源ID": "42", "名称": "宠物医生问诊问药"})
if err != nil {
t.Fatalf("Upsert() error = %v", err)
}
if gotData["来源ID"] != "42" {
t.Fatalf("created data = %#v", gotData)
}
}
func TestUpsertUpdatesExistingRecord(t *testing.T) {
updated := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if r.URL.Path == "/tables/46/columns" {
_ = json.NewEncoder(w).Encode([]map[string]interface{}{{"id": 1, "name": "来源ID"}})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []map[string]interface{}{{"id": 99}}})
case http.MethodPut:
if r.URL.Path != "/tables/46/records/99" {
t.Fatalf("update path = %s", r.URL.Path)
}
updated = true
w.WriteHeader(http.StatusOK)
default:
t.Fatalf("unexpected method %s", r.Method)
}
}))
defer server.Close()
if err := NewClient(server.URL, "test-key", time.Second).Upsert(context.Background(), 46, "42", map[string]interface{}{"来源ID": "42"}); err != nil {
t.Fatalf("Upsert() error = %v", err)
}
if !updated {
t.Fatal("existing record was not updated")
}
}
func TestDeletedScenarioFieldCreatesArchivedProjectionFromAuditPayload(t *testing.T) {
var created map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/tables/47/columns" {
_ = json.NewEncoder(w).Encode([]map[string]interface{}{{"id": 1, "name": "来源ID"}})
return
}
if r.Method == http.MethodGet {
_ = json.NewEncoder(w).Encode(map[string]interface{}{"items": []interface{}{}})
return
}
if r.Method == http.MethodPost {
var body struct {
Data map[string]interface{} `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
created = body.Data
w.WriteHeader(http.StatusCreated)
return
}
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
payload := []byte(`{"scenario_id":9,"field_key":"pet_name","field_name":"宠物姓名","source_path":"pet.name","field_type":"text","required":true,"options":[],"validation":{},"sort_order":1}`)
projector := NewProjector(nil, NewClient(server.URL, "test-key", time.Second), config.MultiTableTables{ScenarioFields: 47})
event := model.MultiTableOutbox{Base: model.Base{ID: 3, UpdatedAt: time.Now()}, TenantID: 2, ResourceID: 12, Action: "delete", Payload: payload}
if err := projector.deletedScenarioField(context.Background(), event); err != nil {
t.Fatalf("deletedScenarioField() error = %v", err)
}
if created["同步状态"] != "已归档" || created["字段标识"] != "pet_name" || created["外部数据路径"] != "pet.name" || created["是否必填"] != "是" {
t.Fatalf("created projection = %#v", created)
}
}

View File

@@ -0,0 +1,44 @@
package multitable
import (
"fmt"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func EnqueueBackfill(db *gorm.DB) (int, error) {
resources := []struct {
name string
model interface{}
}{
{"scenario", &model.Scenario{}},
{"scenario_field", &model.ScenarioField{}},
{"scenario_rule", &model.ScenarioRule{}},
{"sop", &model.SOP{}},
{"knowledge_item", &model.KnowledgeItem{}},
{"knowledge_relation", &model.KnowledgeRelation{}},
{"sop_run", &model.SOPRun{}},
}
count := 0
for _, resource := range resources {
var rows []struct{ ID, TenantID uint64 }
if err := db.Model(resource.model).Select("id, tenant_id").Scan(&rows).Error; err != nil {
return count, fmt.Errorf("list %s: %w", resource.name, err)
}
for _, row := range rows {
event := model.MultiTableOutbox{
TenantID: row.TenantID, Resource: resource.name, ResourceID: row.ID, Action: "backfill",
Payload: []byte(`{}`), DedupeKey: fmt.Sprintf("backfill:%s:%d", resource.name, row.ID),
Status: "pending", Attempts: 0, AvailableAt: time.Now(), LastError: "",
}
if err := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "dedupe_key"}}, DoUpdates: clause.Assignments(map[string]interface{}{"status": "pending", "attempts": 0, "available_at": time.Now(), "last_error": ""})}).Create(&event).Error; err != nil {
return count, fmt.Errorf("enqueue %s %d: %w", resource.name, row.ID, err)
}
count++
}
}
return count, nil
}

View File

@@ -0,0 +1,335 @@
package multitable
import (
"context"
"encoding/json"
"errors"
"strconv"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type Projector struct {
db *gorm.DB
client *Client
tables config.MultiTableTables
}
func NewProjector(db *gorm.DB, client *Client, tables config.MultiTableTables) *Projector {
return &Projector{db: db, client: client, tables: tables}
}
func (p *Projector) Project(ctx context.Context, event model.MultiTableOutbox) error {
switch event.Resource {
case "scenario":
return p.scenario(ctx, event)
case "scenario_field":
return p.scenarioField(ctx, event)
case "scenario_rule":
return p.scenarioRule(ctx, event)
case "sop":
return p.sop(ctx, event)
case "knowledge_item":
return p.knowledgeItem(ctx, event)
case "knowledge_relation":
return p.knowledgeRelation(ctx, event)
case "sop_run":
if err := p.run(ctx, event); err != nil {
return err
}
if event.Action == "feedback" {
return p.feedback(ctx, event)
}
}
return nil
}
func (p *Projector) scenario(ctx context.Context, event model.MultiTableOutbox) error {
var item model.Scenario
if err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error; err != nil {
return err
}
if err := p.client.Upsert(ctx, p.tables.Scenarios, id(item.ID), common(item.ID, item.TenantID, statusFor(item.Status), item.UpdatedAt, map[string]interface{}{
"名称": item.Name, "行业": item.Industry, "适用角色": item.RoleName, "目标": clip(item.Goal), "触发条件": clip(item.TriggerText), "可见范围": item.Visibility, "业务状态": item.Status, "创建人ID": id(item.CreatedBy),
})); err != nil {
return err
}
if item.Status != "archived" {
return nil
}
fields := make([]model.ScenarioField, 0)
if err := p.db.Where("scenario_id = ? AND tenant_id = ?", item.ID, item.TenantID).Find(&fields).Error; err != nil {
return err
}
for _, field := range fields {
if err := p.projectScenarioField(ctx, field, "已归档"); err != nil {
return err
}
}
sops := make([]model.SOP, 0)
if err := p.db.Where("scenario_id = ? AND tenant_id = ?", item.ID, item.TenantID).Find(&sops).Error; err != nil {
return err
}
for _, sop := range sops {
if err := p.sop(ctx, model.MultiTableOutbox{TenantID: item.TenantID, ResourceID: sop.ID}); err != nil {
return err
}
}
return nil
}
func (p *Projector) scenarioField(ctx context.Context, event model.MultiTableOutbox) error {
var item model.ScenarioField
if err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) && event.Action == "delete" {
return p.deletedScenarioField(ctx, event)
}
return err
}
return p.projectScenarioField(ctx, item, "正常")
}
func (p *Projector) projectScenarioField(ctx context.Context, item model.ScenarioField, syncStatus string) error {
return p.client.Upsert(ctx, p.tables.ScenarioFields, id(item.ID), common(item.ID, item.TenantID, syncStatus, item.UpdatedAt, map[string]interface{}{
"场景来源ID": id(item.ScenarioID), "字段标识": item.FieldKey, "字段名称": item.FieldName, "外部数据路径": item.SourcePath, "字段类型": item.FieldType, "是否必填": yesNo(item.Required), "选项": jsonText(item.Options), "校验规则": jsonText(item.Validation), "排序": item.SortOrder,
}))
}
func (p *Projector) scenarioRule(ctx context.Context, event model.MultiTableOutbox) error {
var item model.ScenarioRule
err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error
if err == nil {
return p.client.Upsert(ctx, p.tables.ScenarioRules, id(item.ID), scenarioRuleProjection(item, "正常"))
}
if !errors.Is(err, gorm.ErrRecordNotFound) || event.Action != "archive" {
return err
}
var payload struct {
ScenarioID uint64 `json:"scenario_id"`
RuleKey string `json:"rule_key"`
Name string `json:"name"`
Priority int `json:"priority"`
Status string `json:"status"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
return err
}
return p.client.Upsert(ctx, p.tables.ScenarioRules, id(event.ResourceID), common(event.ResourceID, event.TenantID, "已归档", event.UpdatedAt, map[string]interface{}{
"场景来源ID": id(payload.ScenarioID), "规则标识": payload.RuleKey, "名称": payload.Name, "优先级": payload.Priority, "规则状态": payload.Status,
}))
}
func (p *Projector) deletedScenarioField(ctx context.Context, event model.MultiTableOutbox) error {
var payload struct {
ScenarioID uint64 `json:"scenario_id"`
FieldKey string `json:"field_key"`
FieldName string `json:"field_name"`
SourcePath string `json:"source_path"`
FieldType string `json:"field_type"`
Required bool `json:"required"`
Options json.RawMessage `json:"options"`
Validation json.RawMessage `json:"validation"`
SortOrder int `json:"sort_order"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
return err
}
if payload.ScenarioID == 0 || payload.FieldKey == "" || payload.FieldName == "" || payload.FieldType == "" {
return errors.New("deleted scenario field audit payload is incomplete")
}
item := model.ScenarioField{Base: model.Base{ID: event.ResourceID, UpdatedAt: event.UpdatedAt}, TenantID: event.TenantID, ScenarioID: payload.ScenarioID, FieldKey: payload.FieldKey, FieldName: payload.FieldName, SourcePath: payload.SourcePath, FieldType: payload.FieldType, Required: payload.Required, Options: datatypes.JSON(payload.Options), Validation: datatypes.JSON(payload.Validation), SortOrder: payload.SortOrder}
return p.projectScenarioField(ctx, item, "已归档")
}
func (p *Projector) sop(ctx context.Context, event model.MultiTableOutbox) error {
var sop model.SOP
if err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&sop).Error; err != nil {
return err
}
versions := make([]model.SOPVersion, 0)
if err := p.db.Where("sop_id = ? AND tenant_id = ? AND status IN ?", sop.ID, sop.TenantID, []string{"published", "superseded", "offline"}).Find(&versions).Error; err != nil {
return err
}
for _, version := range versions {
if err := p.sopVersion(ctx, sop, version); err != nil {
return err
}
}
return nil
}
func (p *Projector) sopVersion(ctx context.Context, sop model.SOP, version model.SOPVersion) error {
nodes := make([]model.SOPNode, 0)
edges := make([]model.SOPEdge, 0)
if err := p.db.Where("sop_version_id = ?", version.ID).Order("id").Find(&nodes).Error; err != nil {
return err
}
if err := p.db.Where("sop_version_id = ?", version.ID).Order("priority, id").Find(&edges).Error; err != nil {
return err
}
state := statusFor(version.Status)
if sop.Status == "archived" {
state = "已归档"
}
if err := p.client.Upsert(ctx, p.tables.SOPVersions, id(version.ID), common(version.ID, version.TenantID, state, version.UpdatedAt, map[string]interface{}{
"SOP来源ID": id(sop.ID), "场景来源ID": id(sop.ScenarioID), "SOP名称": sop.Name, "版本号": version.Version, "版本状态": version.Status, "开始节点": version.StartNodeKey, "发布时间": formatTime(version.PublishedAt), "节点数": len(nodes), "路径数": len(edges),
})); err != nil {
return err
}
for _, node := range nodes {
if err := p.client.Upsert(ctx, p.tables.SOPNodes, id(node.ID), common(node.ID, node.TenantID, state, node.UpdatedAt, map[string]interface{}{
"SOP版本来源ID": id(version.ID), "节点标识": node.NodeKey, "节点类型": node.Type, "标题": node.Title, "标准话术或操作提示": clip(node.Content), "配置": jsonText(node.Config), "排序": node.PositionY,
})); err != nil {
return err
}
}
for _, edge := range edges {
if err := p.client.Upsert(ctx, p.tables.SOPEdges, id(edge.ID), common(edge.ID, edge.TenantID, state, edge.UpdatedAt, map[string]interface{}{
"SOP版本来源ID": id(version.ID), "起点节点标识": edge.SourceNodeKey, "终点节点标识": edge.TargetNodeKey, "条件": jsonText(edge.Condition), "优先级": edge.Priority,
})); err != nil {
return err
}
}
return nil
}
func (p *Projector) knowledgeItem(ctx context.Context, event model.MultiTableOutbox) error {
var item model.KnowledgeItem
err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error
if err == nil {
return p.client.Upsert(ctx, p.tables.KnowledgeItems, id(item.ID), knowledgeItemProjection(item, statusFor(item.Status)))
}
if !errors.Is(err, gorm.ErrRecordNotFound) || event.Action != "archive" {
return err
}
var payload struct {
ScenarioID uint64 `json:"scenario_id"`
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
return err
}
return p.client.Upsert(ctx, p.tables.KnowledgeItems, id(event.ResourceID), common(event.ResourceID, event.TenantID, "已归档", event.UpdatedAt, map[string]interface{}{
"场景来源ID": id(payload.ScenarioID), "知识标识": payload.Key, "名称": payload.Name, "知识类型": payload.Type, "知识状态": payload.Status, "排序": payload.SortOrder,
}))
}
func (p *Projector) knowledgeRelation(ctx context.Context, event model.MultiTableOutbox) error {
var item model.KnowledgeRelation
err := p.db.Where("id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).First(&item).Error
if err == nil {
return p.client.Upsert(ctx, p.tables.KnowledgeRelations, id(item.ID), knowledgeRelationProjection(item, "正常"))
}
if !errors.Is(err, gorm.ErrRecordNotFound) || event.Action != "archive" {
return err
}
var payload struct {
ScenarioID uint64 `json:"scenario_id"`
FromKnowledge uint64 `json:"from_knowledge_id"`
RelationType string `json:"relation_type"`
ToKnowledge uint64 `json:"to_knowledge_id"`
SortOrder int `json:"sort_order"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
return err
}
return p.client.Upsert(ctx, p.tables.KnowledgeRelations, id(event.ResourceID), common(event.ResourceID, event.TenantID, "已归档", event.UpdatedAt, map[string]interface{}{
"场景来源ID": id(payload.ScenarioID), "起点知识来源ID": id(payload.FromKnowledge), "关系类型": payload.RelationType, "终点知识来源ID": id(payload.ToKnowledge), "排序": payload.SortOrder,
}))
}
func (p *Projector) run(ctx context.Context, event model.MultiTableOutbox) error {
var row struct {
model.SOPRun
SOPName string
ScenarioName string
}
err := p.db.Table("sop_runs r").Select("r.*, s.name AS sop_name, sc.name AS scenario_name").Joins("JOIN sops s ON s.id = r.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Where("r.id = ? AND r.tenant_id = ?", event.ResourceID, event.TenantID).Scan(&row).Error
if err != nil {
return err
}
if row.ID == 0 {
return gorm.ErrRecordNotFound
}
var answerCount int
var answers map[string]interface{}
_ = json.Unmarshal(row.Answers, &answers)
answerCount = len(answers)
return p.client.Upsert(ctx, p.tables.Runs, id(row.ID), common(row.ID, row.TenantID, "正常", row.UpdatedAt, map[string]interface{}{
"SOP来源ID": id(row.SOPID), "SOP版本来源ID": id(row.SOPVersionID), "场景名称": row.ScenarioName, "执行人ID": id(row.OperatorID), "执行状态": row.Status, "执行结果": row.Result, "最终结果": jsonText(row.FinalResult), "已采集字段数": answerCount, "开始时间": formatTime(&row.StartedAt), "完成时间": formatTime(row.CompletedAt),
}))
}
func (p *Projector) feedback(ctx context.Context, event model.MultiTableOutbox) error {
items := make([]model.SOPFeedback, 0)
if err := p.db.Where("run_id = ? AND tenant_id = ?", event.ResourceID, event.TenantID).Find(&items).Error; err != nil {
return err
}
for _, item := range items {
if err := p.client.Upsert(ctx, p.tables.Feedback, id(item.ID), common(item.ID, item.TenantID, "正常", item.UpdatedAt, map[string]interface{}{
"执行来源ID": id(item.RunID), "提交人ID": id(item.UserID), "评分": item.Score, "反馈内容": clip(item.Comment), "提交时间": item.CreatedAt.Format(time.RFC3339),
})); err != nil {
return err
}
}
return nil
}
func common(sourceID, tenantID uint64, status string, updated time.Time, extra map[string]interface{}) map[string]interface{} {
data := map[string]interface{}{"来源ID": id(sourceID), "业务租户ID": tenantID, "同步状态": status, "来源更新时间": updated.Format(time.RFC3339)}
for key, value := range extra {
data[key] = value
}
return data
}
func scenarioRuleProjection(item model.ScenarioRule, status string) map[string]interface{} {
return common(item.ID, item.TenantID, status, item.UpdatedAt, map[string]interface{}{"场景来源ID": id(item.ScenarioID), "规则标识": item.RuleKey, "名称": item.Name, "优先级": item.Priority, "规则状态": item.Status})
}
func knowledgeItemProjection(item model.KnowledgeItem, status string) map[string]interface{} {
return common(item.ID, item.TenantID, status, item.UpdatedAt, map[string]interface{}{"场景来源ID": id(item.ScenarioID), "知识标识": item.ItemKey, "名称": item.Name, "知识类型": item.Type, "知识状态": item.Status, "排序": item.SortOrder})
}
func knowledgeRelationProjection(item model.KnowledgeRelation, status string) map[string]interface{} {
return common(item.ID, item.TenantID, status, item.UpdatedAt, map[string]interface{}{"场景来源ID": id(item.ScenarioID), "起点知识来源ID": id(item.FromKnowledgeID), "关系类型": item.RelationType, "终点知识来源ID": id(item.ToKnowledgeID), "排序": item.SortOrder})
}
func id(value uint64) string { return strconv.FormatUint(value, 10) }
func formatTime(value *time.Time) string {
if value == nil {
return ""
}
return value.Format(time.RFC3339)
}
func statusFor(value string) string {
if value == "archived" {
return "已归档"
}
if value == "offline" {
return "已下线"
}
return "正常"
}
func jsonText(value []byte) string { return clip(string(value)) }
func stringValue(value interface{}) string { result, _ := value.(string); return result }
func clip(value string) string {
if len(value) > 4000 {
return value[:4000]
}
return value
}
func yesNo(value bool) string {
if value {
return "是"
}
return "否"
}

View File

@@ -0,0 +1,30 @@
package multitable
import (
"testing"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func TestNewResourceProjections(t *testing.T) {
now := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
data map[string]interface{}
want map[string]interface{}
}{
{"scenario rule", scenarioRuleProjection(model.ScenarioRule{Base: model.Base{ID: 11, UpdatedAt: now}, TenantID: 2, ScenarioID: 3, RuleKey: "match", Name: "匹配", Priority: 10, Status: "active"}, "正常"), map[string]interface{}{"来源ID": "11", "场景来源ID": "3", "规则标识": "match", "优先级": 10}},
{"knowledge item", knowledgeItemProjection(model.KnowledgeItem{Base: model.Base{ID: 12, UpdatedAt: now}, TenantID: 2, ScenarioID: 3, ItemKey: "soft", Name: "软便", Type: "symptom", Status: "active", SortOrder: 4}, "正常"), map[string]interface{}{"来源ID": "12", "知识标识": "soft", "知识类型": "symptom", "排序": 4}},
{"knowledge relation", knowledgeRelationProjection(model.KnowledgeRelation{Base: model.Base{ID: 13, UpdatedAt: now}, TenantID: 2, ScenarioID: 3, FromKnowledgeID: 12, RelationType: "recommended_copy", ToKnowledgeID: 14, SortOrder: 5}, "正常"), map[string]interface{}{"来源ID": "13", "起点知识来源ID": "12", "关系类型": "recommended_copy", "终点知识来源ID": "14"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for key, want := range test.want {
if got := test.data[key]; got != want {
t.Fatalf("%s=%v want %v; data=%#v", key, got, want, test.data)
}
}
})
}
}

View File

@@ -0,0 +1,101 @@
package multitable
import (
"context"
"fmt"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Worker struct {
db *gorm.DB
projector *Projector
config config.MultiTableConfig
log *zap.Logger
}
func NewWorker(db *gorm.DB, cfg config.MultiTableConfig, log *zap.Logger) *Worker {
return &Worker{db: db, projector: NewProjector(db, NewClient(cfg.BaseURL, cfg.APIKey, cfg.RequestTimeout), cfg.Tables), config: cfg, log: log.Named("multitable")}
}
func (w *Worker) Run(ctx context.Context) {
// A prior process may have stopped while an event was claimed.
w.db.Model(&model.MultiTableOutbox{}).Where("status = ?", "processing").Update("status", "pending")
w.process(ctx)
ticker := time.NewTicker(w.config.SyncInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.process(ctx)
}
}
}
func (w *Worker) process(ctx context.Context) {
for _, event := range w.claim() {
if err := w.projector.Project(ctx, event); err != nil {
w.retry(event, err)
continue
}
if err := w.db.Model(&model.MultiTableOutbox{}).Where("id = ?", event.ID).Updates(map[string]interface{}{"status": "succeeded", "last_error": ""}).Error; err != nil {
w.log.Error("mark outbox succeeded", zap.Uint64("event_id", event.ID), zap.Error(err))
}
}
}
func (w *Worker) claim() []model.MultiTableOutbox {
items := make([]model.MultiTableOutbox, 0)
err := w.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ? AND available_at <= ?", "pending", time.Now()).Order("id").Limit(w.config.BatchSize).Find(&items).Error; err != nil {
return err
}
for i := range items {
if err := tx.Model(&model.MultiTableOutbox{}).Where("id = ? AND status = ?", items[i].ID, "pending").Updates(map[string]interface{}{"status": "processing", "attempts": gorm.Expr("attempts + 1")}).Error; err != nil {
return err
}
items[i].Attempts++
}
return nil
})
if err != nil {
w.log.Error("claim multitable outbox", zap.Error(err))
return nil
}
return items
}
func (w *Worker) retry(event model.MultiTableOutbox, cause error) {
status := "pending"
availableAt := time.Now().Add(backoff(event.Attempts))
if event.Attempts >= w.config.MaxAttempts {
status = "failed"
}
message := truncate(cause.Error(), 1024)
if err := w.db.Model(&model.MultiTableOutbox{}).Where("id = ?", event.ID).Updates(map[string]interface{}{"status": status, "available_at": availableAt, "last_error": message}).Error; err != nil {
w.log.Error("reschedule multitable event", zap.Uint64("event_id", event.ID), zap.Error(err))
return
}
w.log.Warn("multitable projection failed", zap.Uint64("event_id", event.ID), zap.String("resource", event.Resource), zap.String("action", event.Action), zap.String("status", status), zap.Error(cause))
}
func backoff(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
if attempt > 8 {
attempt = 8
}
return time.Second * time.Duration(1<<(attempt-1))
}
func (w *Worker) String() string {
return fmt.Sprintf("multitable worker (every %s)", w.config.SyncInterval)
}

View File

@@ -0,0 +1,196 @@
package resultcontract
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var keyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
type Schema struct {
Fields []Field `json:"fields"`
}
type Field struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
Options []any `json:"options,omitempty"`
Fields []Field `json:"fields,omitempty"`
Items *Field `json:"items,omitempty"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
Default interface{} `json:"default,omitempty"`
}
func ParseAndValidate(raw []byte) (Schema, error) {
var schema Schema
if len(raw) == 0 {
return schema, nil
}
if err := json.Unmarshal(raw, &schema); err != nil {
return schema, fmt.Errorf("result_schema 格式不正确")
}
if err := validateFields(schema.Fields, 0); err != nil {
return schema, err
}
return schema, nil
}
func ValidateSchema(value map[string]interface{}) error {
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("result_schema 格式不正确")
}
_, err = ParseAndValidate(raw)
return err
}
// ValidateResult permits nil and an empty object so a run can finish without a business outcome.
func ValidateResult(schema Schema, value map[string]interface{}) error {
if len(value) == 0 {
return nil
}
return validateObject(schema.Fields, value, "final_result")
}
func validateFields(fields []Field, depth int) error {
if depth > 8 {
return fmt.Errorf("result_schema 嵌套层级不能超过 8 层")
}
seen := map[string]bool{}
for _, field := range fields {
if !keyPattern.MatchString(field.Key) || seen[field.Key] {
return fmt.Errorf("结果字段标识必须唯一且格式正确")
}
seen[field.Key] = true
if field.Name == "" {
return fmt.Errorf("结果字段 %s 缺少名称", field.Key)
}
if !supportedType(field.Type) {
return fmt.Errorf("结果字段 %s 使用了不支持的类型 %s", field.Key, field.Type)
}
if field.Type == "object" {
if len(field.Fields) == 0 {
return fmt.Errorf("对象字段 %s 必须定义 fields", field.Key)
}
if err := validateFields(field.Fields, depth+1); err != nil {
return err
}
}
if field.Type == "array" && field.Items != nil {
item := *field.Items
if item.Type == "" {
return fmt.Errorf("数组字段 %s 的 items 必须定义类型", field.Key)
}
if item.Type == "object" {
if len(item.Fields) == 0 {
return fmt.Errorf("数组字段 %s 的对象项必须定义 fields", field.Key)
}
if err := validateFields(item.Fields, depth+1); err != nil {
return err
}
} else if !supportedType(item.Type) || item.Type == "array" {
return fmt.Errorf("数组字段 %s 的 items 类型不正确", field.Key)
}
}
}
return nil
}
func supportedType(value string) bool {
switch value {
case "string", "text", "textarea", "number", "integer", "boolean", "select", "multiselect", "date", "array", "object", "any":
return true
default:
return false
}
}
func validateObject(fields []Field, value map[string]interface{}, path string) error {
definitions := make(map[string]Field, len(fields))
for _, field := range fields {
definitions[field.Key] = field
if field.Required {
if item, ok := value[field.Key]; !ok || item == nil || item == "" {
return fmt.Errorf("%s.%s 为必填字段", path, field.Key)
}
}
}
for key, item := range value {
field, ok := definitions[key]
if !ok {
return fmt.Errorf("%s.%s 未在结果格式中定义", path, key)
}
if err := validateValue(field, item, path+"."+key); err != nil {
return err
}
}
return nil
}
func validateValue(field Field, value interface{}, path string) error {
if value == nil {
if field.Required {
return fmt.Errorf("%s 为必填字段", path)
}
return nil
}
switch field.Type {
case "string", "text", "textarea", "select", "date":
text, ok := value.(string)
if !ok {
return fmt.Errorf("%s 必须是字符串", path)
}
if field.Type == "select" && len(field.Options) > 0 && !contains(field.Options, text) {
return fmt.Errorf("%s 不在允许选项中", path)
}
case "number", "integer":
number, ok := value.(float64)
if !ok || (field.Type == "integer" && number != float64(int64(number))) {
return fmt.Errorf("%s 必须是%s", path, map[bool]string{true: "整数", false: "数字"}[field.Type == "integer"])
}
if field.Min != nil && number < *field.Min || field.Max != nil && number > *field.Max {
return fmt.Errorf("%s 超出允许范围", path)
}
case "boolean":
if _, ok := value.(bool); !ok {
return fmt.Errorf("%s 必须是布尔值", path)
}
case "object":
object, ok := value.(map[string]interface{})
if !ok {
return fmt.Errorf("%s 必须是对象", path)
}
return validateObject(field.Fields, object, path)
case "array", "multiselect":
items, ok := value.([]interface{})
if !ok {
return fmt.Errorf("%s 必须是数组", path)
}
for index, item := range items {
if field.Type == "multiselect" && len(field.Options) > 0 && !contains(field.Options, item) {
return fmt.Errorf("%s[%d] 不在允许选项中", path, index)
}
if field.Items != nil {
if err := validateValue(*field.Items, item, fmt.Sprintf("%s[%d]", path, index)); err != nil {
return err
}
}
}
}
return nil
}
func contains(options []any, value interface{}) bool {
want := fmt.Sprint(value)
for _, option := range options {
if strings.EqualFold(fmt.Sprint(option), want) {
return true
}
}
return false
}

View File

@@ -0,0 +1,21 @@
package resultcontract
import "testing"
func TestValidateResult(t *testing.T) {
schema, err := ParseAndValidate([]byte(`{"fields":[{"key":"recommended_products","name":"成功推荐商品","type":"array","items":{"type":"object","fields":[{"key":"product_id","name":"商品 ID","type":"string","required":true},{"key":"quantity","name":"数量","type":"integer"}]}},{"key":"note","name":"备注","type":"text"}]}`))
if err != nil {
t.Fatal(err)
}
if err := ValidateResult(schema, nil); err != nil {
t.Fatalf("empty result should be allowed: %v", err)
}
valid := map[string]interface{}{"recommended_products": []interface{}{map[string]interface{}{"product_id": "A", "quantity": float64(1)}}}
if err := ValidateResult(schema, valid); err != nil {
t.Fatalf("valid result rejected: %v", err)
}
invalid := map[string]interface{}{"recommended_products": []interface{}{map[string]interface{}{"quantity": float64(1)}}}
if err := ValidateResult(schema, invalid); err == nil {
t.Fatal("missing nested required field should be rejected")
}
}

View File

@@ -1,6 +1,7 @@
package run
import (
"encoding/json"
"net/http"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
@@ -14,17 +15,17 @@ type DetailHeader struct {
model.SOPRun
SOPName string `json:"sop_name"`
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
OperatorName string `json:"operator_name"`
}
type DetailEvent struct {
model.SOPRunEvent
NodeTitle string `json:"node_title"`
NodeType string `json:"node_type"`
NodeContent string `json:"node_content"`
NodeConfig datatypes.JSON `json:"-"`
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
NodeTitle string `json:"node_title"`
NodeType string `json:"node_type"`
NodeContent string `json:"node_content"`
NodeConfig datatypes.JSON `json:"-"`
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"`
}
type DetailFeedback struct {
@@ -40,8 +41,8 @@ func (h *Handler) Detail(c *gin.Context) {
}
var header DetailHeader
err := h.db.Table("sop_runs r").Select(
"r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name",
).Joins("JOIN sops s ON s.id = r.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").Joins("JOIN users u ON u.id = r.operator_id").Where("r.id = ? AND r.tenant_id = ?", id, principal.TenantID).Scan(&header).Error
"r.*, s.name AS sop_name, sc.name AS scenario_name, u.display_name AS operator_name",
).Joins("JOIN sops s ON s.id = r.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN users u ON u.id = r.operator_id").Where("r.id = ? AND r.tenant_id = ?", id, principal.TenantID).Scan(&header).Error
if err != nil || header.ID == 0 || !canViewRun(principal, header.SOPRun) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
@@ -52,6 +53,14 @@ func (h *Handler) Detail(c *gin.Context) {
return
}
knowledgeCache := map[string]*KnowledgeView{}
answers := map[string]interface{}{}
_ = json.Unmarshal(header.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(header.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(header.Input, &input)
context := runtimeContext(input, derived, answers)
context["__knowledge_snapshot"] = json.RawMessage(header.KnowledgeSnapshot)
for i := range events {
if events[i].NodeType != "knowledge" {
continue
@@ -61,9 +70,23 @@ func (h *Handler) Detail(c *gin.Context) {
events[i].Knowledge = cached
continue
}
knowledge, err := loadKnowledgeView(h.db, model.SOPNode{Type: events[i].NodeType, Config: events[i].NodeConfig}, principal.TenantID)
var config knowledgeNodeConfig
_ = json.Unmarshal(events[i].NodeConfig, &config)
if config.KnowledgeSelector != nil {
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 {
outputs, loadErr := loadKnowledgeOutputs(h.db, node, principal.TenantID, context, *config.KnowledgeSelector)
if loadErr != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录知识快照不可用")
return
}
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", "执行记录关联的知识卡版本不存在")
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的历史知识内容不存在")
return
}
events[i].Knowledge = &knowledge

View File

@@ -66,7 +66,7 @@ func matchRule(rule map[string]interface{}, answers map[string]interface{}) (boo
if field == "" || operator == "" {
return false, fmt.Errorf("condition field and operator are required")
}
actual, exists := answers[field]
actual, exists := lookupContextValue(answers, field)
expected := rule["value"]
switch operator {
case "exists":
@@ -105,6 +105,35 @@ func matchRule(rule map[string]interface{}, answers map[string]interface{}) (boo
}
}
// lookupContextValue accepts the public namespaced form (input.foo/derived.foo)
// and the legacy bare form used by SOP edge conditions.
func lookupContextValue(values map[string]interface{}, field string) (interface{}, bool) {
if value, ok := values[field]; ok {
return value, true
}
for _, prefix := range []string{"input.", "derived.", "form."} {
if strings.HasPrefix(field, prefix) {
key := strings.TrimPrefix(field, prefix)
if namespace, ok := values[strings.TrimSuffix(prefix, ".")].(map[string]interface{}); ok {
value, exists := namespace[key]
return value, exists
}
value, ok := values[key]
return value, ok
}
}
return nil, false
}
func runtimeContext(input, derived, form map[string]interface{}) map[string]interface{} {
context := mergeValues(input, derived)
context = mergeValues(context, form)
context["input"] = input
context["derived"] = derived
context["form"] = form
return context
}
func normalizeValue(value interface{}) interface{} {
switch typed := value.(type) {
case json.Number:

View File

@@ -0,0 +1,50 @@
package run
import (
"reflect"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
)
func TestGenericRuntimeSupportsDifferentDomains(t *testing.T) {
tests := []struct {
name string
fields []model.ScenarioField
input map[string]interface{}
rules []model.ScenarioRule
wantInput map[string]interface{}
wantOutput interface{}
}{
{
name: "pet order",
fields: []model.ScenarioField{{FieldKey: "tags", SourcePath: "order.items[*].symptom_tags[*]"}},
input: map[string]interface{}{"order": map[string]interface{}{"items": []interface{}{map[string]interface{}{"symptom_tags": []interface{}{"soft_stool"}}}}},
rules: []model.ScenarioRule{{RuleKey: "pet", Condition: datatypes.JSON([]byte(`{"field":"input.tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched","value_from":"input.tags"}]`))}},
wantInput: map[string]interface{}{"tags": []interface{}{"soft_stool"}}, wantOutput: []interface{}{"soft_stool"},
},
{
name: "course recommendation",
fields: []model.ScenarioField{{FieldKey: "goals", SourcePath: "learner.goals[*]"}},
input: map[string]interface{}{"learner": map[string]interface{}{"goals": []interface{}{"presentation"}}},
rules: []model.ScenarioRule{{RuleKey: "course", Condition: datatypes.JSON([]byte(`{"field":"input.goals","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched","value_from":"input.goals"}]`))}},
wantInput: map[string]interface{}{"goals": []interface{}{"presentation"}}, wantOutput: []interface{}{"presentation"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mapped := mapScenarioInput(test.fields, test.input)
if !reflect.DeepEqual(mapped, test.wantInput) {
t.Fatalf("mapped=%#v", mapped)
}
derived, _, err := applyScenarioRules(test.rules, mapped)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(derived["matched"], test.wantOutput) {
t.Fatalf("derived=%#v", derived)
}
})
}
}

View File

@@ -13,7 +13,9 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -27,7 +29,6 @@ type listItem struct {
model.SOPRun
SOPName string `json:"sop_name"`
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
OperatorName string `json:"operator_name"`
}
@@ -40,7 +41,7 @@ func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db}
}
func (h *Handler) PublishedSOPs(c *gin.Context) {
func (h *Handler) AvailableSOPs(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
type item struct {
ID uint64 `json:"id"`
@@ -48,14 +49,13 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
Description string `json:"description"`
ScenarioID uint64 `json:"scenario_id"`
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
}
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, sv.version").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").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published")
query = access.ScopeScenarios(query, p, "sc")
err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可执行 SOP 失败")
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可 SOP 失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
@@ -64,27 +64,90 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
func (h *Handler) Start(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
var input struct {
SOPID uint64 `json:"sop_id" binding:"required"`
SOPID uint64 `json:"sop_id" binding:"required"`
Input map[string]interface{} `json:"input"`
ExternalRef string `json:"external_ref"`
InitialValues map[string]interface{} `json:"initial_values"`
InitialAnswers map[string]interface{} `json:"initial_answers"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP")
return
}
if !access.CanViewSOP(h.db, p, input.SOPID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布 SOP")
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP")
return
}
if err := validateExternalRef(input.ExternalRef); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_EXTERNAL_REF", err.Error())
return
}
if input.ExternalRef != "" {
var existing model.SOPRun
if err := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", p.TenantID, input.SOPID, input.ExternalRef).First(&existing).Error; err == nil {
h.respondRun(c, existing)
return
}
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布版本")
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP")
return
}
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, SOPVersionID: version.ID, OperatorID: p.UserID, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON([]byte(`{}`)), Result: "", StartedAt: time.Now()}
err := h.db.Transaction(func(tx *gorm.DB) error {
fields := make([]model.ScenarioField, 0)
if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", input.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
return
}
var sopItem model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", input.SOPID, p.TenantID).First(&sopItem).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
if input.Input == nil {
input.Input = map[string]interface{}{}
}
if input.InitialValues == nil {
input.InitialValues = input.InitialAnswers
}
if input.InitialValues == nil {
input.InitialValues = map[string]interface{}{}
}
normalizedInput := mergeValues(mapScenarioInput(fields, input.Input), input.InitialValues)
if err := validateInitialAnswers(fields, normalizedInput); err != nil {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INITIAL_ANSWERS", err.Error())
return
}
initialAnswers, marshalErr := json.Marshal(normalizedInput)
if marshalErr != nil {
response.Error(c, http.StatusBadRequest, "INVALID_INITIAL_ANSWERS", "传入字段格式不正确")
return
}
derived, matchedRules, err := deriveForScenario(h.db, p.TenantID, sopItem.ScenarioID, normalizedInput)
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
return
}
derivedRaw, _ := json.Marshal(derived)
knowledgeRaw, err := snapshotKnowledge(h.db, p.TenantID, sopItem.ScenarioID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败")
return
}
externalRef := input.ExternalRef
if externalRef == "" {
externalRef = "run-" + uuid.NewString()
}
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, SOPVersionID: version.ID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON(initialAnswers), Input: datatypes.JSON(initialAnswers), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), Result: "", StartedAt: time.Now()}
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&run).Error; err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON([]byte(`{}`))}).Error
payload, err := json.Marshal(gin.H{"source": "scenario_input", "mapped_field_keys": sortedKeys(normalizedInput), "matched_rule_keys": matchedRules})
if err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON(payload)}).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动 SOP 失败")
@@ -118,7 +181,6 @@ func (h *Handler) List(c *gin.Context) {
query := scopeRuns(h.db.Table("sop_runs r").
Joins("JOIN sops s ON s.id = r.sop_id").
Joins("JOIN scenarios sc ON sc.id = s.scenario_id").
Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").
Joins("JOIN users u ON u.id = r.operator_id"), p, "r")
if status := c.Query("status"); status != "" {
query = query.Where("r.status = ?", status)
@@ -144,7 +206,10 @@ func (h *Handler) List(c *gin.Context) {
return
}
items := make([]listItem, 0)
if err := query.Select("r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
// The run payload contains several large JSON snapshots. The history list only
// needs scalar metadata; excluding blobs keeps MySQL's sort buffer bounded.
listColumns := "r.id, r.created_at, r.updated_at, r.tenant_id, r.sop_id, r.sop_version_id, r.operator_id, r.external_ref, r.current_node_key, r.status, r.result, r.started_at, r.completed_at, s.name AS sop_name, sc.name AS scenario_name, u.display_name AS operator_name"
if err := query.Select(listColumns).Order("r.created_at DESC, r.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
return
}
@@ -220,6 +285,9 @@ func (h *Handler) Answer(c *gin.Context) {
for key, value := range input.Answers {
answers[key] = value
}
if err := validateKnowledgeSelections(currentNode, updated, answers); err != nil {
return err
}
var edges []model.SOPEdge
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
return err
@@ -283,6 +351,15 @@ func sortEdges(edges []model.SOPEdge) {
})
}
func sortedKeys(values map[string]interface{}) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func defaultCondition(raw []byte) bool {
if len(raw) == 0 {
return true
@@ -302,10 +379,11 @@ func (h *Handler) Finish(c *gin.Context) {
return
}
var input struct {
Result string `json:"result" binding:"required,oneof=manual"`
Result string `json:"result"`
FinalResult json.RawMessage `json:"final_result"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "执行结果格式不正确")
return
}
var run model.SOPRun
@@ -317,20 +395,51 @@ func (h *Handler) Finish(c *gin.Context) {
if !canOperateRun(p, run) {
return gorm.ErrRecordNotFound
}
if run.Status != "running" {
if run.Status != "running" && run.Status != "completed" {
return runCompletedErr
}
now := time.Now()
payload, _ := json.Marshal(input)
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
finalResult, err := parseFinalResult(input.FinalResult)
if err != nil {
return err
}
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now}).Error; err != nil {
var scenario model.Scenario
if err := tx.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
return err
}
schema, err := resultcontract.ParseAndValidate(scenario.ResultSchema)
if err != nil {
return err
}
if err := resultcontract.ValidateResult(schema, finalResult); err != nil {
return err
}
finalRaw, _ := json.Marshal(finalResult)
if input.Result == "" {
input.Result = run.Result
if input.Result == "" {
input.Result = "manual"
}
}
now := time.Now()
completedAt := run.CompletedAt
if completedAt == nil {
completedAt = &now
}
payload, _ := json.Marshal(input)
action := "finish"
if run.Status == "completed" {
action = "final_result"
}
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: action, Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "final_result": datatypes.JSON(finalRaw), "completed_at": completedAt}).Error; err != nil {
return err
}
run.Status = "completed"
run.Result = input.Result
run.CompletedAt = &now
run.FinalResult = datatypes.JSON(finalRaw)
run.CompletedAt = completedAt
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -342,7 +451,7 @@ func (h *Handler) Finish(c *gin.Context) {
return
}
if err != nil {
response.Error(c, http.StatusInternalServerError, "FINISH_FAILED", "结束执行失败")
response.Error(c, http.StatusUnprocessableEntity, "FINISH_FAILED", err.Error())
return
}
_ = audit.Record(h.db, p, "finish", "sop_run", id, input)
@@ -387,17 +496,39 @@ func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
nodeView, err := h.nodeView(h.db, node, item.TenantID)
answers := map[string]interface{}{}
_ = json.Unmarshal(item.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(item.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(item.Input, &input)
context := runtimeContext(input, derived, answers)
context["__knowledge_snapshot"] = json.RawMessage(item.KnowledgeSnapshot)
nodeView, err := h.nodeView(h.db, node, item.TenantID, context)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的知识卡版本不存在")
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的历史知识内容不存在")
return
}
outputs, err := buildScenarioOutputs(h.db, item, nodeView.Outputs)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
return
}
if raw, marshalErr := json.Marshal(outputs); marshalErr == nil {
item.Outputs = datatypes.JSON(raw)
_ = h.db.Model(&model.SOPRun{}).Where("id = ?", item.ID).Update("outputs", item.Outputs).Error
}
fields := make([]model.ScenarioField, 0)
if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
return
}
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields})
var scenario model.Scenario
if err := h.db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", item.SOPID, item.TenantID).First(&scenario).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景结果格式失败")
return
}
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields, "outputs": outputs, "result_schema": scenario.ResultSchema})
}
func runID(c *gin.Context) (uint64, bool) {

View File

@@ -2,37 +2,173 @@ package run
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
knowledgecontent "git.iwork-ai.com/xdc/iqudo-top1/internal/knowledge"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
var knowledgePlaceholderPattern = regexp.MustCompile(`\{\{\s*(?:input|derived|form)\.([A-Za-z][A-Za-z0-9_]*)\s*\}\}`)
type KnowledgeView struct {
CardID uint64 `json:"card_id"`
CardVersionID uint64 `json:"card_version_id"`
Version int `json:"version"`
Title string `json:"title"`
StandardCopy string `json:"standard_copy"`
ForbiddenCopy string `json:"forbidden_copy"`
RiskNote string `json:"risk_note"`
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 {
model.SOPNode
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
NodeKey string `json:"node_key"`
Type string `json:"type"`
Title string `json:"title"`
Content string `json:"content"`
Config json.RawMessage `json:"config,omitempty"`
Fields []PublicFieldView `json:"fields,omitempty"`
Presentation *NodePresentation `json:"presentation,omitempty"`
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
Outputs []KnowledgeGroup `json:"outputs"`
Collection *KnowledgeCollectionView `json:"collection,omitempty"`
}
type PublicFieldView struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
Options json.RawMessage `json:"options"`
Validation json.RawMessage `json:"validation"`
}
type KnowledgeGroup struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Content json.RawMessage `json:"content"`
Relations map[string][]KnowledgeItemView `json:"relations"`
Suggested bool `json:"suggested,omitempty"`
}
type KnowledgeItemView struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Content json.RawMessage `json:"content"`
}
type knowledgeNodeConfig struct {
KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
KnowledgeSelector *knowledgeSelector `json:"knowledge_selector"`
KnowledgeCollection *knowledgeCollectionConfig `json:"knowledge_collection"`
}
func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (NodeView, error) {
view := NodeView{SOPNode: node}
type knowledgeCollectionConfig struct {
ContextFieldKeys []string `json:"context_field_keys"`
ContextTitle string `json:"context_title"`
ContextHint string `json:"context_hint"`
SelectionTitle string `json:"selection_title"`
SelectionHint string `json:"selection_hint"`
Steps []knowledgeCollectionStep `json:"steps"`
}
type knowledgeCollectionStep struct {
FieldKey string `json:"field_key"`
Name string `json:"name"`
Root bool `json:"root"`
CandidateScope string `json:"candidate_scope"`
KnowledgeTypes []string `json:"knowledge_types"`
FromField string `json:"from_field"`
RelationType string `json:"relation_type"`
Required bool `json:"required"`
Multiple bool `json:"multiple"`
}
type KnowledgeCollectionView struct {
ContextFields []PublicFieldView `json:"context_fields"`
ContextTitle string `json:"context_title"`
ContextHint string `json:"context_hint"`
SelectionTitle string `json:"selection_title"`
SelectionHint string `json:"selection_hint"`
Steps []KnowledgeCollectionStepView `json:"steps"`
}
type KnowledgeCollectionStepView struct {
FieldKey string `json:"field_key"`
Name string `json:"name"`
Required bool `json:"required"`
Multiple bool `json:"multiple"`
FromField string `json:"from_field,omitempty"`
Options []KnowledgeCollectionOption `json:"options"`
}
type KnowledgeCollectionOption struct {
Value string `json:"value"`
Label string `json:"label"`
Parents []string `json:"parents,omitempty"`
Suggested bool `json:"suggested,omitempty"`
}
type knowledgeSelector struct {
DerivedField string `json:"derived_field"`
AnswerField string `json:"answer_field"`
CandidateScope string `json:"candidate_scope"`
KnowledgeTypes []string `json:"knowledge_types"`
KnowledgeKeys []string `json:"knowledge_keys"`
RelationTypes []string `json:"relation_types"`
RelationLabels map[string]string `json:"relation_labels"`
}
func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64, answers map[string]interface{}) (NodeView, error) {
view := NodeView{NodeKey: node.NodeKey, Type: node.Type, Title: node.Title, Content: node.Content, Config: json.RawMessage(node.Config)}
if node.Type == "start" {
presentation, err := loadStartPresentation(db, node, tenantID, answers)
if err != nil {
return view, err
}
view.Presentation = presentation
}
if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
fields, err := loadPublicNodeFields(db, node, tenantID)
if err != nil {
return view, err
}
view.Fields = fields
}
if node.Type != "knowledge" {
return view, nil
}
knowledge, err := loadKnowledgeView(db, node, tenantID)
var config knowledgeNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return view, err
}
if config.KnowledgeSelector != nil {
outputs, err := loadKnowledgeOutputs(db, node, tenantID, answers, *config.KnowledgeSelector)
view.Outputs = outputs
if err != nil {
return view, err
}
if config.KnowledgeCollection != nil {
collection, err := loadKnowledgeCollection(db, node, tenantID, *config.KnowledgeCollection, outputs, answers)
view.Collection = &collection
return view, err
}
return view, nil
}
knowledge, err := loadKnowledgeView(db, node, tenantID, answers)
if err != nil {
return view, err
}
@@ -40,7 +176,366 @@ func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (No
return view, nil
}
func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (KnowledgeView, error) {
func loadKnowledgeCollection(db *gorm.DB, node model.SOPNode, tenantID uint64, config knowledgeCollectionConfig, roots []KnowledgeGroup, context map[string]interface{}) (KnowledgeCollectionView, error) {
fields, err := loadPublicFieldsByKeys(db, node, tenantID, config.ContextFieldKeys)
if err != nil {
return KnowledgeCollectionView{}, err
}
view := KnowledgeCollectionView{ContextFields: fields, ContextTitle: config.ContextTitle, ContextHint: config.ContextHint, SelectionTitle: config.SelectionTitle, SelectionHint: config.SelectionHint, Steps: make([]KnowledgeCollectionStepView, 0, len(config.Steps))}
childrenByParent := map[string]map[string][]string{}
allItems := make([]model.KnowledgeItem, 0)
if raw, ok := context["__knowledge_snapshot"].(json.RawMessage); ok && len(raw) > 0 {
snapshot, parseErr := parseKnowledgeSnapshot(raw)
if parseErr != nil {
return KnowledgeCollectionView{}, parseErr
}
byID := map[uint64]model.KnowledgeItem{}
for _, item := range snapshot.Items {
byID[item.ID] = item
if item.Status == "active" {
allItems = append(allItems, item)
}
}
for _, relation := range snapshot.Relations {
from, fromOK := byID[relation.FromKnowledgeID]
to, toOK := byID[relation.ToKnowledgeID]
if !fromOK || !toOK || from.Status != "active" || to.Status != "active" {
continue
}
if childrenByParent[from.Name] == nil {
childrenByParent[from.Name] = map[string][]string{}
}
childrenByParent[from.Name][relation.RelationType] = appendUnique(childrenByParent[from.Name][relation.RelationType], to.Name)
}
}
suggestedRoots := make(map[string]bool, len(roots))
rootOptions := make([]KnowledgeCollectionOption, 0, len(roots))
for _, root := range roots {
suggestedRoots[root.Name] = root.Suggested
rootOptions = append(rootOptions, KnowledgeCollectionOption{Value: root.Name, Label: root.Name, Suggested: root.Suggested})
}
for _, step := range config.Steps {
item := KnowledgeCollectionStepView{FieldKey: step.FieldKey, Name: step.Name, Required: step.Required, Multiple: step.Multiple, FromField: step.FromField, Options: []KnowledgeCollectionOption{}}
if step.Root {
item.Options = append(item.Options, rootOptions...)
if step.CandidateScope == "all" {
seen := map[string]bool{}
for _, option := range item.Options {
seen[option.Value] = true
}
for _, knowledgeItem := range allItems {
if seen[knowledgeItem.Name] || (len(step.KnowledgeTypes) > 0 && !containsString(step.KnowledgeTypes, knowledgeItem.Type)) {
continue
}
item.Options = append(item.Options, KnowledgeCollectionOption{Value: knowledgeItem.Name, Label: knowledgeItem.Name, Suggested: suggestedRoots[knowledgeItem.Name]})
}
sort.SliceStable(item.Options, func(i, j int) bool {
if item.Options[i].Suggested != item.Options[j].Suggested {
return item.Options[i].Suggested
}
return item.Options[i].Label < item.Options[j].Label
})
}
} else {
seen := map[string]*KnowledgeCollectionOption{}
var parentStep *KnowledgeCollectionStepView
for index := range view.Steps {
if view.Steps[index].FieldKey == step.FromField {
parentStep = &view.Steps[index]
break
}
}
if parentStep != nil {
for _, parent := range parentStep.Options {
for _, childName := range childrenByParent[parent.Value][step.RelationType] {
option := seen[childName]
if option == nil {
option = &KnowledgeCollectionOption{Value: childName, Label: childName}
seen[childName] = option
}
option.Parents = appendUnique(option.Parents, parent.Value)
}
}
}
for _, option := range seen {
item.Options = append(item.Options, *option)
}
sort.Slice(item.Options, func(i, j int) bool { return item.Options[i].Label < item.Options[j].Label })
}
view.Steps = append(view.Steps, item)
}
return view, nil
}
func loadPublicFieldsByKeys(db *gorm.DB, node model.SOPNode, tenantID uint64, keys []string) ([]PublicFieldView, error) {
if len(keys) == 0 {
return []PublicFieldView{}, nil
}
var fields []model.ScenarioField
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id").Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).Find(&fields).Error; err != nil {
return nil, err
}
byKey := map[string]model.ScenarioField{}
for _, field := range fields {
byKey[field.FieldKey] = field
}
result := make([]PublicFieldView, 0, len(keys))
for _, key := range keys {
if field, ok := byKey[key]; ok {
result = append(result, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required, Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)})
}
}
return result, nil
}
func appendUnique(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
func loadPublicNodeFields(db *gorm.DB, node model.SOPNode, tenantID uint64) ([]PublicFieldView, error) {
var config answerNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return nil, err
}
keys := config.FieldKeys
if config.FieldKey != "" {
keys = []string{config.FieldKey}
}
if len(keys) == 0 {
return nil, nil
}
var fields []model.ScenarioField
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id").Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).Find(&fields).Error; err != nil {
return nil, err
}
byKey := make(map[string]model.ScenarioField, len(fields))
for _, field := range fields {
byKey[field.FieldKey] = field
}
views := make([]PublicFieldView, 0, len(keys))
for _, key := range keys {
field, ok := byKey[key]
if !ok {
continue
}
views = append(views, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required || config.Required || containsString(config.RequiredFieldKeys, key), Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)})
}
return views, nil
}
func loadKnowledgeOutputs(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) {
var sopRow struct{ ScenarioID uint64 }
if err := db.Table("sop_nodes n").Select("s.scenario_id").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where("n.id = ? AND n.tenant_id = ?", node.ID, tenantID).Scan(&sopRow).Error; err != nil {
return nil, err
}
return loadKnowledgeOutputsForScenario(db, sopRow.ScenarioID, tenantID, context, selector)
}
func loadKnowledgeOutputsForScenario(db *gorm.DB, scenarioID, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) {
keys := append([]string{}, selector.KnowledgeKeys...)
if selector.DerivedField != "" {
value, _ := lookupContextValue(context, "derived."+selector.DerivedField)
keys = append(keys, stringSlice(value)...)
}
if selector.AnswerField != "" {
value, _ := lookupContextValue(context, "form."+selector.AnswerField)
keys = append(keys, stringSlice(value)...)
}
if len(keys) == 0 && selector.CandidateScope != "all" {
return []KnowledgeGroup{}, nil
}
keySet := make(map[string]bool, len(keys))
for _, key := range keys {
keySet[key] = true
}
items := make([]model.KnowledgeItem, 0)
relations := make([]model.KnowledgeRelation, 0)
snapshotTargets := map[uint64]model.KnowledgeItem{}
if raw, ok := context["__knowledge_snapshot"].(json.RawMessage); ok && len(raw) > 0 {
snapshot, err := parseKnowledgeSnapshot(raw)
if err != nil {
return nil, err
}
for _, item := range snapshot.Items {
snapshotTargets[item.ID] = item
if item.Status != "active" {
continue
}
if selector.CandidateScope != "all" && len(keys) > 0 && !knowledgeCandidateMatches(keySet, item) {
continue
}
if len(selector.KnowledgeTypes) > 0 && !containsString(selector.KnowledgeTypes, item.Type) {
continue
}
items = append(items, item)
}
for _, relation := range snapshot.Relations {
if len(selector.RelationTypes) > 0 && !containsString(selector.RelationTypes, relation.RelationType) {
continue
}
relations = append(relations, relation)
}
} else {
query := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active")
if selector.CandidateScope != "all" && len(keys) > 0 {
query = query.Where("item_key IN ? OR name IN ?", keys, keys)
}
if len(selector.KnowledgeTypes) > 0 {
query = query.Where("type IN ?", selector.KnowledgeTypes)
}
if err := query.Order("sort_order, id").Find(&items).Error; err != nil {
return nil, err
}
}
ids := make([]uint64, 0, len(items))
for _, item := range items {
if selector.CandidateScope == "all" || len(keySet) == 0 || knowledgeCandidateMatches(keySet, item) {
ids = append(ids, item.ID)
}
}
if len(relations) == 0 && len(ids) > 0 {
rq := db.Where("tenant_id = ? AND scenario_id = ? AND from_knowledge_id IN ?", tenantID, scenarioID, ids)
if len(selector.RelationTypes) > 0 {
rq = rq.Where("relation_type IN ?", selector.RelationTypes)
}
if err := rq.Order("sort_order,id").Find(&relations).Error; err != nil {
return nil, err
}
}
targetIDs := make([]uint64, 0, len(relations))
for _, rel := range relations {
targetIDs = append(targetIDs, rel.ToKnowledgeID)
}
targets := make([]model.KnowledgeItem, 0)
if len(snapshotTargets) > 0 {
for _, id := range targetIDs {
if target, ok := snapshotTargets[id]; ok && target.Status == "active" {
targets = append(targets, target)
}
}
} else if len(targetIDs) > 0 {
if err := db.Where("tenant_id = ? AND id IN ? AND status = ?", tenantID, targetIDs, "active").Find(&targets).Error; err != nil {
return nil, err
}
}
targetMap := map[uint64]model.KnowledgeItem{}
for _, target := range targets {
targetMap[target.ID] = target
}
relationMap := map[uint64]map[string][]KnowledgeItemView{}
for _, rel := range relations {
matched, err := matchCondition(json.RawMessage(rel.Condition), context)
if err != nil {
return nil, fmt.Errorf("知识关系 %d 条件不正确: %w", rel.ID, err)
}
if !matched {
continue
}
target, ok := targetMap[rel.ToKnowledgeID]
if !ok {
continue
}
if relationMap[rel.FromKnowledgeID] == nil {
relationMap[rel.FromKnowledgeID] = map[string][]KnowledgeItemView{}
}
relationMap[rel.FromKnowledgeID][rel.RelationType] = append(relationMap[rel.FromKnowledgeID][rel.RelationType], KnowledgeItemView{Key: target.ItemKey, Name: target.Name, Type: target.Type, Content: renderKnowledgeContent(target.Content, context)})
}
outputs := make([]KnowledgeGroup, 0, len(items))
for _, item := range items {
outputs = append(outputs, KnowledgeGroup{Key: item.ItemKey, Name: item.Name, Type: item.Type, Content: renderKnowledgeContent(item.Content, context), Relations: relationMap[item.ID], Suggested: knowledgeCandidateMatches(keySet, item)})
}
return outputs, nil
}
func knowledgeCandidateMatches(candidates map[string]bool, item model.KnowledgeItem) bool {
return candidates[item.ItemKey] || candidates[item.Name]
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func renderKnowledgeContent(raw []byte, context map[string]interface{}) json.RawMessage {
var value interface{}
if json.Unmarshal(raw, &value) != nil {
return json.RawMessage(raw)
}
value = renderKnowledgeValue(value, context)
rendered, _ := json.Marshal(value)
return rendered
}
func renderKnowledgeValue(value interface{}, context map[string]interface{}) interface{} {
switch typed := value.(type) {
case string:
return knowledgePlaceholderPattern.ReplaceAllStringFunc(typed, func(token string) string {
match := knowledgePlaceholderPattern.FindStringSubmatch(token)
if len(match) != 2 {
return token
}
resolved, ok := lookupContextValue(context, tokenNamespaceKey(token, match[1]))
if !ok || resolved == nil {
return "未提供"
}
if values, ok := resolved.([]interface{}); ok {
parts := make([]string, 0, len(values))
for _, item := range values {
parts = append(parts, fmt.Sprint(item))
}
return strings.Join(parts, "、")
}
return fmt.Sprint(resolved)
})
case []interface{}:
for index, item := range typed {
typed[index] = renderKnowledgeValue(item, context)
}
return typed
case map[string]interface{}:
for key, item := range typed {
typed[key] = renderKnowledgeValue(item, context)
}
return typed
default:
return value
}
}
func tokenNamespaceKey(token, key string) string {
trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(token, "{{"), "}}"))
if strings.Contains(trimmed, ".") {
return trimmed
}
return key
}
func stringSlice(value interface{}) []string {
result := []string{}
switch values := value.(type) {
case []interface{}:
for _, item := range values {
if text, ok := item.(string); ok {
result = append(result, text)
}
}
case []string:
return values
case string:
return []string{values}
}
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
@@ -61,16 +556,21 @@ func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (Knowle
if err := query.First(&version).Error; err != nil {
return KnowledgeView{}, err
}
var content struct {
StandardCopy string `json:"standard_copy"`
ForbiddenCopy string `json:"forbidden_copy"`
RiskNote string `json:"risk_note"`
}
if err := json.Unmarshal(version.Content, &content); err != nil {
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: content.StandardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote,
StandardCopy: standardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote, Copies: copies,
}, nil
}

View File

@@ -0,0 +1,79 @@
package run
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func validateKnowledgeSelections(node model.SOPNode, run model.SOPRun, answers map[string]interface{}) error {
if node.Type != "knowledge" {
return nil
}
var config knowledgeNodeConfig
if json.Unmarshal(node.Config, &config) != nil || config.KnowledgeCollection == nil {
return nil
}
snapshot, err := parseKnowledgeSnapshot(run.KnowledgeSnapshot)
if err != nil {
return err
}
itemsByID := map[uint64]model.KnowledgeItem{}
itemsByName := map[string]model.KnowledgeItem{}
for _, item := range snapshot.Items {
if item.Status == "active" {
itemsByID[item.ID] = item
itemsByName[item.Name] = item
}
}
relations := map[uint64]map[string]map[uint64]bool{}
for _, rel := range snapshot.Relations {
if relations[rel.FromKnowledgeID] == nil {
relations[rel.FromKnowledgeID] = map[string]map[uint64]bool{}
}
if relations[rel.FromKnowledgeID][rel.RelationType] == nil {
relations[rel.FromKnowledgeID][rel.RelationType] = map[uint64]bool{}
}
relations[rel.FromKnowledgeID][rel.RelationType][rel.ToKnowledgeID] = true
}
selected := map[string][]model.KnowledgeItem{}
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
rootCandidates := map[string]bool{}
if config.KnowledgeSelector != nil {
for _, value := range stringSlice(derived[config.KnowledgeSelector.DerivedField]) {
rootCandidates[value] = true
}
}
for _, step := range config.KnowledgeCollection.Steps {
values, _ := stringValues(answers[step.FieldKey])
for _, value := range values {
item, ok := itemsByName[value]
if !ok {
return fmt.Errorf("%s包含不存在的知识选项%s", step.Name, value)
}
if step.Root {
if len(step.KnowledgeTypes) > 0 && !containsString(step.KnowledgeTypes, item.Type) {
return fmt.Errorf("%s的知识类型不正确%s", step.Name, value)
}
if step.CandidateScope != "all" && !rootCandidates[item.Name] && !rootCandidates[item.ItemKey] {
return fmt.Errorf("%s不属于本次订单推断结果%s", step.Name, value)
}
} else {
valid := false
for _, parent := range selected[step.FromField] {
if relations[parent.ID][step.RelationType][item.ID] {
valid = true
break
}
}
if !valid {
return fmt.Errorf("%s与已选择的上级知识不关联%s", step.Name, value)
}
}
selected[step.FieldKey] = append(selected[step.FieldKey], item)
}
}
return nil
}

View File

@@ -0,0 +1,62 @@
package run
import (
"encoding/json"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
)
func TestValidateKnowledgeSelectionsRejectsUnrelatedPlan(t *testing.T) {
config := map[string]interface{}{
"knowledge_selector": map[string]interface{}{"derived_field": "matched"},
"knowledge_collection": map[string]interface{}{"steps": []map[string]interface{}{
{"field_key": "segments", "name": "客户分群", "root": true, "required": true, "multiple": true},
{"field_key": "strategies", "name": "推荐策略", "from_field": "segments", "relation_type": "matched_strategy", "required": true, "multiple": true},
{"field_key": "offers", "name": "推荐内容", "from_field": "strategies", "relation_type": "recommended_offer", "required": true, "multiple": true},
}},
}
configRaw, _ := json.Marshal(config)
snapshotRaw, _ := json.Marshal(knowledgeSnapshot{
Items: []model.KnowledgeItem{
{Base: model.Base{ID: 1}, ItemKey: "vip", Name: "高价值客户", Status: "active"},
{Base: model.Base{ID: 2}, ItemKey: "renewal", Name: "续费策略", Status: "active"},
{Base: model.Base{ID: 3}, ItemKey: "annual", Name: "年度套餐", Status: "active"},
{Base: model.Base{ID: 4}, ItemKey: "trial", Name: "试用课程", Status: "active"},
},
Relations: []model.KnowledgeRelation{
{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "matched_strategy"},
{FromKnowledgeID: 2, ToKnowledgeID: 3, RelationType: "recommended_offer"},
},
})
derivedRaw, _ := json.Marshal(map[string]interface{}{"matched": []string{"高价值客户"}})
run := model.SOPRun{Derived: datatypes.JSON(derivedRaw), KnowledgeSnapshot: datatypes.JSON(snapshotRaw)}
node := model.SOPNode{Type: "knowledge", Config: datatypes.JSON(configRaw)}
valid := map[string]interface{}{"segments": []interface{}{"高价值客户"}, "strategies": []interface{}{"续费策略"}, "offers": []interface{}{"年度套餐"}}
if err := validateKnowledgeSelections(node, run, valid); err != nil {
t.Fatalf("valid selection rejected: %v", err)
}
invalid := map[string]interface{}{"segments": []interface{}{"高价值客户"}, "strategies": []interface{}{"续费策略"}, "offers": []interface{}{"试用课程"}}
if err := validateKnowledgeSelections(node, run, invalid); err == nil {
t.Fatal("unrelated plan should be rejected")
}
}
func TestValidateKnowledgeSelectionsAllowsAdditionalRootFromConfiguredKnowledgeType(t *testing.T) {
configRaw := datatypes.JSON([]byte(`{"knowledge_selector":{"derived_field":"matched"},"knowledge_collection":{"steps":[{"field_key":"symptoms","name":"症状","root":true,"candidate_scope":"all","knowledge_types":["symptom"],"required":true,"multiple":true}]}}`))
snapshotRaw, _ := json.Marshal(knowledgeSnapshot{Items: []model.KnowledgeItem{
{Base: model.Base{ID: 1}, ItemKey: "diarrhea", Name: "腹泻", Type: "symptom", Status: "active"},
{Base: model.Base{ID: 2}, ItemKey: "vomiting", Name: "呕吐", Type: "symptom", Status: "active"},
{Base: model.Base{ID: 3}, ItemKey: "disease", Name: "胃肠炎", Type: "disease", Status: "active"},
}})
derivedRaw, _ := json.Marshal(map[string]interface{}{"matched": []string{"腹泻"}})
run := model.SOPRun{Derived: datatypes.JSON(derivedRaw), KnowledgeSnapshot: datatypes.JSON(snapshotRaw)}
node := model.SOPNode{Type: "knowledge", Config: configRaw}
if err := validateKnowledgeSelections(node, run, map[string]interface{}{"symptoms": []interface{}{"腹泻", "呕吐"}}); err != nil {
t.Fatalf("additional symptom should be accepted: %v", err)
}
if err := validateKnowledgeSelections(node, run, map[string]interface{}{"symptoms": []interface{}{"胃肠炎"}}); err == nil {
t.Fatal("knowledge item of another type should be rejected")
}
}

View File

@@ -0,0 +1,88 @@
package run
import (
"encoding/json"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func TestRenderKnowledgeContent(t *testing.T) {
got := string(renderKnowledgeContent([]byte(`{"template":"您好{{input.name}},标签:{{derived.tags}}"}`), map[string]interface{}{"name": "王女士", "tags": []interface{}{"高意向", "复购"}}))
want := `{"template":"您好王女士,标签:高意向、复购"}`
if got != want {
t.Fatalf("got %s want %s", got, want)
}
}
func TestRenderKnowledgeContentSupportsFormNamespace(t *testing.T) {
got := string(renderKnowledgeContent([]byte(`{"template":"结果:{{form.call_result}}"}`), runtimeContext(nil, nil, map[string]interface{}{"call_result": "已接受"})))
if got != `{"template":"结果:已接受"}` {
t.Fatalf("got %s", got)
}
}
func TestTemplateNamespacesDoNotOverwriteEachOther(t *testing.T) {
context := runtimeContext(map[string]interface{}{"status": "input"}, map[string]interface{}{"status": "derived"}, map[string]interface{}{"status": "form"})
got := string(renderKnowledgeContent([]byte(`{"template":"{{input.status}}/{{derived.status}}/{{form.status}}"}`), context))
if got != `{"template":"input/derived/form"}` {
t.Fatalf("got %s", got)
}
}
func TestKnowledgeRelationCondition(t *testing.T) {
condition := json.RawMessage(`{"field":"derived.segment","operator":"equals","value":"vip"}`)
matched, err := matchCondition(condition, map[string]interface{}{"segment": "vip"})
if err != nil || !matched {
t.Fatalf("matched=%v err=%v", matched, err)
}
matched, err = matchCondition(condition, map[string]interface{}{"segment": "normal"})
if err != nil || matched {
t.Fatalf("matched=%v err=%v", matched, err)
}
}
func TestSnapshotRelationsHonorSelector(t *testing.T) {
snapshot := knowledgeSnapshot{Relations: []model.KnowledgeRelation{
{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "recommended_copy"},
{FromKnowledgeID: 1, ToKnowledgeID: 3, RelationType: "internal_note"},
}}
raw, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
parsed, err := parseKnowledgeSnapshot(raw)
if err != nil {
t.Fatal(err)
}
selector := knowledgeSelector{RelationTypes: []string{"recommended_copy"}}
filtered := make([]model.KnowledgeRelation, 0)
for _, relation := range parsed.Relations {
if len(selector.RelationTypes) == 0 || containsString(selector.RelationTypes, relation.RelationType) {
filtered = append(filtered, relation)
}
}
if len(filtered) != 1 || filtered[0].RelationType != "recommended_copy" {
t.Fatalf("filtered relations = %#v", filtered)
}
}
func TestKnowledgeSelectorWithoutCandidateKeysReturnsEmpty(t *testing.T) {
outputs, err := loadKnowledgeOutputsForScenario(nil, 1, 1, map[string]interface{}{}, knowledgeSelector{DerivedField: "matched", KnowledgeTypes: []string{"symptom"}})
if err != nil {
t.Fatal(err)
}
if len(outputs) != 0 {
t.Fatalf("outputs = %#v", outputs)
}
}
func TestKnowledgeCandidateMatchesKeyOrDisplayName(t *testing.T) {
item := model.KnowledgeItem{ItemKey: "xlsx_symptom_123", Name: "腹泻"}
if !knowledgeCandidateMatches(map[string]bool{"腹泻": true}, item) {
t.Fatal("display name should match an external symptom tag")
}
if !knowledgeCandidateMatches(map[string]bool{"xlsx_symptom_123": true}, item) {
t.Fatal("item key should remain supported")
}
}

93
internal/run/mapping.go Normal file
View File

@@ -0,0 +1,93 @@
package run
import (
"fmt"
"regexp"
"strconv"
"strings"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
var pathTokenPattern = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9_]*)(?:\[(\d+|\*)\])?$`)
func mapScenarioInput(fields []model.ScenarioField, input map[string]interface{}) map[string]interface{} {
mapped := make(map[string]interface{})
for _, field := range fields {
if field.SourcePath == "" {
continue
}
if value, ok := extractPath(input, field.SourcePath); ok {
mapped[field.FieldKey] = value
}
}
return mapped
}
func extractPath(root map[string]interface{}, path string) (interface{}, bool) {
parts := strings.Split(path, ".")
return walkPath(root, parts)
}
func walkPath(current interface{}, parts []string) (interface{}, bool) {
if len(parts) == 0 {
return current, true
}
match := pathTokenPattern.FindStringSubmatch(parts[0])
if match == nil {
return nil, false
}
object, ok := current.(map[string]interface{})
if !ok {
return nil, false
}
value, ok := object[match[1]]
if !ok {
return nil, false
}
if match[2] == "" {
return walkPath(value, parts[1:])
}
items, ok := value.([]interface{})
if !ok {
return nil, false
}
if match[2] == "*" {
values := make([]interface{}, 0)
for _, item := range items {
resolved, found := walkPath(item, parts[1:])
if !found {
continue
}
if nested, ok := resolved.([]interface{}); ok {
values = append(values, nested...)
} else {
values = append(values, resolved)
}
}
return values, len(values) > 0
}
index, err := strconv.Atoi(match[2])
if err != nil || index < 0 || index >= len(items) {
return nil, false
}
return walkPath(items[index], parts[1:])
}
func mergeValues(base map[string]interface{}, overrides map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{}, len(base)+len(overrides))
for key, value := range base {
result[key] = value
}
for key, value := range overrides {
result[key] = value
}
return result
}
func validateExternalRef(value string) error {
if len(value) > 191 {
return fmt.Errorf("external_ref 不能超过191个字符")
}
return nil
}

View File

@@ -0,0 +1,24 @@
package run
import (
"reflect"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func TestMapScenarioInputSupportsArrays(t *testing.T) {
fields := []model.ScenarioField{{FieldKey: "customer_name", SourcePath: "customer.name"}, {FieldKey: "product_ids", SourcePath: "order.items[*].product_id"}}
input := map[string]interface{}{"customer": map[string]interface{}{"name": "王女士"}, "order": map[string]interface{}{"items": []interface{}{map[string]interface{}{"product_id": "P1"}, map[string]interface{}{"product_id": "P2"}}}}
want := map[string]interface{}{"customer_name": "王女士", "product_ids": []interface{}{"P1", "P2"}}
if got := mapScenarioInput(fields, input); !reflect.DeepEqual(got, want) {
t.Fatalf("mapped input = %#v, want %#v", got, want)
}
}
func TestMergeValuesUsesExplicitValues(t *testing.T) {
got := mergeValues(map[string]interface{}{"name": "mapped"}, map[string]interface{}{"name": "explicit"})
if got["name"] != "explicit" {
t.Fatalf("name = %v", got["name"])
}
}

View File

@@ -0,0 +1,22 @@
package run
import (
"encoding/json"
"testing"
)
func TestNodeViewDoesNotExposeInternalSnapshotFields(t *testing.T) {
raw, err := json.Marshal(NodeView{NodeKey: "knowledge", Type: "knowledge", Title: "知识", Content: "内容"})
if err != nil {
t.Fatal(err)
}
var value map[string]interface{}
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatal(err)
}
for _, key := range []string{"id", "tenant_id", "sop_version_id", "config", "position_x", "position_y"} {
if _, exists := value[key]; exists {
t.Fatalf("public node contains internal field %s: %s", key, raw)
}
}
}

101
internal/run/outputs.go Normal file
View File

@@ -0,0 +1,101 @@
package run
import (
"encoding/json"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
type outputSchema struct {
Fields []outputField `json:"fields"`
}
type outputField struct {
Key string `json:"key"`
Name string `json:"name"`
Display string `json:"display"`
Type string `json:"type"`
Source string `json:"source"`
SourceField string `json:"source_field"`
Default interface{} `json:"default"`
}
type OutputView struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Source string `json:"source"`
Value interface{} `json:"value"`
}
func buildScenarioOutputs(db *gorm.DB, run model.SOPRun, knowledge []KnowledgeGroup) ([]OutputView, error) {
var scenario model.Scenario
if err := db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
return nil, err
}
input := map[string]interface{}{}
derived := map[string]interface{}{}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
_ = json.Unmarshal(run.Derived, &derived)
_ = json.Unmarshal(run.Answers, &answers)
return buildOutputViews(scenario.OutputSchema, input, derived, answers, knowledge, run.Status, run.Result)
}
func buildOutputViews(raw []byte, input, derived, answers map[string]interface{}, knowledge []KnowledgeGroup, status, result string) ([]OutputView, error) {
var schema outputSchema
if err := json.Unmarshal(raw, &schema); err != nil {
return nil, err
}
views := make([]OutputView, 0, len(schema.Fields))
for _, field := range schema.Fields {
sourceField := field.SourceField
var value interface{}
var ok bool
switch field.Source {
case "derived":
if sourceField == "" {
sourceField = field.Key
}
value, ok = derived[sourceField]
case "knowledge":
if sourceField == "" {
value, ok = knowledge, true
} else {
for _, group := range knowledge {
if group.Key == sourceField {
value, ok = group, true
break
}
}
}
case "form":
if sourceField == "" {
sourceField = field.Key
}
value, ok = answers[sourceField]
case "system":
if sourceField == "status" {
value, ok = status, true
} else if sourceField == "result" {
value, ok = result, true
}
default:
if sourceField == "" {
sourceField = field.Key
}
value, ok = input[sourceField]
}
if !ok {
value = field.Default
}
name := field.Name
if name == "" {
name = field.Display
}
if name == "" {
name = field.Key
}
views = append(views, OutputView{Key: field.Key, Name: name, Type: field.Type, Source: field.Source, Value: value})
}
return views, nil
}

View File

@@ -0,0 +1,21 @@
package run
import "testing"
func TestBuildOutputViewsSupportsKnowledge(t *testing.T) {
knowledge := []KnowledgeGroup{{Key: "soft_stool", Name: "软便", Type: "symptom"}}
raw := []byte(`{"fields":[{"key":"recommended","name":"推荐知识","type":"array","source":"knowledge"},{"key":"symptom","name":"症状","type":"object","source":"knowledge","source_field":"soft_stool"}]}`)
outputs, err := buildOutputViews(raw, nil, nil, nil, knowledge, "preview", "")
if err != nil {
t.Fatal(err)
}
if len(outputs) != 2 {
t.Fatalf("outputs=%#v", outputs)
}
if groups, ok := outputs[0].Value.([]KnowledgeGroup); !ok || len(groups) != 1 {
t.Fatalf("knowledge output=%#v", outputs[0].Value)
}
if group, ok := outputs[1].Value.(KnowledgeGroup); !ok || group.Key != "soft_stool" {
t.Fatalf("selected output=%#v", outputs[1].Value)
}
}

62
internal/run/preview.go Normal file
View File

@@ -0,0 +1,62 @@
package run
import (
"net/http"
"strconv"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
)
func (h *Handler) PreviewScenario(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
scenarioID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || scenarioID == 0 || !access.CanViewScenario(h.db, p, scenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
return
}
var body struct {
Input map[string]interface{} `json:"input"`
InitialValues map[string]interface{} `json:"initial_values"`
Selector knowledgeSelector `json:"knowledge_selector"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "预览参数格式不正确")
return
}
var scenario model.Scenario
if err := h.db.Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).First(&scenario).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
return
}
var fields []model.ScenarioField
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", scenarioID, p.TenantID).Order("sort_order,id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景输入失败")
return
}
mapped := mergeValues(mapScenarioInput(fields, body.Input), body.InitialValues)
if err := validateInitialAnswers(fields, mapped); err != nil {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INPUT", err.Error())
return
}
derived, matchedRules, err := deriveForScenario(h.db, p.TenantID, scenarioID, mapped)
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
return
}
context := runtimeContext(mapped, derived, nil)
knowledge, err := loadKnowledgeOutputsForScenario(h.db, scenarioID, p.TenantID, context, body.Selector)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成知识预览失败")
return
}
outputs, err := buildOutputViews(scenario.OutputSchema, mapped, derived, map[string]interface{}{}, knowledge, "preview", "")
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
return
}
response.OK(c, gin.H{"input": mapped, "derived": derived, "matched_rules": matchedRules, "knowledge": knowledge, "outputs": outputs})
}

458
internal/run/public.go Normal file
View File

@@ -0,0 +1,458 @@
package run
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
errPublicRunCompleted = errors.New("执行记录已经结束")
errPublicNodeChanged = errors.New("当前步骤已经变化")
errPublicInputNeeded = errors.New("当前节点需要提交表单")
)
func (h *Handler) PublicStart(c *gin.Context) {
var body struct {
PublicKey string `json:"public_key" binding:"required"`
SOPID uint64 `json:"sop_id"`
Input map[string]interface{} `json:"input"`
InitialValues map[string]interface{} `json:"initial_values"`
ExternalRef string `json:"external_ref"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "公开场景参数不完整")
return
}
var scenario model.Scenario
if err := h.db.Where("scenario_key = ? AND public_key = ? AND status <> ?", c.Param("scenarioKey"), body.PublicKey, "archived").First(&scenario).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "公开场景不存在")
return
}
query := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", scenario.ID, scenario.TenantID, "published")
if body.SOPID != 0 {
query = query.Where("id = ?", body.SOPID)
}
var sop model.SOP
if err := query.Order("updated_at DESC").First(&sop).Error; err != nil {
response.Error(c, http.StatusNotFound, "SOP_NOT_FOUND", "场景没有可执行的 SOP")
return
}
if body.ExternalRef != "" {
var existing model.SOPRun
if err := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", scenario.TenantID, sop.ID, body.ExternalRef).First(&existing).Error; err == nil {
token := uuid.NewString() + uuid.NewString()
if err := h.db.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: existing.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SESSION_FAILED", "创建 SDK 会话失败")
return
}
h.respondPublicRun(c, existing, token)
return
}
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", sop.ID, scenario.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusNotFound, "SOP_NOT_FOUND", "场景没有可执行的 SOP")
return
}
fields := make([]model.ScenarioField, 0)
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", scenario.ID, scenario.TenantID).Order("sort_order, id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景契约失败")
return
}
normalized := mergeValues(mapScenarioInput(fields, body.Input), body.InitialValues)
if err := validateInitialAnswers(fields, normalized); err != nil {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INPUT", err.Error())
return
}
raw, _ := json.Marshal(normalized)
derived, matchedRules, err := deriveForScenario(h.db, scenario.TenantID, scenario.ID, normalized)
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
return
}
derivedRaw, _ := json.Marshal(derived)
knowledgeRaw, err := snapshotKnowledge(h.db, scenario.TenantID, scenario.ID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败")
return
}
externalRef := body.ExternalRef
if externalRef == "" {
externalRef = "run-" + uuid.NewString()
}
run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, SOPVersionID: version.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), StartedAt: time.Now()}
token := uuid.NewString() + uuid.NewString()
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&run).Error; err != nil {
return err
}
if err := tx.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: run.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; err != nil {
return err
}
payload, _ := json.Marshal(gin.H{"source": "public_sdk", "mapped_field_keys": sortedKeys(normalized), "matched_rule_keys": matchedRules})
return tx.Create(&model.SOPRunEvent{TenantID: scenario.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON(payload)}).Error
})
if err != nil {
if body.ExternalRef != "" {
var existing model.SOPRun
if findErr := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", scenario.TenantID, sop.ID, body.ExternalRef).First(&existing).Error; findErr == nil {
token = uuid.NewString() + uuid.NewString()
if sessionErr := h.db.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: existing.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; sessionErr == nil {
h.respondPublicRun(c, existing, token)
return
}
}
}
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动公开场景失败")
return
}
h.respondPublicRun(c, run, token)
}
func (h *Handler) PublicCurrent(c *gin.Context) {
session, run, ok := h.publicSession(c)
if !ok {
return
}
_ = session
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicSubmit(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var body struct {
NodeKey string `json:"node_key"`
Answers map[string]interface{} `json:"answers"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "提交内容格式不正确")
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
if run.Status != "running" {
return errPublicRunCompleted
}
if body.NodeKey != "" && body.NodeKey != run.CurrentNodeKey {
return errPublicNodeChanged
}
var node model.SOPNode
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
fields := make([]model.ScenarioField, 0)
if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", run.SOPID, run.TenantID).Find(&fields).Error; err != nil {
return err
}
if err := validateNodeAnswers(node, fields, body.Answers); err != nil {
return err
}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
for key, value := range body.Answers {
answers[key] = value
}
answerRaw, _ := json.Marshal(answers)
run.Answers = datatypes.JSON(answerRaw)
payload, _ := json.Marshal(body.Answers)
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
return advancePublicRun(tx, &run)
})
if err != nil {
h.respondPublicMutationError(c, err, "提交节点失败")
return
}
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicNext(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
if run.Status != "running" {
return errPublicRunCompleted
}
var node model.SOPNode
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
return errPublicInputNeeded
}
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
return advancePublicRun(tx, &run)
})
if err != nil {
h.respondPublicMutationError(c, err, "推进节点失败")
return
}
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicFinish(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var body struct {
Result string `json:"result"`
FinalResult json.RawMessage `json:"final_result"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "最终结果格式不正确")
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
if run.Status != "running" && run.Status != "completed" {
return errPublicRunCompleted
}
if body.Result == "" {
body.Result = run.Result
if body.Result == "" {
body.Result = "completed"
}
}
finalResult, err := parseFinalResult(body.FinalResult)
if err != nil {
return err
}
var scenario model.Scenario
if err := tx.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
return err
}
schema, err := resultcontract.ParseAndValidate(scenario.ResultSchema)
if err != nil {
return err
}
if err := resultcontract.ValidateResult(schema, finalResult); err != nil {
return err
}
finalRaw, _ := json.Marshal(finalResult)
now := time.Now()
completedAt := run.CompletedAt
if completedAt == nil {
completedAt = &now
}
payload, _ := json.Marshal(gin.H{"result": body.Result, "final_result": finalResult})
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": body.Result, "final_result": datatypes.JSON(finalRaw), "completed_at": completedAt}).Error; err != nil {
return err
}
run.Status, run.Result, run.FinalResult, run.CompletedAt = "completed", body.Result, datatypes.JSON(finalRaw), completedAt
return nil
})
if err != nil {
h.respondPublicMutationError(c, err, "结束执行失败")
return
}
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicReset(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
var version model.SOPVersion
if err := tx.Where("id = ? AND tenant_id = ?", run.SOPVersionID, run.TenantID).First(&version).Error; err != nil {
return err
}
run.CurrentNodeKey, run.Status, run.Result, run.FinalResult, run.CompletedAt, run.Answers = version.StartNodeKey, "running", "", nil, nil, run.Input
if err := tx.Model(&run).Updates(map[string]interface{}{"current_node_key": run.CurrentNodeKey, "status": run.Status, "result": run.Result, "final_result": nil, "completed_at": nil, "answers": run.Input, "outputs": datatypes.JSON([]byte(`[]`))}).Error; err != nil {
return err
}
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "reset", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
h.respondPublicMutationError(c, err, "重置执行失败")
return
}
h.respondPublicRun(c, run, "")
}
func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error {
var edges []model.SOPEdge
if err := tx.Where("sop_version_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).Order("priority,id").Find(&edges).Error; err != nil {
return err
}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
context := runtimeContext(input, derived, answers)
sortEdges(edges)
nextKey := ""
for _, edge := range edges {
matched, err := matchCondition(json.RawMessage(edge.Condition), context)
if err != nil {
return err
}
if matched {
nextKey = edge.TargetNodeKey
break
}
}
if nextKey == "" {
return errors.New("没有满足条件的下一节点")
}
var next model.SOPNode
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, nextKey, run.TenantID).First(&next).Error; err != nil {
return err
}
updates := map[string]interface{}{"current_node_key": nextKey}
if next.Type == "finish" || next.Type == "escalate" {
now := time.Now()
updates["status"] = "completed"
updates["completed_at"] = &now
updates["result"] = next.Type
run.Status, run.CompletedAt, run.Result = "completed", &now, next.Type
}
if err := tx.Model(run).Updates(updates).Error; err != nil {
return err
}
run.CurrentNodeKey = nextKey
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
if run.Status == "completed" {
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "finish", Payload: datatypes.JSON([]byte(`{"source":"terminal_node"}`))}).Error
}
return nil
}
func lockPublicRun(tx *gorm.DB, session model.PublicRunSession, run *model.SOPRun) error {
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", session.RunID, session.TenantID).First(run).Error
}
func (h *Handler) respondPublicMutationError(c *gin.Context, err error, fallback string) {
switch {
case errors.Is(err, errPublicRunCompleted):
response.Error(c, http.StatusConflict, "RUN_COMPLETED", err.Error())
case errors.Is(err, errPublicNodeChanged):
response.Error(c, http.StatusConflict, "NODE_CHANGED", err.Error())
case errors.Is(err, errPublicInputNeeded):
response.Error(c, http.StatusUnprocessableEntity, "INPUT_REQUIRED", err.Error())
case errors.Is(err, gorm.ErrRecordNotFound):
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录或流程节点不存在")
default:
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", fallback+": "+err.Error())
}
}
func (h *Handler) respondPublicRun(c *gin.Context, run model.SOPRun, token string) {
var node model.SOPNode
if err := h.db.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
context := runtimeContext(input, derived, answers)
context["__knowledge_snapshot"] = json.RawMessage(run.KnowledgeSnapshot)
view, err := h.nodeView(h.db, node, run.TenantID, context)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成节点输出失败")
return
}
view.Config = nil
outputs, err := buildScenarioOutputs(h.db, run, view.Outputs)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
return
}
if raw, marshalErr := json.Marshal(outputs); marshalErr == nil {
run.Outputs = datatypes.JSON(raw)
_ = h.db.Model(&model.SOPRun{}).Where("id = ?", run.ID).Update("outputs", run.Outputs).Error
}
var scenario model.Scenario
if err := h.db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "读取场景结果格式失败")
return
}
data := gin.H{"run_id": run.ID, "external_ref": run.ExternalRef, "status": run.Status, "final_result": json.RawMessage(run.FinalResult), "result_schema": json.RawMessage(scenario.ResultSchema), "node": view, "outputs": outputs}
if token != "" {
data["session_token"] = token
}
response.OK(c, data)
}
func parseFinalResult(raw json.RawMessage) (map[string]interface{}, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var result map[string]interface{}
if err := json.Unmarshal(raw, &result); err != nil || result == nil {
return nil, errors.New("final_result 必须是对象或 null")
}
return result, nil
}
func (h *Handler) publicSession(c *gin.Context) (model.PublicRunSession, model.SOPRun, bool) {
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
var session model.PublicRunSession
if raw == "" || h.db.Where("token_hash = ? AND expires_at > ?", publicTokenHash(raw), time.Now()).First(&session).Error != nil {
response.Error(c, http.StatusUnauthorized, "INVALID_SESSION", "SDK 会话无效或已过期")
return session, model.SOPRun{}, false
}
var run model.SOPRun
if err := h.db.Where("id = ? AND tenant_id = ?", c.Param("id"), session.TenantID).First(&run).Error; err != nil || run.ID != session.RunID {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return session, run, false
}
return session, run, true
}
func publicTokenHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}

89
internal/run/rules.go Normal file
View File

@@ -0,0 +1,89 @@
package run
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
func deriveForScenario(db *gorm.DB, tenantID, scenarioID uint64, input map[string]interface{}) (map[string]interface{}, []string, error) {
rules := make([]model.ScenarioRule, 0)
if err := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active").Order("priority, id").Find(&rules).Error; err != nil {
return nil, nil, err
}
return applyScenarioRules(rules, input)
}
type ruleAction struct {
Operation string `json:"operation"`
Field string `json:"field"`
Value interface{} `json:"value"`
ValueFrom string `json:"value_from"`
}
func applyScenarioRules(rules []model.ScenarioRule, input map[string]interface{}) (map[string]interface{}, []string, error) {
derived := map[string]interface{}{}
matched := make([]string, 0)
context := runtimeContext(input, derived, nil)
for _, rule := range rules {
ok, err := matchCondition(json.RawMessage(rule.Condition), context)
if err != nil {
return nil, nil, fmt.Errorf("规则 %s 条件不正确: %w", rule.RuleKey, err)
}
if !ok {
continue
}
var actions []ruleAction
if err := json.Unmarshal(rule.Actions, &actions); err != nil {
return nil, nil, fmt.Errorf("规则 %s 动作不正确", rule.RuleKey)
}
for _, action := range actions {
resolvedValues := []interface{}{action.Value}
valueFromList := false
if action.ValueFrom != "" {
resolved, exists := lookupContextValue(context, action.ValueFrom)
if !exists {
continue
}
if list, ok := resolved.([]interface{}); ok {
resolvedValues = list
valueFromList = true
} else {
resolvedValues = []interface{}{resolved}
}
}
switch action.Operation {
case "set":
if valueFromList {
derived[action.Field] = resolvedValues
} else if len(resolvedValues) == 1 {
derived[action.Field] = resolvedValues[0]
} else {
derived[action.Field] = resolvedValues
}
case "append":
targetValues, _ := derived[action.Field].([]interface{})
for _, value := range resolvedValues {
duplicate := false
for _, existing := range targetValues {
if fmt.Sprint(existing) == fmt.Sprint(value) {
duplicate = true
break
}
}
if !duplicate {
targetValues = append(targetValues, value)
}
}
derived[action.Field] = targetValues
default:
return nil, nil, fmt.Errorf("规则 %s 使用了不支持的动作", rule.RuleKey)
}
}
matched = append(matched, rule.RuleKey)
context = runtimeContext(input, derived, nil)
}
return derived, matched, nil
}

View File

@@ -0,0 +1,39 @@
package run
import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
"testing"
)
func TestApplyScenarioRules(t *testing.T) {
rules := []model.ScenarioRule{{RuleKey: "r1", Condition: datatypes.JSON([]byte(`{"field":"tags","operator":"contains","value":"soft"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"append","field":"symptoms","value":"soft_stool"}]`))}}
derived, matched, err := applyScenarioRules(rules, map[string]interface{}{"tags": []interface{}{"soft"}})
if err != nil || len(matched) != 1 || len(derived["symptoms"].([]interface{})) != 1 {
t.Fatalf("derived=%v matched=%v err=%v", derived, matched, err)
}
}
func TestApplyScenarioRulesSupportsValueFrom(t *testing.T) {
rules := []model.ScenarioRule{{RuleKey: "copy_tags", Condition: datatypes.JSON([]byte(`{"field":"input_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"append","field":"matched_symptoms","value_from":"input_tags"}]`))}}
derived, _, err := applyScenarioRules(rules, map[string]interface{}{"input_tags": []interface{}{"soft_stool", "poor_appetite"}})
if err != nil {
t.Fatal(err)
}
values, ok := derived["matched_symptoms"].([]interface{})
if !ok || len(values) != 2 || values[0] != "soft_stool" || values[1] != "poor_appetite" {
t.Fatalf("derived = %#v", derived)
}
}
func TestApplyScenarioRulesSupportsNamespacedValueFrom(t *testing.T) {
rules := []model.ScenarioRule{{RuleKey: "copy_tags", Condition: datatypes.JSON([]byte(`{"field":"input.input_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_symptoms","value_from":"input.input_tags"}]`))}}
derived, _, err := applyScenarioRules(rules, map[string]interface{}{"input_tags": []interface{}{"soft_stool"}})
if err != nil {
t.Fatal(err)
}
values, ok := derived["matched_symptoms"].([]interface{})
if !ok || len(values) != 1 || values[0] != "soft_stool" {
t.Fatalf("derived = %#v", derived)
}
}

29
internal/run/snapshot.go Normal file
View File

@@ -0,0 +1,29 @@
package run
import (
"encoding/json"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
type knowledgeSnapshot struct {
Items []model.KnowledgeItem `json:"items"`
Relations []model.KnowledgeRelation `json:"relations"`
}
func snapshotKnowledge(db *gorm.DB, tenantID, scenarioID uint64) ([]byte, error) {
var snapshot knowledgeSnapshot
if err := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active").Order("sort_order,id").Find(&snapshot.Items).Error; err != nil {
return nil, err
}
if err := db.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Order("sort_order,id").Find(&snapshot.Relations).Error; err != nil {
return nil, err
}
return json.Marshal(snapshot)
}
func parseKnowledgeSnapshot(raw []byte) (knowledgeSnapshot, error) {
var snapshot knowledgeSnapshot
err := json.Unmarshal(raw, &snapshot)
return snapshot, err
}

View File

@@ -0,0 +1,160 @@
package run
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
type NodePresentation struct {
Summary []PresentationField `json:"summary"`
Items []PresentationItem `json:"items"`
Opening *PresentationCopy `json:"opening,omitempty"`
}
type PresentationField struct {
Key string `json:"key"`
Label string `json:"label"`
Kind string `json:"kind"`
Value interface{} `json:"value"`
}
type PresentationItem struct {
Fields []PresentationField `json:"fields"`
}
type PresentationCopy struct {
Title string `json:"title"`
Content string `json:"content"`
}
type startNodeConfig struct {
Presentation *startPresentationConfig `json:"presentation"`
}
type startPresentationConfig struct {
SummaryFieldKeys []string `json:"summary_field_keys"`
ItemFieldKeys []string `json:"item_field_keys"`
ImageFieldKeys []string `json:"image_field_keys"`
OpeningTitle string `json:"opening_title"`
OpeningTemplate string `json:"opening_template"`
}
func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}) (*NodePresentation, error) {
var config startNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return nil, err
}
if config.Presentation == nil {
return nil, nil
}
keys := append([]string{}, config.Presentation.SummaryFieldKeys...)
keys = append(keys, config.Presentation.ItemFieldKeys...)
fields := make([]model.ScenarioField, 0)
if len(keys) > 0 {
if err := db.Table("scenario_fields sf").Select("sf.*").
Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").
Joins("JOIN sop_versions sv ON sv.sop_id = s.id").
Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, tenantID, keys).
Find(&fields).Error; err != nil {
return nil, err
}
}
byKey := make(map[string]model.ScenarioField, len(fields))
for _, field := range fields {
byKey[field.FieldKey] = field
}
return buildStartPresentation(*config.Presentation, byKey, context), nil
}
func buildStartPresentation(config startPresentationConfig, fields map[string]model.ScenarioField, context map[string]interface{}) *NodePresentation {
view := &NodePresentation{Summary: []PresentationField{}, Items: []PresentationItem{}}
imageKeys := make(map[string]bool, len(config.ImageFieldKeys))
for _, key := range config.ImageFieldKeys {
imageKeys[key] = true
}
for _, key := range config.SummaryFieldKeys {
value, ok := lookupContextValue(context, "input."+key)
if !ok || presentationValueEmpty(value) {
continue
}
view.Summary = append(view.Summary, presentationField(key, value, fields, imageKeys))
}
itemValues := make(map[string][]interface{}, len(config.ItemFieldKeys))
itemCount := 0
for _, key := range config.ItemFieldKeys {
value, _ := lookupContextValue(context, "input."+key)
values := presentationValues(value)
itemValues[key] = values
if len(values) > itemCount {
itemCount = len(values)
}
}
for index := 0; index < itemCount; index++ {
item := PresentationItem{Fields: []PresentationField{}}
for _, key := range config.ItemFieldKeys {
values := itemValues[key]
if index >= len(values) || presentationValueEmpty(values[index]) {
continue
}
item.Fields = append(item.Fields, presentationField(key, values[index], fields, imageKeys))
}
if len(item.Fields) > 0 {
view.Items = append(view.Items, item)
}
}
if config.OpeningTemplate != "" {
title := config.OpeningTitle
if title == "" {
title = "开场话术"
}
view.Opening = &PresentationCopy{Title: title, Content: fmt.Sprint(renderKnowledgeValue(config.OpeningTemplate, context))}
}
return view
}
func presentationField(key string, value interface{}, fields map[string]model.ScenarioField, imageKeys map[string]bool) PresentationField {
label := key
if field, ok := fields[key]; ok && field.FieldName != "" {
label = field.FieldName
}
kind := "text"
if imageKeys[key] {
kind = "image"
}
return PresentationField{Key: key, Label: label, Kind: kind, Value: value}
}
func presentationValues(value interface{}) []interface{} {
switch values := value.(type) {
case []interface{}:
return values
case []string:
result := make([]interface{}, 0, len(values))
for _, item := range values {
result = append(result, item)
}
return result
case nil:
return nil
default:
return []interface{}{value}
}
}
func presentationValueEmpty(value interface{}) bool {
switch typed := value.(type) {
case nil:
return true
case string:
return typed == ""
case []interface{}:
return len(typed) == 0
case []string:
return len(typed) == 0
default:
return false
}
}

View File

@@ -0,0 +1,46 @@
package run
import (
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func TestBuildStartPresentation(t *testing.T) {
config := startPresentationConfig{
SummaryFieldKeys: []string{"order_id", "customer_name", "pet_name"},
ItemFieldKeys: []string{"product_images", "product_names", "product_ids"},
ImageFieldKeys: []string{"product_images"},
OpeningTitle: "开场话术",
OpeningTemplate: "您好,{{input.customer_name}},看到您购买了{{input.product_names}}。",
}
fields := map[string]model.ScenarioField{
"order_id": {FieldKey: "order_id", FieldName: "订单号"},
"customer_name": {FieldKey: "customer_name", FieldName: "客户称呼"},
"pet_name": {FieldKey: "pet_name", FieldName: "宠物名称"},
"product_images": {FieldKey: "product_images", FieldName: "订单商品图片"},
"product_names": {FieldKey: "product_names", FieldName: "订单商品名称"},
"product_ids": {FieldKey: "product_ids", FieldName: "订单商品 ID"},
}
context := runtimeContext(map[string]interface{}{
"order_id": "ORDER-001",
"customer_name": "王女士",
"product_images": []interface{}{"https://img.example.com/a.jpg", "https://img.example.com/b.jpg"},
"product_names": []interface{}{"商品 A", "商品 B"},
"product_ids": []interface{}{"SKU-A", "SKU-B"},
}, nil, nil)
got := buildStartPresentation(config, fields, context)
if len(got.Summary) != 2 {
t.Fatalf("summary length = %d, want 2", len(got.Summary))
}
if len(got.Items) != 2 || len(got.Items[0].Fields) != 3 {
t.Fatalf("items = %#v, want two complete product rows", got.Items)
}
if got.Items[0].Fields[0].Kind != "image" {
t.Fatalf("image kind = %q, want image", got.Items[0].Fields[0].Kind)
}
if got.Opening == nil || got.Opening.Content != "您好,王女士,看到您购买了商品 A、商品 B。" {
t.Fatalf("opening = %#v", got.Opening)
}
}

View File

@@ -10,10 +10,37 @@ import (
)
type answerNodeConfig struct {
FieldKey string `json:"field_key"`
FieldKeys []string `json:"field_keys"`
Required bool `json:"required"`
Options []string `json:"options"`
FieldKey string `json:"field_key"`
FieldKeys []string `json:"field_keys"`
RequiredFieldKeys []string `json:"required_field_keys"`
Required bool `json:"required"`
Options []string `json:"options"`
}
// validateInitialAnswers accepts a partial, externally supplied set of values
// when an execution starts. Required fields are still enforced by their
// collection nodes, so integrations can supply only the data they possess.
func validateInitialAnswers(fields []model.ScenarioField, answers map[string]interface{}) error {
fieldMap := make(map[string]model.ScenarioField, len(fields))
for _, field := range fields {
fieldMap[field.FieldKey] = field
if field.Required && field.SourcePath != "" && isEmptyValue(answers[field.FieldKey]) {
return fmt.Errorf("缺少必填输入%s", field.FieldName)
}
}
for key, value := range answers {
field, exists := fieldMap[key]
if !exists {
return fmt.Errorf("传入字段 %s 不存在", key)
}
if isEmptyValue(value) {
continue
}
if err := validateFieldValue(field, value); err != nil {
return err
}
}
return nil
}
func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
@@ -39,9 +66,12 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
return fmt.Errorf("当前表单没有配置采集字段")
}
for _, key := range config.FieldKeys {
expected[key] = false
expected[key] = containsString(config.RequiredFieldKeys, key)
}
default:
if node.Type == "knowledge" {
return validateKnowledgeNodeAnswers(node, fieldMap, answers)
}
if len(answers) > 0 {
return fmt.Errorf("当前节点不接受字段回答")
}
@@ -76,6 +106,87 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
return nil
}
func validateKnowledgeNodeAnswers(node model.SOPNode, fields map[string]model.ScenarioField, answers map[string]interface{}) error {
var config knowledgeNodeConfig
if json.Unmarshal(node.Config, &config) != nil || config.KnowledgeCollection == nil {
if len(answers) > 0 {
return fmt.Errorf("当前节点不接受字段回答")
}
return nil
}
expected := map[string]bool{}
for _, key := range config.KnowledgeCollection.ContextFieldKeys {
expected[key] = fields[key].Required
}
for _, step := range config.KnowledgeCollection.Steps {
expected[step.FieldKey] = step.Required
}
for key := range answers {
if _, ok := expected[key]; !ok {
return fmt.Errorf("字段 %s 不属于当前节点", key)
}
}
for key, required := range expected {
value := answers[key]
if isEmptyValue(value) {
if required {
return fmt.Errorf("请填写%s", knowledgeAnswerName(config, fields, key))
}
continue
}
if field, ok := fields[key]; ok && !isKnowledgeStep(config, key) {
if err := validateFieldValue(field, value); err != nil {
return err
}
}
if isKnowledgeStep(config, key) {
if _, ok := stringValues(value); !ok {
return fmt.Errorf("%s必须选择有效选项", knowledgeAnswerName(config, fields, key))
}
}
}
return nil
}
func knowledgeAnswerName(config knowledgeNodeConfig, fields map[string]model.ScenarioField, key string) string {
if field, ok := fields[key]; ok {
return field.FieldName
}
for _, step := range config.KnowledgeCollection.Steps {
if step.FieldKey == key {
return step.Name
}
}
return key
}
func isKnowledgeStep(config knowledgeNodeConfig, key string) bool {
for _, step := range config.KnowledgeCollection.Steps {
if step.FieldKey == key {
return true
}
}
return false
}
func stringValues(value interface{}) ([]string, bool) {
switch typed := value.(type) {
case string:
return []string{typed}, typed != ""
case []interface{}:
result := make([]string, 0, len(typed))
for _, item := range typed {
text, ok := item.(string)
if !ok || text == "" {
return nil, false
}
result = append(result, text)
}
return result, true
case []string:
return typed, true
}
return nil, false
}
func stringAllowed(options []string, selected string) bool {
for _, option := range options {
if option == selected {
@@ -140,6 +251,15 @@ func validateFieldValue(field model.ScenarioField, value interface{}) error {
return fmt.Errorf("%s包含不正确的选项", field.FieldName)
}
}
case "array":
switch values := value.(type) {
case []interface{}:
_ = values
case []string:
_ = values
default:
return fmt.Errorf("%s必须是数组", field.FieldName)
}
case "date":
text, ok := value.(string)
if !ok || !validDate(text) {

View File

@@ -47,6 +47,14 @@ func TestValidateNodeAnswersRejectsAnswersForMessage(t *testing.T) {
}
}
func TestValidateNodeAnswersSupportsNodeRequiredFormFields(t *testing.T) {
fields := []model.ScenarioField{{FieldKey: "pet_name", FieldName: "宠物名称", FieldType: "text"}}
node := model.SOPNode{Type: "form", Config: datatypes.JSON([]byte(`{"field_keys":["pet_name"],"required_field_keys":["pet_name"]}`))}
if err := validateNodeAnswers(node, fields, map[string]interface{}{}); err == nil || !strings.Contains(err.Error(), "请填写宠物名称") {
t.Fatalf("validateNodeAnswers() error = %v, want node-required error", err)
}
}
func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
fields := []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "text"}}
node := model.SOPNode{Type: "choice", Config: datatypes.JSON([]byte(`{"field_key":"intent","required":true,"options":["继续了解","暂不考虑"]}`))}
@@ -58,3 +66,26 @@ func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
t.Fatalf("validateNodeAnswers() error = %v, want invalid choice option", err)
}
}
func TestValidateInitialAnswers(t *testing.T) {
fields := []model.ScenarioField{
{FieldKey: "pet_name", FieldName: "宠物名称", FieldType: "text", Required: true},
{FieldKey: "pet_weight", FieldName: "体重", FieldType: "number"},
}
if err := validateInitialAnswers(fields, map[string]interface{}{"pet_name": "团子"}); err != nil {
t.Fatalf("validateInitialAnswers() error = %v", err)
}
if err := validateInitialAnswers(fields, map[string]interface{}{"unknown": "value"}); err == nil || !strings.Contains(err.Error(), "不存在") {
t.Fatalf("validateInitialAnswers() error = %v, want unknown-field error", err)
}
if err := validateInitialAnswers(fields, map[string]interface{}{"pet_weight": "heavy"}); err == nil || !strings.Contains(err.Error(), "必须是数字") {
t.Fatalf("validateInitialAnswers() error = %v, want value-type error", err)
}
}
func TestValidateInitialAnswersRequiresMappedInput(t *testing.T) {
fields := []model.ScenarioField{{FieldKey: "customer_id", FieldName: "客户 ID", FieldType: "text", SourcePath: "customer.id", Required: true}}
if err := validateInitialAnswers(fields, map[string]interface{}{}); err == nil {
t.Fatal("expected missing mapped input to fail")
}
}

View File

@@ -0,0 +1,280 @@
package scenario
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type contractInput struct {
OutputSchema map[string]interface{} `json:"output_schema"`
ResultSchema map[string]interface{} `json:"result_schema"`
Rules []ruleInput `json:"rules"`
AllowedOrigins []string `json:"allowed_origins"`
}
type ruleInput struct {
RuleKey string `json:"rule_key"`
Name string `json:"name"`
Condition map[string]interface{} `json:"condition"`
Actions []map[string]interface{} `json:"actions"`
Priority int `json:"priority"`
Status string `json:"status"`
}
func (h *Handler) GetContract(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := contractScenarioID(c)
if !ok || !access.CanViewScenario(h.db, p, id) {
if ok {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
}
return
}
var scenario model.Scenario
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&scenario).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
return
}
rules := make([]model.ScenarioRule, 0)
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("priority,id").Find(&rules).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景规则失败")
return
}
response.OK(c, gin.H{"input_schema": scenario.InputSchema, "output_schema": scenario.OutputSchema, "result_schema": scenario.ResultSchema, "scenario_key": scenario.ScenarioKey, "public_key": scenario.PublicKey, "allowed_origins": scenario.AllowedOrigins, "rules": rules})
}
func (h *Handler) ReplaceContract(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := contractScenarioID(c)
if !ok || !access.CanEditScenario(h.db, p, id) {
if ok {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
}
return
}
var input contractInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "契约 JSON 格式不正确")
return
}
if input.OutputSchema == nil {
input.OutputSchema = map[string]interface{}{"fields": []interface{}{}}
}
if input.ResultSchema == nil {
input.ResultSchema = map[string]interface{}{"fields": []interface{}{}}
}
if err := validateContractInput(input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
seen := map[string]bool{}
for _, rule := range input.Rules {
if !fieldKeyPattern.MatchString(rule.RuleKey) || rule.Name == "" || seen[rule.RuleKey] {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "规则标识必须唯一且格式正确")
return
}
seen[rule.RuleKey] = true
if rule.Status != "" && rule.Status != "active" && rule.Status != "disabled" {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "规则状态不正确")
return
}
}
outputRaw, _ := json.Marshal(input.OutputSchema)
resultRaw, _ := json.Marshal(input.ResultSchema)
originsRaw, _ := json.Marshal(input.AllowedOrigins)
err := h.db.Transaction(func(tx *gorm.DB) error {
var oldRules []model.ScenarioRule
if err := tx.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Find(&oldRules).Error; err != nil {
return err
}
if err := tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(map[string]interface{}{"output_schema": datatypes.JSON(outputRaw), "result_schema": datatypes.JSON(resultRaw), "allowed_origins": datatypes.JSON(originsRaw)}).Error; err != nil {
return err
}
if err := tx.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioRule{}).Error; err != nil {
return err
}
for _, rule := range input.Rules {
condition, _ := json.Marshal(rule.Condition)
actions, _ := json.Marshal(rule.Actions)
status := rule.Status
if status == "" {
status = "active"
}
row := model.ScenarioRule{TenantID: p.TenantID, ScenarioID: id, RuleKey: rule.RuleKey, Name: rule.Name, Condition: datatypes.JSON(condition), Actions: datatypes.JSON(actions), Priority: rule.Priority, Status: status}
if err := tx.Create(&row).Error; err != nil {
return err
}
if err := audit.RecordTx(tx, p, "create", "scenario_rule", row.ID, gin.H{"scenario_id": id, "rule_key": row.RuleKey}); err != nil {
return err
}
}
for _, rule := range oldRules {
if err := audit.RecordTx(tx, p, "archive", "scenario_rule", rule.ID, gin.H{"scenario_id": id, "rule_key": rule.RuleKey, "name": rule.Name, "priority": rule.Priority, "status": rule.Status}); err != nil {
return err
}
}
return audit.RecordTx(tx, p, "update", "scenario", id, gin.H{"contract": true})
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存场景契约失败")
return
}
h.GetContract(c)
}
func validateContractInput(input contractInput) error {
fields, ok := input.OutputSchema["fields"].([]interface{})
if !ok {
return fmt.Errorf("output_schema.fields 必须是数组")
}
outputKeys := map[string]bool{}
allowedSources := map[string]bool{"input": true, "derived": true, "knowledge": true, "form": true, "system": true}
for index, raw := range fields {
field, ok := raw.(map[string]interface{})
if !ok {
return fmt.Errorf("第 %d 个输出字段必须是对象", index+1)
}
key, _ := field["key"].(string)
if !fieldKeyPattern.MatchString(key) || outputKeys[key] {
return fmt.Errorf("输出字段标识必须唯一且格式正确")
}
outputKeys[key] = true
source, _ := field["source"].(string)
if source == "" {
source = "input"
}
if !allowedSources[source] {
return fmt.Errorf("输出字段 %s 使用了不支持的来源 %s", key, source)
}
if sourceField, exists := field["source_field"]; exists {
value, ok := sourceField.(string)
if !ok || !fieldKeyPattern.MatchString(value) {
return fmt.Errorf("输出字段 %s 的 source_field 格式不正确", key)
}
}
}
if err := resultcontract.ValidateSchema(input.ResultSchema); err != nil {
return err
}
for _, origin := range input.AllowedOrigins {
if origin == "" || (origin != "*" && !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://")) {
return fmt.Errorf("允许域名必须是完整的 http/https Origin")
}
}
for _, rule := range input.Rules {
if len(rule.Condition) == 0 {
return fmt.Errorf("规则 %s 的条件不能为空", rule.RuleKey)
}
if err := validateContractCondition(rule.Condition, 0); err != nil {
return fmt.Errorf("规则 %s 条件不正确: %w", rule.RuleKey, err)
}
if len(rule.Actions) == 0 {
return fmt.Errorf("规则 %s 至少需要一个动作", rule.RuleKey)
}
for _, action := range rule.Actions {
operation, _ := action["operation"].(string)
field, _ := action["field"].(string)
if operation != "set" && operation != "append" {
return fmt.Errorf("规则 %s 使用了不支持的动作 %s", rule.RuleKey, operation)
}
if !fieldKeyPattern.MatchString(field) {
return fmt.Errorf("规则 %s 的动作目标字段格式不正确", rule.RuleKey)
}
valuePresent := false
if _, exists := action["value"]; exists {
valuePresent = true
}
if source, exists := action["value_from"]; exists {
value, ok := source.(string)
if !ok || !contextFieldPattern(value) {
return fmt.Errorf("规则 %s 的 value_from 格式不正确", rule.RuleKey)
}
valuePresent = true
}
if !valuePresent {
return fmt.Errorf("规则 %s 的动作缺少 value", rule.RuleKey)
}
}
}
return nil
}
func validateContractCondition(value interface{}, depth int) error {
if depth > 12 {
return fmt.Errorf("条件嵌套层级过深")
}
rule, ok := value.(map[string]interface{})
if !ok {
return fmt.Errorf("条件必须是对象")
}
groups := 0
for _, key := range []string{"all", "any"} {
if raw, exists := rule[key]; exists {
groups++
items, ok := raw.([]interface{})
if !ok || len(items) == 0 || len(items) > 100 {
return fmt.Errorf("%s 条件必须是非空数组且最多包含 100 项", key)
}
for _, item := range items {
if err := validateContractCondition(item, depth+1); err != nil {
return err
}
}
}
}
if groups > 1 {
return fmt.Errorf("条件不能同时包含 all 和 any")
}
if groups == 1 {
return nil
}
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
allowedOperators := map[string]bool{"equals": true, "not_equals": true, "contains": true, "greater_than": true, "less_than": true, "exists": true, "not_exists": true, "in": true}
if !contextFieldPattern(field) {
return fmt.Errorf("条件字段格式不正确")
}
if !allowedOperators[operator] {
return fmt.Errorf("不支持的条件运算符 %s", operator)
}
if operator != "exists" && operator != "not_exists" {
if _, exists := rule["value"]; !exists {
return fmt.Errorf("条件缺少 value")
}
}
return nil
}
func contextFieldPattern(value string) bool {
if fieldKeyPattern.MatchString(value) {
return true
}
for _, prefix := range []string{"input.", "derived.", "form."} {
if strings.HasPrefix(value, prefix) && fieldKeyPattern.MatchString(strings.TrimPrefix(value, prefix)) {
return true
}
}
return false
}
func contractScenarioID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "场景 ID 不正确")
return 0, false
}
return id, true
}

View File

@@ -0,0 +1,71 @@
package scenario
import (
"strings"
"testing"
)
func TestValidateContractInput(t *testing.T) {
valid := contractInput{
OutputSchema: map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"key": "matched_topics", "source": "derived", "source_field": "topics"},
}},
ResultSchema: map[string]interface{}{"fields": []interface{}{
map[string]interface{}{"key": "selected_products", "name": "成交商品", "type": "array", "items": map[string]interface{}{"type": "string"}},
}},
Rules: []ruleInput{{
RuleKey: "match_topic", Name: "匹配主题",
Condition: map[string]interface{}{"field": "product_tags", "operator": "contains", "value": "hot"},
Actions: []map[string]interface{}{{"operation": "append", "field": "topics", "value": "topic_hot"}},
}},
AllowedOrigins: []string{"https://crm.example.com"},
}
if err := validateContractInput(valid); err != nil {
t.Fatalf("valid contract rejected: %v", err)
}
tests := []struct {
name string
edit func(*contractInput)
want string
}{
{name: "duplicate output", edit: func(input *contractInput) {
input.OutputSchema["fields"] = append(input.OutputSchema["fields"].([]interface{}), map[string]interface{}{"key": "matched_topics"})
}, want: "输出字段标识"},
{name: "invalid source", edit: func(input *contractInput) {
input.OutputSchema["fields"].([]interface{})[0].(map[string]interface{})["source"] = "script"
}, want: "不支持的来源"},
{name: "invalid condition", edit: func(input *contractInput) { input.Rules[0].Condition = map[string]interface{}{"all": []interface{}{}} }, want: "非空数组"},
{name: "invalid action", edit: func(input *contractInput) { input.Rules[0].Actions[0]["operation"] = "execute" }, want: "不支持的动作"},
{name: "invalid origin", edit: func(input *contractInput) { input.AllowedOrigins = []string{"crm.example.com"} }, want: "完整的 http/https Origin"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := cloneContractInput(valid)
test.edit(&input)
if err := validateContractInput(input); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want containing %q", err, test.want)
}
})
}
}
func cloneContractInput(input contractInput) contractInput {
field := input.OutputSchema["fields"].([]interface{})[0].(map[string]interface{})
fieldCopy := map[string]interface{}{}
for key, value := range field {
fieldCopy[key] = value
}
rule := input.Rules[0]
condition := map[string]interface{}{}
for key, value := range rule.Condition {
condition[key] = value
}
action := map[string]interface{}{}
for key, value := range rule.Actions[0] {
action[key] = value
}
rule.Condition = condition
rule.Actions = []map[string]interface{}{action}
return contractInput{OutputSchema: map[string]interface{}{"fields": []interface{}{fieldCopy}}, ResultSchema: input.ResultSchema, Rules: []ruleInput{rule}, AllowedOrigins: append([]string{}, input.AllowedOrigins...)}
}

View File

@@ -14,6 +14,7 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
)
@@ -27,18 +28,21 @@ func NewHandler(db *gorm.DB) *Handler {
}
type scenarioInput struct {
Name string `json:"name" binding:"required,max=128"`
Industry string `json:"industry" binding:"required,max=64"`
RoleName string `json:"role_name" binding:"required,max=64"`
Goal string `json:"goal" binding:"required,max=2000"`
TriggerText string `json:"trigger_text" binding:"required,max=2000"`
Visibility string `json:"visibility" binding:"omitempty,oneof=private team tenant"`
Name string `json:"name" binding:"required,max=128"`
Industry string `json:"industry" binding:"required,max=64"`
RoleName string `json:"role_name" binding:"required,max=64"`
Goal string `json:"goal" binding:"required,max=2000"`
TriggerText string `json:"trigger_text" binding:"required,max=2000"`
Visibility string `json:"visibility" binding:"omitempty,oneof=private team tenant"`
OutputSchema json.RawMessage `json:"output_schema"`
ResultSchema json.RawMessage `json:"result_schema"`
}
type fieldInput struct {
FieldKey string `json:"field_key" binding:"required,max=64"`
FieldName string `json:"field_name" binding:"required,max=128"`
FieldType string `json:"field_type" binding:"required,oneof=text textarea number boolean select multiselect date"`
SourcePath string `json:"source_path" binding:"max=255"`
FieldType string `json:"field_type" binding:"required,oneof=text textarea number boolean select multiselect array date"`
Required bool `json:"required"`
Options json.RawMessage `json:"options"`
Validation json.RawMessage `json:"validation"`
@@ -46,6 +50,24 @@ type fieldInput struct {
}
var fieldKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
var sourcePathPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*(?:\.(?:[A-Za-z][A-Za-z0-9_]*|\*)|\[(?:\d+|\*)\])*$`)
func buildInputSchema(fields []model.ScenarioField) datatypes.JSON {
items := make([]gin.H, 0, len(fields))
for _, field := range fields {
items = append(items, gin.H{"key": field.FieldKey, "name": field.FieldName, "type": field.FieldType, "source_path": field.SourcePath, "required": field.Required, "options": field.Options, "validation": field.Validation})
}
raw, _ := json.Marshal(gin.H{"fields": items})
return datatypes.JSON(raw)
}
func syncInputSchema(tx *gorm.DB, tenantID, scenarioID uint64) error {
fields := make([]model.ScenarioField, 0)
if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Order("sort_order, id").Find(&fields).Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, tenantID).Update("input_schema", buildInputSchema(fields)).Error
}
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
@@ -80,7 +102,7 @@ func (h *Handler) Create(c *gin.Context) {
if visibility == "" {
visibility = "tenant"
}
item := model.Scenario{TenantID: p.TenantID, Name: input.Name, Industry: input.Industry, RoleName: input.RoleName, Goal: input.Goal, TriggerText: input.TriggerText, Visibility: visibility, Status: "draft", CreatedBy: p.UserID}
item := model.Scenario{TenantID: p.TenantID, ScenarioKey: "scenario-" + uuid.NewString(), PublicKey: "pk_" + uuid.NewString(), AllowedOrigins: datatypes.JSON([]byte(`[]`)), Name: input.Name, Industry: input.Industry, RoleName: input.RoleName, Goal: input.Goal, TriggerText: input.TriggerText, Visibility: visibility, Status: "draft", CreatedBy: p.UserID, InputSchema: datatypes.JSON([]byte(`{"fields":[]}`)), OutputSchema: normalizedJSON(input.OutputSchema, `{"fields":[]}`), ResultSchema: normalizedJSON(input.ResultSchema, `{"fields":[]}`)}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建场景失败")
return
@@ -107,6 +129,7 @@ func (h *Handler) Get(c *gin.Context) {
fields := make([]model.ScenarioField, 0)
sops := make([]model.SOP, 0)
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("sort_order, id").Find(&fields)
item.InputSchema = buildInputSchema(fields)
if auth.HasPermission(p, "sop.view") {
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops)
}
@@ -133,6 +156,12 @@ func (h *Handler) Update(c *gin.Context) {
visibility = "tenant"
}
updates := map[string]interface{}{"name": input.Name, "industry": input.Industry, "role_name": input.RoleName, "goal": input.Goal, "trigger_text": input.TriggerText, "visibility": visibility}
if len(input.OutputSchema) > 0 {
updates["output_schema"] = normalizedJSON(input.OutputSchema, `{"fields":[]}`)
}
if len(input.ResultSchema) > 0 {
updates["result_schema"] = normalizedJSON(input.ResultSchema, `{"fields":[]}`)
}
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").Updates(updates)
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
@@ -152,15 +181,6 @@ func (h *Handler) Archive(c *gin.Context) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可归档")
return
}
var activeSOPs int64
if err := h.db.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"published", "reviewing"}).Count(&activeSOPs).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "检查场景状态失败")
return
}
if activeSOPs > 0 {
response.Error(c, http.StatusConflict, "SCENARIO_IN_USE", "请先下线已发布 SOP 或处理审核任务")
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error; err != nil {
return err
@@ -193,11 +213,15 @@ func (h *Handler) CreateField(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
item := model.ScenarioField{TenantID: p.TenantID, ScenarioID: scenarioID, FieldKey: input.FieldKey, FieldName: input.FieldName, FieldType: input.FieldType, Required: input.Required, Options: normalizedJSON(input.Options, `[]`), Validation: normalizedJSON(input.Validation, `{}`), SortOrder: input.SortOrder}
item := model.ScenarioField{TenantID: p.TenantID, ScenarioID: scenarioID, FieldKey: input.FieldKey, FieldName: input.FieldName, SourcePath: input.SourcePath, FieldType: input.FieldType, Required: input.Required, Options: normalizedJSON(input.Options, `[]`), Validation: normalizedJSON(input.Validation, `{}`), SortOrder: input.SortOrder}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusConflict, "CREATE_FAILED", "字段标识已存在或配置不正确")
return
}
if err := syncInputSchema(h.db, p.TenantID, scenarioID); err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "更新输入契约失败")
return
}
_ = audit.Record(h.db, p, "create", "scenario_field", item.ID, input)
response.Created(c, item)
}
@@ -214,7 +238,7 @@ func (h *Handler) UpdateField(c *gin.Context) {
return
}
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能修改;请新增字段并创建 SOP 新版本")
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被当前 SOP 引用,不能修改;请先调整 SOP")
return
}
var input fieldInput
@@ -226,12 +250,16 @@ func (h *Handler) UpdateField(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return
}
updates := map[string]interface{}{"field_key": input.FieldKey, "field_name": input.FieldName, "field_type": input.FieldType, "required": input.Required, "options": normalizedJSON(input.Options, `[]`), "validation": normalizedJSON(input.Validation, `{}`), "sort_order": input.SortOrder}
updates := map[string]interface{}{"field_key": input.FieldKey, "field_name": input.FieldName, "source_path": input.SourcePath, "field_type": input.FieldType, "required": input.Required, "options": normalizedJSON(input.Options, `[]`), "validation": normalizedJSON(input.Validation, `{}`), "sort_order": input.SortOrder}
result := h.db.Model(&model.ScenarioField{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(updates)
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
return
}
if err := syncInputSchema(h.db, p.TenantID, existing.ScenarioID); err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "更新输入契约失败")
return
}
_ = audit.Record(h.db, p, "update", "scenario_field", id, input)
var item model.ScenarioField
h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item)
@@ -250,7 +278,7 @@ func (h *Handler) DeleteField(c *gin.Context) {
return
}
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能删除")
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被当前 SOP 引用,不能删除")
return
}
result := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioField{})
@@ -258,7 +286,21 @@ func (h *Handler) DeleteField(c *gin.Context) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
return
}
_ = audit.Record(h.db, p, "delete", "scenario_field", id, nil)
if err := syncInputSchema(h.db, p.TenantID, existing.ScenarioID); err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "更新输入契约失败")
return
}
_ = audit.Record(h.db, p, "delete", "scenario_field", id, gin.H{
"scenario_id": existing.ScenarioID,
"field_key": existing.FieldKey,
"field_name": existing.FieldName,
"source_path": existing.SourcePath,
"field_type": existing.FieldType,
"required": existing.Required,
"options": json.RawMessage(existing.Options),
"validation": json.RawMessage(existing.Validation),
"sort_order": existing.SortOrder,
})
response.OK(c, gin.H{"id": id})
}
@@ -273,6 +315,9 @@ func validateFieldInput(input fieldInput) error {
if !fieldKeyPattern.MatchString(input.FieldKey) {
return errors.New("字段标识必须以字母开头,且只能包含字母、数字和下划线")
}
if input.SourcePath != "" && !sourcePathPattern.MatchString(input.SourcePath) {
return errors.New("数据路径格式不正确,例如 customer.name 或 order.items[*].product_id")
}
if len(input.Options) > 0 {
var options []string
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {

View File

@@ -5,7 +5,6 @@ import (
"errors"
"net/http"
"strconv"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
@@ -15,7 +14,6 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Handler struct {
@@ -54,6 +52,10 @@ type edgeInput struct {
Priority int `json:"priority"`
}
type editState struct {
StartNodeKey string `json:"start_node_key"`
}
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
items := make([]model.SOP, 0)
@@ -96,11 +98,11 @@ func (h *Handler) Create(c *gin.Context) {
var item model.SOP
var version model.SOPVersion
err := h.db.Transaction(func(tx *gorm.DB) error {
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "draft", CreatedBy: p.UserID}
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "published", CreatedBy: p.UserID}
if err := tx.Create(&item).Error; err != nil {
return err
}
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "draft", StartNodeKey: "start", CreatedBy: p.UserID}
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
}
@@ -116,14 +118,17 @@ func (h *Handler) Create(c *gin.Context) {
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
}
return tx.Create(&edges).Error
if err := tx.Create(&edges).Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).Update("status", "active").Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败")
return
}
_ = audit.Record(h.db, p, "create", "sop", item.ID, input)
response.Created(c, gin.H{"sop": item, "version": version})
response.Created(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}})
}
func (h *Handler) Get(c *gin.Context) {
@@ -140,17 +145,7 @@ func (h *Handler) Get(c *gin.Context) {
var version model.SOPVersion
var nodes []model.SOPNode
var edges []model.SOPEdge
var err error
if requested := c.Query("version"); requested != "" {
versionNumber, parseErr := strconv.Atoi(requested)
if parseErr != nil || versionNumber < 1 {
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
return
}
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
} else {
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
@@ -159,36 +154,7 @@ func (h *Handler) Get(c *gin.Context) {
}
return
}
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges})
}
type versionItem struct {
model.SOPVersion
CreatorName string `json:"creator_name"`
ReviewerName string `json:"reviewer_name"`
}
func (h *Handler) ListVersions(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
items := make([]versionItem, 0)
err := h.db.Table("sop_versions sv").Select("sv.*, creator.display_name AS creator_name, COALESCE(reviewer.display_name, '') AS reviewer_name").
Joins("JOIN users creator ON creator.id = sv.created_by").
Joins("LEFT JOIN users reviewer ON reviewer.id = sv.reviewed_by").
Where("sv.sop_id = ? AND sv.tenant_id = ?", id, p.TenantID).
Order("sv.version DESC").Scan(&items).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询版本历史失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
response.OK(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}, "nodes": nodes, "edges": edges})
}
func (h *Handler) SaveGraph(c *gin.Context) {
@@ -206,18 +172,22 @@ func (h *Handler) SaveGraph(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
return
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可编辑的草稿版本")
item, version, _, _, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
nodes, edges := toModels(p.TenantID, version.ID, input)
problems := ValidateGraph(input.StartNodeKey, nodes, edges)
problems, validationErr := h.validateForPublish(item, input.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
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 {
return err
}
@@ -232,13 +202,22 @@ func (h *Handler) SaveGraph(c *gin.Context) {
return err
}
}
return tx.Model(&version).Update("start_node_key", input.StartNodeKey).Error
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND id <> ?", id, p.TenantID, version.ID).Update("status", "superseded").Error; err != nil {
return err
}
if err := tx.Model(&version).Updates(map[string]interface{}{"start_node_key": input.StartNodeKey, "status": "published"}).Error; err != nil {
return err
}
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败")
return
}
_ = audit.Record(h.db, p, "save_graph", "sop", id, gin.H{"version_id": version.ID})
_ = audit.Record(h.db, p, "save_graph", "sop", id, nil)
h.Get(c)
}
@@ -256,17 +235,7 @@ func (h *Handler) Validate(c *gin.Context) {
var version model.SOPVersion
var nodes []model.SOPNode
var edges []model.SOPEdge
var err error
if requested := c.Query("version"); requested != "" {
versionNumber, parseErr := strconv.Atoi(requested)
if parseErr != nil || versionNumber < 1 {
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
return
}
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
} else {
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
@@ -279,273 +248,6 @@ func (h *Handler) Validate(c *gin.Context) {
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
}
func (h *Handler) SubmitReview(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanEditSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可提交")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil || version.Status != "draft" {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
return
}
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&version).Update("status", "reviewing").Error; err != nil {
return err
}
var published int64
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Count(&published).Error; err != nil {
return err
}
if published == 0 {
return tx.Model(&item).Update("status", "reviewing").Error
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SUBMIT_REVIEW_FAILED", "提交审核失败")
return
}
_ = audit.Record(h.db, p, "submit_review", "sop", id, gin.H{"version": version.Version})
response.OK(c, gin.H{"id": id, "version": version.Version, "status": "reviewing"})
}
func (h *Handler) Publish(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
canDirectPublish := auth.HasPermission(p, "*")
if err != nil || (version.Status != "reviewing" && !(version.Status == "draft" && canDirectPublish)) {
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
return
}
if !canDirectPublish && version.CreatedBy == p.UserID {
response.Error(c, http.StatusForbidden, "SELF_REVIEW_FORBIDDEN", "不能审核并发布自己创建的 SOP")
return
}
problems, validationErr := h.validateForPublish(item, version.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验流程失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
now := time.Now()
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := BindKnowledgeVersions(tx, p.TenantID, version.ID); err != nil {
return err
}
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "published", "published_at": &now, "reviewed_by": p.UserID}).Error; err != nil {
return err
}
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "PUBLISH_FAILED", "发布 SOP 失败")
return
}
_ = audit.Record(h.db, p, "publish", "sop", id, gin.H{"version": version.Version})
response.OK(c, gin.H{"id": id, "version": version.Version, "published_at": now})
}
func (h *Handler) Offline(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
return
}
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").First(&item).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&item).Update("status", "offline").Error; err != nil {
return err
}
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "offline").Error; err != nil {
return err
}
var published int64
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, p.TenantID, "published").Count(&published).Error; err != nil {
return err
}
if published == 0 {
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "draft").Error
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "OFFLINE_FAILED", "下线 SOP 失败")
return
}
_ = audit.Record(h.db, p, "offline", "sop", id, nil)
response.OK(c, gin.H{"id": id, "status": "offline"})
}
func (h *Handler) CreateVersion(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanEditSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可编辑")
return
}
var existing int64
h.db.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&existing)
if existing > 0 {
response.Error(c, http.StatusConflict, "DRAFT_EXISTS", "已经存在草稿或审核中的版本")
return
}
_, source, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
var version model.SOPVersion
err = h.db.Transaction(func(tx *gorm.DB) error {
version = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: source.Version + 1, Status: "draft", StartNodeKey: source.StartNodeKey, CreatedBy: p.UserID}
if err := tx.Create(&version).Error; err != nil {
return err
}
for i := range nodes {
nodes[i].Base = model.Base{}
nodes[i].SOPVersionID = version.ID
}
for i := range edges {
edges[i].Base = model.Base{}
edges[i].SOPVersionID = version.ID
}
if err := tx.Create(&nodes).Error; err != nil {
return err
}
if len(edges) > 0 {
return tx.Create(&edges).Error
}
return nil
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_VERSION_FAILED", "创建草稿版本失败")
return
}
_ = audit.Record(h.db, p, "create_version", "sop", id, gin.H{"version": version.Version})
response.Created(c, version)
}
func (h *Handler) Rollback(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, p, id) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
var input struct {
Version int `json:"version" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要回滚的历史版本")
return
}
item, source, nodes, edges, err := h.loadVersion(id, p.TenantID, input.Version)
if err != nil || (source.Status != "superseded" && source.Status != "offline") {
response.Error(c, http.StatusConflict, "INVALID_ROLLBACK_VERSION", "只能回滚到已替换或已下线的历史版本")
return
}
problems, validationErr := h.validateForPublish(item, source.StartNodeKey, nodes, edges, p.TenantID)
if validationErr != nil {
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验历史版本失败")
return
}
if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return
}
now := time.Now()
var restored model.SOPVersion
pendingVersionErr := errors.New("存在草稿或审核中的版本,请先处理后再回滚")
err = h.db.Transaction(func(tx *gorm.DB) error {
var locked model.SOP
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&locked).Error; err != nil {
return err
}
var pending int64
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&pending).Error; err != nil {
return err
}
if pending > 0 {
return pendingVersionErr
}
var maxVersion int
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
return err
}
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
return err
}
reviewerID := p.UserID
restored = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: maxVersion + 1, Status: "published", StartNodeKey: source.StartNodeKey, PublishedAt: &now, CreatedBy: p.UserID, ReviewedBy: &reviewerID}
if err := tx.Create(&restored).Error; err != nil {
return err
}
if err := cloneGraph(tx, nodes, edges, restored.ID); err != nil {
return err
}
if err := tx.Model(&locked).Update("status", "published").Error; err != nil {
return err
}
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", locked.ScenarioID, p.TenantID).Update("status", "active").Error
})
if err != nil {
if errors.Is(err, pendingVersionErr) {
response.Error(c, http.StatusConflict, "PENDING_VERSION_EXISTS", err.Error())
return
}
response.Error(c, http.StatusInternalServerError, "ROLLBACK_FAILED", "回滚 SOP 失败")
return
}
_ = audit.Record(h.db, p, "rollback", "sop", id, gin.H{"source_version": source.Version, "new_version": restored.Version})
response.Created(c, gin.H{"id": id, "source_version": source.Version, "version": restored.Version, "published_at": now})
}
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
@@ -566,60 +268,20 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
return item, version, nodes, edges, nil
}
func (h *Handler) loadVersion(sopID, tenantID uint64, versionNumber int) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
return item, model.SOPVersion{}, nil, nil, err
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND version = ?", sopID, tenantID, versionNumber).First(&version).Error; err != nil {
return item, version, nil, nil, err
}
nodes := make([]model.SOPNode, 0)
edges := make([]model.SOPEdge, 0)
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
return item, version, nil, nil, err
}
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("priority, id").Find(&edges).Error; err != nil {
return item, version, nil, nil, err
}
return item, version, nodes, edges, nil
}
func cloneGraph(tx *gorm.DB, nodes []model.SOPNode, edges []model.SOPEdge, versionID uint64) error {
for i := range nodes {
nodes[i].Base = model.Base{}
nodes[i].SOPVersionID = versionID
}
for i := range edges {
edges[i].Base = model.Base{}
edges[i].SOPVersionID = versionID
}
if len(nodes) > 0 {
if err := tx.Create(&nodes).Error; err != nil {
return err
}
}
if len(edges) > 0 {
return tx.Create(&edges).Error
}
return nil
}
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
fields := make([]model.ScenarioField, 0)
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
return nil, err
}
var cards []model.KnowledgeCard
if err := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, tenantID, "published").Find(&cards).Error; err != nil {
var knowledgeItems []model.KnowledgeItem
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeItems).Error; err != nil {
return nil, err
}
cardIDs := make(map[uint64]bool, len(cards))
for _, card := range cards {
cardIDs[card.ID] = true
var knowledgeRelations []model.KnowledgeRelation
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeRelations).Error; err != nil {
return nil, err
}
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, PublishedKnowledgeCardIDs: cardIDs}), 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) {

View File

@@ -1,58 +0,0 @@
package sop
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type knowledgeNodeConfig struct {
KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
}
// BindKnowledgeVersions freezes the current published knowledge-card version
// into every knowledge node before the SOP version becomes immutable.
func BindKnowledgeVersions(tx *gorm.DB, tenantID, sopVersionID uint64) error {
nodes := make([]model.SOPNode, 0)
if err := tx.Where("tenant_id = ? AND sop_version_id = ? AND type = ?", tenantID, sopVersionID, "knowledge").Find(&nodes).Error; err != nil {
return err
}
for _, node := range nodes {
var config knowledgeNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil || config.KnowledgeCardID == 0 {
return fmt.Errorf("knowledge node %s has invalid configuration", node.NodeKey)
}
var version model.KnowledgeCardVersion
err := tx.Table("knowledge_card_versions kv").Select("kv.*").
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
Where("kv.tenant_id = ? AND kv.knowledge_card_id = ? AND kv.status = ? AND kc.tenant_id = ? AND kc.status = ?", tenantID, config.KnowledgeCardID, "published", tenantID, "published").
Order("kv.version DESC").First(&version).Error
if err != nil {
return fmt.Errorf("knowledge node %s has no published card version: %w", node.NodeKey, err)
}
updated, err := withKnowledgeVersion(node.Config, version.ID)
if err != nil {
return fmt.Errorf("update knowledge node %s: %w", node.NodeKey, err)
}
if err := tx.Model(&model.SOPNode{}).Where("id = ? AND tenant_id = ? AND sop_version_id = ?", node.ID, tenantID, sopVersionID).Update("config", updated).Error; err != nil {
return err
}
}
return nil
}
func withKnowledgeVersion(config datatypes.JSON, versionID uint64) (datatypes.JSON, error) {
value := map[string]interface{}{}
if len(config) > 0 {
if err := json.Unmarshal(config, &value); err != nil {
return nil, err
}
}
value["knowledge_card_version_id"] = versionID
encoded, err := json.Marshal(value)
return datatypes.JSON(encoded), err
}

View File

@@ -1,22 +0,0 @@
package sop
import (
"encoding/json"
"testing"
"gorm.io/datatypes"
)
func TestWithKnowledgeVersionPreservesConfiguration(t *testing.T) {
updated, err := withKnowledgeVersion(datatypes.JSON([]byte(`{"knowledge_card_id":12,"display":"full"}`)), 34)
if err != nil {
t.Fatal(err)
}
var value map[string]interface{}
if err := json.Unmarshal(updated, &value); err != nil {
t.Fatal(err)
}
if value["knowledge_card_id"] != float64(12) || value["knowledge_card_version_id"] != float64(34) || value["display"] != "full" {
t.Fatalf("unexpected knowledge config: %#v", value)
}
}

View File

@@ -1,90 +0,0 @@
package sop
import (
"net/http"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ReviewItem struct {
SOPID uint64 `json:"sop_id"`
SOPName string `json:"sop_name"`
Description string `json:"description"`
ScenarioID uint64 `json:"scenario_id"`
ScenarioName string `json:"scenario_name"`
VersionID uint64 `json:"version_id"`
Version int `json:"version"`
CreatorID uint64 `json:"creator_id"`
CreatorName string `json:"creator_name"`
SubmittedAt time.Time `json:"submitted_at"`
}
func (h *Handler) Reviews(c *gin.Context) {
principal, _ := auth.PrincipalFromContext(c)
items := make([]ReviewItem, 0)
query := h.db.Table("sop_versions sv").Select(
"s.id AS sop_id, s.name AS sop_name, s.description, sc.id AS scenario_id, sc.name AS scenario_name, " +
"sv.id AS version_id, sv.version, sv.created_by AS creator_id, u.display_name AS creator_name, sv.updated_at AS submitted_at",
).Joins("JOIN sops s ON s.id = sv.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN users u ON u.id = sv.created_by")
query = access.ScopeScenarios(query, principal, "sc")
if err := query.Where("sv.tenant_id = ? AND sv.status = ?", principal.TenantID, "reviewing").Order("sv.updated_at ASC").Scan(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询审核任务失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Reject(c *gin.Context) {
principal, _ := auth.PrincipalFromContext(c)
sopID, ok := parseID(c, "id")
if !ok {
return
}
if !access.CanViewSOP(h.db, principal, sopID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
var input struct {
Reason string `json:"reason" binding:"required,max=1000"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请填写退回原因")
return
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", sopID, principal.TenantID, "reviewing").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有待审核版本")
return
}
if !auth.HasPermission(principal, "*") && version.CreatedBy == principal.UserID {
response.Error(c, http.StatusForbidden, "SELF_REVIEW_FORBIDDEN", "不能审核自己创建的 SOP")
return
}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "draft", "reviewed_by": nil}).Error; err != nil {
return err
}
var published int64
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", sopID, principal.TenantID, "published").Count(&published).Error; err != nil {
return err
}
status := "draft"
if published > 0 {
status = "published"
}
return tx.Model(&model.SOP{}).Where("id = ? AND tenant_id = ?", sopID, principal.TenantID).Update("status", status).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "REJECT_FAILED", "退回审核失败")
return
}
_ = audit.Record(h.db, principal, "reject", "sop", sopID, gin.H{"version": version.Version, "reason": input.Reason})
response.OK(c, gin.H{"id": sopID, "version": version.Version, "status": "draft"})
}

View File

@@ -12,8 +12,9 @@ var allowedNodeTypes = map[string]bool{"start": true, "message": true, "question
var allowedConditionOperators = map[string]bool{"equals": true, "not_equals": true, "contains": true, "greater_than": true, "less_than": true, "exists": true, "not_exists": true, "in": true}
type ValidationContext struct {
Fields []model.ScenarioField
PublishedKnowledgeCardIDs map[uint64]bool
Fields []model.ScenarioField
KnowledgeItems []model.KnowledgeItem
KnowledgeRelations []model.KnowledgeRelation
}
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
@@ -134,6 +135,23 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
for _, field := range context.Fields {
fieldMap[field.FieldKey] = field
}
knowledgeByKey := make(map[string]model.KnowledgeItem, len(context.KnowledgeItems))
knowledgeTypes := make(map[string]bool)
knowledgeIDs := make(map[uint64]bool, len(context.KnowledgeItems))
for _, item := range context.KnowledgeItems {
if item.Status != "active" {
continue
}
knowledgeByKey[item.ItemKey] = item
knowledgeTypes[item.Type] = true
knowledgeIDs[item.ID] = true
}
relationTypes := make(map[string]bool)
for _, relation := range context.KnowledgeRelations {
if knowledgeIDs[relation.FromKnowledgeID] && knowledgeIDs[relation.ToKnowledgeID] {
relationTypes[relation.RelationType] = true
}
}
collected := map[string]bool{}
nodeMap := make(map[string]model.SOPNode, len(nodes))
adjacency := make(map[string][]string)
@@ -174,14 +192,38 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
collected[fieldKey] = true
}
case "knowledge":
cardID := uint64FromJSON(config["knowledge_card_id"])
if cardID == 0 || !context.PublishedKnowledgeCardIDs[cardID] {
problems = append(problems, fmt.Sprintf("节点“%s”没有关联已发布的知识卡", node.Title))
selector, ok := config["knowledge_selector"].(map[string]interface{})
if !ok {
problems = append(problems, fmt.Sprintf("节点“%s”没有配置知识选择器", node.Title))
break
}
validateKnowledgeSelector(node, selector, knowledgeByKey, knowledgeTypes, relationTypes, &problems)
if collection, ok := config["knowledge_collection"].(map[string]interface{}); ok {
if keys, ok := collection["context_field_keys"].([]interface{}); ok {
for _, value := range keys {
if key, ok := value.(string); ok {
if _, exists := fieldMap[key]; !exists {
problems = append(problems, fmt.Sprintf("节点“%s”引用的采集字段 %s 不存在", node.Title, key))
} else {
collected[key] = true
}
}
}
}
if steps, ok := collection["steps"].([]interface{}); ok {
for _, raw := range steps {
if step, ok := raw.(map[string]interface{}); ok {
if key, ok := step["field_key"].(string); ok && key != "" {
collected[key] = true
}
}
}
}
}
}
}
for _, field := range context.Fields {
if field.Required && !collected[field.FieldKey] {
if field.Required && field.SourcePath == "" && !collected[field.FieldKey] {
problems = append(problems, fmt.Sprintf("必填字段“%s”没有对应的采集节点", field.FieldName))
}
}
@@ -312,20 +354,61 @@ func isDefaultCondition(value []byte) bool {
return ok && len(object) == 0
}
func uint64FromJSON(value interface{}) uint64 {
switch typed := value.(type) {
case float64:
if typed > 0 {
return uint64(typed)
}
case uint64:
return typed
case int:
if typed > 0 {
return uint64(typed)
func validateKnowledgeSelector(node model.SOPNode, selector map[string]interface{}, items map[string]model.KnowledgeItem, availableTypes, availableRelations map[string]bool, problems *[]string) {
derivedField, _ := selector["derived_field"].(string)
answerField, _ := selector["answer_field"].(string)
keys, keysValid := selectorStrings(selector, "knowledge_keys")
types, typesValid := selectorStrings(selector, "knowledge_types")
relations, relationsValid := selectorStrings(selector, "relation_types")
if !keysValid || !typesValid || !relationsValid {
*problems = append(*problems, fmt.Sprintf("节点“%s”的知识选择器必须使用字符串数组", node.Title))
return
}
if derivedField == "" && answerField == "" && len(keys) == 0 {
*problems = append(*problems, fmt.Sprintf("节点“%s”没有配置派生知识字段或固定知识 key", node.Title))
}
allowedTypes := make(map[string]bool, len(types))
for _, itemType := range types {
allowedTypes[itemType] = true
if !availableTypes[itemType] {
*problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识类型 %s 不存在", node.Title, itemType))
}
}
return 0
for _, key := range keys {
item, exists := items[key]
if !exists {
*problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识 key %s 不存在或未启用", node.Title, key))
continue
}
if len(allowedTypes) > 0 && !allowedTypes[item.Type] {
*problems = append(*problems, fmt.Sprintf("节点“%s”的知识 key %s 不属于允许的根类型", node.Title, key))
}
}
for _, relationType := range relations {
if !availableRelations[relationType] {
*problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识关系类型 %s 不存在", node.Title, relationType))
}
}
}
func selectorStrings(selector map[string]interface{}, key string) ([]string, bool) {
raw, exists := selector[key]
if !exists || raw == nil {
return nil, true
}
values, ok := raw.([]interface{})
if !ok {
return nil, false
}
result := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok || text == "" {
return nil, false
}
result = append(result, text)
}
return result, true
}
func canReachType(start, nodeType string, nodes map[string]model.SOPNode, adjacency map[string][]string) bool {

View File

@@ -15,6 +15,14 @@ func TestValidateForPublishValidHighRiskFlow(t *testing.T) {
}
}
func TestValidateForPublishAllowsRequiredExternalInput(t *testing.T) {
nodes, edges, context := validPublishGraph()
context.Fields = append(context.Fields, model.ScenarioField{FieldKey: "order_id", FieldName: "订单号", SourcePath: "order.id", Required: true})
if problems := ValidateForPublish("start", nodes, edges, context); len(problems) != 0 {
t.Fatalf("required external input should not need a collection node: %v", problems)
}
}
func TestValidateForPublishBusinessRules(t *testing.T) {
tests := []struct {
name string
@@ -30,9 +38,15 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
{name: "invalid operator", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"matches","value":true}`)
}, want: "不支持的运算符 matches"},
{name: "unpublished knowledge", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
context.PublishedKnowledgeCardIDs = map[uint64]bool{}
}, want: "没有关联已发布的知识卡"},
{name: "missing knowledge key", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
context.KnowledgeItems = nil
}, want: "知识 key safety 不存在或未启用"},
{name: "missing knowledge type", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, _ *ValidationContext) {
(*nodes)[2].Config = jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["missing"],"relation_types":["related_copy"]}}`)
}, want: "知识类型 missing 不存在"},
{name: "missing relation type", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, _ *ValidationContext) {
(*nodes)[2].Config = jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["guidance"],"relation_types":["missing"]}}`)
}, want: "知识关系类型 missing 不存在"},
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[1].Condition = jsonData(`{}`)
}, want: "配置了多条默认路径"},
@@ -109,7 +123,7 @@ func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
nodes := []model.SOPNode{
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
{NodeKey: "screen", Type: "form", Title: "急症筛查", Config: jsonData(`{"field_keys":["emergency"],"risk_level":"high"}`)},
{NodeKey: "knowledge", Type: "knowledge", Title: "用药原则", Config: jsonData(`{"knowledge_card_id":1}`)},
{NodeKey: "knowledge", Type: "knowledge", Title: "用药原则", Config: jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["guidance"],"relation_types":["related_copy"]}}`)},
{NodeKey: "escalate", Type: "escalate", Title: "转诊", Config: jsonData(`{}`)},
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
}
@@ -120,8 +134,12 @@ func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
{SourceNodeKey: "knowledge", TargetNodeKey: "finish", Condition: jsonData(`{}`)},
}
context := ValidationContext{
Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}},
PublishedKnowledgeCardIDs: map[uint64]bool{1: true},
Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}},
KnowledgeItems: []model.KnowledgeItem{
{Base: model.Base{ID: 1}, ItemKey: "safety", Type: "guidance", Status: "active"},
{Base: model.Base{ID: 2}, ItemKey: "safety_copy", Type: "copy", Status: "active"},
},
KnowledgeRelations: []model.KnowledgeRelation{{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "related_copy"}},
}
return nodes, edges, context
}