feat: simplify SOP to immediate-effect configuration
This commit is contained in:
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
44
internal/httpserver/server_test.go
Normal file
44
internal/httpserver/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
197
internal/knowledge/content.go
Normal file
197
internal/knowledge/content.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var contentKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,63}$`)
|
||||
var placeholderPattern = regexp.MustCompile(`\{\{\s*((?:input|card)\.[A-Za-z][A-Za-z0-9_]*)\s*\}\}`)
|
||||
|
||||
type CardField struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Value interface{} `json:"value"`
|
||||
SourceField string `json:"source_field,omitempty"`
|
||||
}
|
||||
|
||||
type CopyTemplate struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
Fields []CardField `json:"fields,omitempty"`
|
||||
CopyTemplates []CopyTemplate `json:"copy_templates,omitempty"`
|
||||
StandardCopy string `json:"standard_copy,omitempty"`
|
||||
ForbiddenCopy string `json:"forbidden_copy,omitempty"`
|
||||
RiskNote string `json:"risk_note,omitempty"`
|
||||
}
|
||||
|
||||
type RenderedCopy struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func ParseContent(raw []byte) (Content, error) {
|
||||
var content Content
|
||||
if err := json.Unmarshal(raw, &content); err != nil {
|
||||
return Content{}, errors.New("知识卡内容必须是 JSON 对象")
|
||||
}
|
||||
return normalizeAndValidate(content)
|
||||
}
|
||||
|
||||
func ParseContentMap(value map[string]interface{}) (Content, error) {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
return ParseContent(raw)
|
||||
}
|
||||
|
||||
func ValidateReferences(content Content, scenarioFieldKeys map[string]bool) error {
|
||||
cardFieldKeys := make(map[string]bool, len(content.Fields))
|
||||
for _, field := range content.Fields {
|
||||
cardFieldKeys[field.Key] = true
|
||||
if field.SourceField != "" && !scenarioFieldKeys[field.SourceField] {
|
||||
return fmt.Errorf("知识字段“%s”关联的场景字段“%s”不存在", field.Name, field.SourceField)
|
||||
}
|
||||
}
|
||||
for _, template := range content.CopyTemplates {
|
||||
for _, match := range placeholderPattern.FindAllStringSubmatch(template.Content, -1) {
|
||||
if len(match) != 2 {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(match[1], ".", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
switch parts[0] {
|
||||
case "input":
|
||||
if !scenarioFieldKeys[parts[1]] {
|
||||
return fmt.Errorf("话术“%s”引用的场景字段“%s”不存在", template.Title, parts[1])
|
||||
}
|
||||
case "card":
|
||||
if !cardFieldKeys[parts[1]] {
|
||||
return fmt.Errorf("话术“%s”引用的知识字段“%s”不存在", template.Title, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Render(content Content, answers map[string]interface{}) []RenderedCopy {
|
||||
values := make(map[string]string, len(answers)+len(content.Fields)*2)
|
||||
for key, value := range answers {
|
||||
values["input."+key] = displayValue(value)
|
||||
}
|
||||
for _, field := range content.Fields {
|
||||
value := field.Value
|
||||
if field.SourceField != "" {
|
||||
if answer, ok := answers[field.SourceField]; ok && hasValue(answer) {
|
||||
value = answer
|
||||
}
|
||||
}
|
||||
values["card."+field.Key] = displayValue(value)
|
||||
}
|
||||
copies := make([]RenderedCopy, 0, len(content.CopyTemplates))
|
||||
for _, template := range content.CopyTemplates {
|
||||
copies = append(copies, RenderedCopy{ID: template.ID, Title: template.Title, Content: placeholderPattern.ReplaceAllStringFunc(template.Content, func(token string) string {
|
||||
matches := placeholderPattern.FindStringSubmatch(token)
|
||||
if len(matches) != 2 || values[matches[1]] == "" {
|
||||
return "未提供"
|
||||
}
|
||||
return values[matches[1]]
|
||||
})})
|
||||
}
|
||||
return copies
|
||||
}
|
||||
|
||||
func PrimaryCopy(content Content) string {
|
||||
if len(content.CopyTemplates) > 0 {
|
||||
return content.CopyTemplates[0].Content
|
||||
}
|
||||
return content.StandardCopy
|
||||
}
|
||||
|
||||
func normalizeAndValidate(content Content) (Content, error) {
|
||||
content.StandardCopy = strings.TrimSpace(content.StandardCopy)
|
||||
content.ForbiddenCopy = strings.TrimSpace(content.ForbiddenCopy)
|
||||
content.RiskNote = strings.TrimSpace(content.RiskNote)
|
||||
if len(content.CopyTemplates) == 0 && content.StandardCopy != "" {
|
||||
content.CopyTemplates = []CopyTemplate{{ID: "default", Title: "标准话术", Content: content.StandardCopy}}
|
||||
}
|
||||
if len(content.CopyTemplates) == 0 {
|
||||
return Content{}, errors.New("请至少填写一条话术模板")
|
||||
}
|
||||
seenFields := map[string]bool{}
|
||||
for i := range content.Fields {
|
||||
field := &content.Fields[i]
|
||||
field.Key = strings.TrimSpace(field.Key)
|
||||
field.Name = strings.TrimSpace(field.Name)
|
||||
field.SourceField = strings.TrimSpace(field.SourceField)
|
||||
if !contentKeyPattern.MatchString(field.Key) || field.Name == "" {
|
||||
return Content{}, errors.New("知识字段需要有效的字段标识和字段名称")
|
||||
}
|
||||
if seenFields[field.Key] {
|
||||
return Content{}, fmt.Errorf("知识字段标识“%s”重复", field.Key)
|
||||
}
|
||||
if field.SourceField != "" && !contentKeyPattern.MatchString(field.SourceField) {
|
||||
return Content{}, fmt.Errorf("知识字段“%s”的关联字段标识不正确", field.Name)
|
||||
}
|
||||
seenFields[field.Key] = true
|
||||
}
|
||||
seenTemplates := map[string]bool{}
|
||||
for i := range content.CopyTemplates {
|
||||
template := &content.CopyTemplates[i]
|
||||
template.ID = strings.TrimSpace(template.ID)
|
||||
template.Title = strings.TrimSpace(template.Title)
|
||||
template.Content = strings.TrimSpace(template.Content)
|
||||
if !contentKeyPattern.MatchString(template.ID) || template.Title == "" || template.Content == "" {
|
||||
return Content{}, errors.New("每条话术需要有效的标识、标题和内容")
|
||||
}
|
||||
if seenTemplates[template.ID] {
|
||||
return Content{}, fmt.Errorf("话术标识“%s”重复", template.ID)
|
||||
}
|
||||
seenTemplates[template.ID] = true
|
||||
}
|
||||
if content.StandardCopy == "" {
|
||||
content.StandardCopy = content.CopyTemplates[0].Content
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func hasValue(value interface{}) bool {
|
||||
if value == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(displayValue(value)) != ""
|
||||
}
|
||||
|
||||
func displayValue(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case bool:
|
||||
if typed {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
case []interface{}:
|
||||
items := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
items = append(items, displayValue(item))
|
||||
}
|
||||
return strings.Join(items, "、")
|
||||
case []string:
|
||||
return strings.Join(typed, "、")
|
||||
default:
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
44
internal/knowledge/content_test.go
Normal file
44
internal/knowledge/content_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package knowledge
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseContentAcceptsLegacyStandardCopy(t *testing.T) {
|
||||
content, err := ParseContent([]byte(`{"standard_copy":"请说明情况","risk_note":"必要时转诊"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(content.CopyTemplates) != 1 || content.CopyTemplates[0].ID != "default" || content.StandardCopy != "请说明情况" {
|
||||
t.Fatalf("unexpected legacy content: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReferencesRejectsUnknownScenarioField(t *testing.T) {
|
||||
content, err := ParseContent([]byte(`{"fields":[{"key":"symptom","name":"症状","source_field":"pet_symptom"}],"copy_templates":[{"id":"copy","title":"说明","content":"{{card.symptom}}"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateReferences(content, map[string]bool{"pet_name": true}); err == nil {
|
||||
t.Fatal("ValidateReferences() error = nil, want unknown source field error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReferencesRejectsUnknownTemplateField(t *testing.T) {
|
||||
content, err := ParseContent([]byte(`{"fields":[{"key":"symptom","name":"症状"}],"copy_templates":[{"id":"copy","title":"说明","content":"{{input.pet_name}} {{card.disease}}"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateReferences(content, map[string]bool{"pet_name": true}); err == nil {
|
||||
t.Fatal("ValidateReferences() error = nil, want unknown knowledge field error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderResolvesInputAndCardFields(t *testing.T) {
|
||||
content, err := ParseContent([]byte(`{"fields":[{"key":"symptom","name":"症状","value":"未明确","source_field":"symptom"},{"key":"disease","name":"可能疾病","value":"需要医生评估"}],"copy_templates":[{"id":"copy","title":"说明","content":"{{input.pet_name}}出现{{card.symptom}},{{card.disease}}。{{input.pet_age}}岁。"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
copies := Render(content, map[string]interface{}{"pet_name": "团子", "symptom": "呕吐", "pet_age": 3})
|
||||
if len(copies) != 1 || copies[0].Content != "团子出现呕吐,需要医生评估。3岁。" {
|
||||
t.Fatalf("rendered copies = %#v", copies)
|
||||
}
|
||||
}
|
||||
196
internal/knowledge/graph.go
Normal file
196
internal/knowledge/graph.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var graphKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,63}$`)
|
||||
|
||||
type GraphInput struct {
|
||||
Items []GraphItemInput `json:"items"`
|
||||
Relations []GraphRelationInput `json:"relations"`
|
||||
Symptoms []SymptomInput `json:"symptoms"`
|
||||
}
|
||||
|
||||
type GraphItemInput struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content map[string]interface{} `json:"content"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type GraphRelationInput struct {
|
||||
From string `json:"from"`
|
||||
RelationType string `json:"relation_type"`
|
||||
To string `json:"to"`
|
||||
Condition map[string]interface{} `json:"condition"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type SymptomInput struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
CopyTemplateIDs []string `json:"copy_template_ids"`
|
||||
Diseases []GraphItemInput `json:"diseases"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetGraph(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
scenarioID, ok := graphScenarioID(c)
|
||||
if !ok || !access.CanViewScenario(h.db, p, scenarioID) {
|
||||
if ok {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||
}
|
||||
return
|
||||
}
|
||||
items := make([]model.KnowledgeItem, 0)
|
||||
relations := make([]model.KnowledgeRelation, 0)
|
||||
if err := h.db.Where("tenant_id = ? AND scenario_id = ? AND status <> ?", p.TenantID, scenarioID, "archived").Order("sort_order, id").Find(&items).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识失败")
|
||||
return
|
||||
}
|
||||
if err := h.db.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Order("sort_order, id").Find(&relations).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识关系失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "relations": relations})
|
||||
}
|
||||
|
||||
func (h *Handler) ReplaceGraph(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
scenarioID, ok := graphScenarioID(c)
|
||||
if !ok || !access.CanEditScenario(h.db, p, scenarioID) {
|
||||
if ok {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
||||
}
|
||||
return
|
||||
}
|
||||
var input GraphInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识 JSON 格式不正确")
|
||||
return
|
||||
}
|
||||
items, relations, err := normalizeGraphInput(input)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||
return
|
||||
}
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var oldItems []model.KnowledgeItem
|
||||
var oldRelations []model.KnowledgeRelation
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Find(&oldItems).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Find(&oldRelations).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Delete(&model.KnowledgeRelation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("tenant_id = ? AND scenario_id = ?", p.TenantID, scenarioID).Delete(&model.KnowledgeItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make(map[string]uint64, len(items))
|
||||
for _, item := range items {
|
||||
raw, _ := json.Marshal(item.Content)
|
||||
row := model.KnowledgeItem{TenantID: p.TenantID, ScenarioID: scenarioID, ItemKey: item.Key, Name: item.Name, Type: item.Type, Content: datatypes.JSON(raw), Status: item.Status, SortOrder: item.SortOrder}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ids[item.Key] = row.ID
|
||||
if err := audit.RecordTx(tx, p, "create", "knowledge_item", row.ID, gin.H{"scenario_id": scenarioID, "key": row.ItemKey}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, relation := range relations {
|
||||
raw, _ := json.Marshal(relation.Condition)
|
||||
row := model.KnowledgeRelation{TenantID: p.TenantID, ScenarioID: scenarioID, FromKnowledgeID: ids[relation.From], RelationType: relation.RelationType, ToKnowledgeID: ids[relation.To], Condition: datatypes.JSON(raw), SortOrder: relation.SortOrder}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := audit.RecordTx(tx, p, "create", "knowledge_relation", row.ID, gin.H{"scenario_id": scenarioID, "from": relation.From, "relation_type": relation.RelationType, "to": relation.To}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, relation := range oldRelations {
|
||||
if err := audit.RecordTx(tx, p, "archive", "knowledge_relation", relation.ID, gin.H{"scenario_id": scenarioID, "from_knowledge_id": relation.FromKnowledgeID, "relation_type": relation.RelationType, "to_knowledge_id": relation.ToKnowledgeID, "sort_order": relation.SortOrder}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range oldItems {
|
||||
if err := audit.RecordTx(tx, p, "archive", "knowledge_item", item.ID, gin.H{"scenario_id": scenarioID, "key": item.ItemKey, "name": item.Name, "type": item.Type, "status": item.Status, "sort_order": item.SortOrder}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存知识关系失败")
|
||||
return
|
||||
}
|
||||
h.GetGraph(c)
|
||||
}
|
||||
|
||||
func normalizeGraphInput(input GraphInput) ([]GraphItemInput, []GraphRelationInput, error) {
|
||||
items := append([]GraphItemInput{}, input.Items...)
|
||||
relations := append([]GraphRelationInput{}, input.Relations...)
|
||||
for _, symptom := range input.Symptoms {
|
||||
items = append(items, GraphItemInput{Key: symptom.Key, Name: symptom.Name, Type: "symptom", Status: "active"})
|
||||
for _, disease := range symptom.Diseases {
|
||||
disease.Type = "disease"
|
||||
items = append(items, disease)
|
||||
relations = append(relations, GraphRelationInput{From: symptom.Key, RelationType: "possible_disease", To: disease.Key})
|
||||
}
|
||||
for _, copyKey := range symptom.CopyTemplateIDs {
|
||||
relations = append(relations, GraphRelationInput{From: symptom.Key, RelationType: "recommended_copy", To: copyKey})
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
unique := make([]GraphItemInput, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !graphKeyPattern.MatchString(item.Key) || item.Name == "" || item.Type == "" {
|
||||
return nil, nil, fmt.Errorf("知识 key、name 和 type 必须填写且格式正确")
|
||||
}
|
||||
if seen[item.Key] {
|
||||
continue
|
||||
}
|
||||
seen[item.Key] = true
|
||||
if item.Status == "" {
|
||||
item.Status = "active"
|
||||
}
|
||||
if item.Content == nil {
|
||||
item.Content = map[string]interface{}{}
|
||||
}
|
||||
unique = append(unique, item)
|
||||
}
|
||||
for _, relation := range relations {
|
||||
if !seen[relation.From] || !seen[relation.To] || relation.RelationType == "" {
|
||||
return nil, nil, fmt.Errorf("知识关系引用了不存在的 key")
|
||||
}
|
||||
}
|
||||
return unique, relations, nil
|
||||
}
|
||||
|
||||
func graphScenarioID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ID", "场景 ID 不正确")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
13
internal/knowledge/graph_test.go
Normal file
13
internal/knowledge/graph_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package knowledge
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeGraphInputSupportsMultipleDiseases(t *testing.T) {
|
||||
items, relations, err := normalizeGraphInput(GraphInput{Symptoms: []SymptomInput{{Key: "poor_appetite", Name: "食欲下降", Diseases: []GraphItemInput{{Key: "gi", Name: "肠胃不适"}, {Key: "dental", Name: "口腔问题"}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 3 || len(relations) != 2 {
|
||||
t.Fatalf("items=%d relations=%d", len(items), len(relations))
|
||||
}
|
||||
}
|
||||
@@ -1,205 +1,13 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
import "gorm.io/gorm"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Handler struct{ db *gorm.DB }
|
||||
|
||||
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
|
||||
|
||||
type input struct {
|
||||
ScenarioID uint64 `json:"scenario_id" binding:"required"`
|
||||
Title string `json:"title" binding:"required,max=128"`
|
||||
Content map[string]interface{} `json:"content" binding:"required"`
|
||||
// Handler exposes only the scenario knowledge-graph API. Legacy knowledge-card
|
||||
// tables are retained as read-only storage for historical run rendering.
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type updateInput struct {
|
||||
Title string `json:"title" binding:"required,max=128"`
|
||||
Content map[string]interface{} `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
type row struct {
|
||||
model.KnowledgeCard
|
||||
Content datatypes.JSON `json:"content"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
items := make([]row, 0)
|
||||
query := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content, kcv.version").Joins("JOIN scenarios sc ON sc.id = kc.scenario_id").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published")
|
||||
query = access.ScopeScenarios(query, p, "sc")
|
||||
err := query.Where("kc.tenant_id = ? AND kc.status <> ? AND sc.status <> ?", p.TenantID, "archived", "archived").Order("kc.updated_at DESC").Scan(&items).Error
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
|
||||
return
|
||||
}
|
||||
var body updateInput
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
|
||||
return
|
||||
}
|
||||
if err := validateContent(body.Content); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||
return
|
||||
}
|
||||
content, err := json.Marshal(body.Content)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容格式不正确")
|
||||
return
|
||||
}
|
||||
var card model.KnowledgeCard
|
||||
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanEditScenario(h.db, p, card.ScenarioID) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在或不可编辑")
|
||||
return
|
||||
}
|
||||
var version model.KnowledgeCardVersion
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&card).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var maxVersion int
|
||||
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
version = model.KnowledgeCardVersion{TenantID: p.TenantID, KnowledgeCardID: id, Version: maxVersion + 1, Content: datatypes.JSON(content), Status: "published"}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&card).Updates(map[string]interface{}{"title": body.Title, "status": "published"}).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新知识卡失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "update", "knowledge_card", id, gin.H{"version": version.Version})
|
||||
response.OK(c, gin.H{"id": id, "title": body.Title, "version": version.Version, "content": datatypes.JSON(content)})
|
||||
}
|
||||
|
||||
func (h *Handler) Versions(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
|
||||
return
|
||||
}
|
||||
var card model.KnowledgeCard
|
||||
if err := h.db.Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").First(&card).Error; err != nil || !access.CanViewScenario(h.db, p, card.ScenarioID) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
|
||||
return
|
||||
}
|
||||
items := make([]model.KnowledgeCardVersion, 0)
|
||||
if err := h.db.Where("knowledge_card_id = ? AND tenant_id = ?", id, p.TenantID).Order("version DESC").Find(&items).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡版本失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
var body input
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
|
||||
return
|
||||
}
|
||||
if err := validateContent(body.Content); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||
return
|
||||
}
|
||||
if !access.CanEditScenario(h.db, p, body.ScenarioID) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
||||
return
|
||||
}
|
||||
content, _ := json.Marshal(body.Content)
|
||||
var card model.KnowledgeCard
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
card = model.KnowledgeCard{TenantID: p.TenantID, ScenarioID: body.ScenarioID, Title: body.Title, Status: "published", CreatedBy: p.UserID}
|
||||
if err := tx.Create(&card).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&model.KnowledgeCardVersion{TenantID: p.TenantID, KnowledgeCardID: card.ID, Version: 1, Content: datatypes.JSON(content), Status: "published"}).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建知识卡失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "create", "knowledge_card", card.ID, body)
|
||||
response.Created(c, card)
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ID", "知识卡 ID 不正确")
|
||||
return
|
||||
}
|
||||
var card model.KnowledgeCard
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&card).Error; err != nil || !access.CanEditScenario(h.db, p, card.ScenarioID) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在或不可归档")
|
||||
return
|
||||
}
|
||||
var references int64
|
||||
err = h.db.Table("sop_nodes n").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where(
|
||||
"n.tenant_id = ? AND s.scenario_id = ? AND sv.status IN ? AND n.type = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.knowledge_card_id')) = ?",
|
||||
p.TenantID, card.ScenarioID, []string{"published", "offline", "superseded"}, "knowledge", strconv.FormatUint(id, 10),
|
||||
).Count(&references).Error
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "检查知识卡引用失败")
|
||||
return
|
||||
}
|
||||
if references > 0 {
|
||||
response.Error(c, http.StatusConflict, "KNOWLEDGE_IN_USE", "知识卡已被发布版本引用,不能归档")
|
||||
return
|
||||
}
|
||||
result := h.db.Model(&model.KnowledgeCard{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived")
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "知识卡不存在")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "archive", "knowledge_card", id, nil)
|
||||
response.OK(c, gin.H{"id": id})
|
||||
}
|
||||
|
||||
func validateContent(content map[string]interface{}) error {
|
||||
standard, ok := content["standard_copy"].(string)
|
||||
if !ok || strings.TrimSpace(standard) == "" {
|
||||
return errors.New("请填写标准话术")
|
||||
}
|
||||
for _, key := range []string{"forbidden_copy", "risk_note"} {
|
||||
if value, exists := content[key]; exists {
|
||||
if _, ok := value.(string); !ok {
|
||||
return errors.New("知识卡文本字段格式不正确")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
func NewHandler(db *gorm.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package knowledge
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content map[string]interface{}
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": "风险提示"}},
|
||||
{name: "missing standard copy", content: map[string]interface{}{"risk_note": "风险提示"}, wantErr: true},
|
||||
{name: "blank standard copy", content: map[string]interface{}{"standard_copy": " "}, wantErr: true},
|
||||
{name: "invalid risk note", content: map[string]interface{}{"standard_copy": "标准表达", "risk_note": 1}, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := validateContent(test.content); (err != nil) != test.wantErr {
|
||||
t.Fatalf("validateContent() error = %v, wantErr %v", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
137
internal/multitable/client.go
Normal file
137
internal/multitable/client.go
Normal 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]
|
||||
}
|
||||
120
internal/multitable/client_test.go
Normal file
120
internal/multitable/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
44
internal/multitable/outbox.go
Normal file
44
internal/multitable/outbox.go
Normal 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
|
||||
}
|
||||
335
internal/multitable/projector.go
Normal file
335
internal/multitable/projector.go
Normal 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 "否"
|
||||
}
|
||||
30
internal/multitable/projector_test.go
Normal file
30
internal/multitable/projector_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
101
internal/multitable/worker.go
Normal file
101
internal/multitable/worker.go
Normal 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)
|
||||
}
|
||||
196
internal/resultcontract/schema.go
Normal file
196
internal/resultcontract/schema.go
Normal 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
|
||||
}
|
||||
21
internal/resultcontract/schema_test.go
Normal file
21
internal/resultcontract/schema_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
50
internal/run/generic_scenario_test.go
Normal file
50
internal/run/generic_scenario_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
79
internal/run/knowledge_collection.go
Normal file
79
internal/run/knowledge_collection.go
Normal 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
|
||||
}
|
||||
62
internal/run/knowledge_collection_test.go
Normal file
62
internal/run/knowledge_collection_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
88
internal/run/knowledge_graph_test.go
Normal file
88
internal/run/knowledge_graph_test.go
Normal 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
93
internal/run/mapping.go
Normal 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
|
||||
}
|
||||
24
internal/run/mapping_test.go
Normal file
24
internal/run/mapping_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
22
internal/run/node_view_test.go
Normal file
22
internal/run/node_view_test.go
Normal 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
101
internal/run/outputs.go
Normal 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
|
||||
}
|
||||
21
internal/run/outputs_test.go
Normal file
21
internal/run/outputs_test.go
Normal 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
62
internal/run/preview.go
Normal 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
458
internal/run/public.go
Normal 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
89
internal/run/rules.go
Normal 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
|
||||
}
|
||||
39
internal/run/rules_test.go
Normal file
39
internal/run/rules_test.go
Normal 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
29
internal/run/snapshot.go
Normal 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
|
||||
}
|
||||
160
internal/run/start_presentation.go
Normal file
160
internal/run/start_presentation.go
Normal 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
|
||||
}
|
||||
}
|
||||
46
internal/run/start_presentation_test.go
Normal file
46
internal/run/start_presentation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
280
internal/scenario/contract.go
Normal file
280
internal/scenario/contract.go
Normal 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
|
||||
}
|
||||
71
internal/scenario/contract_test.go
Normal file
71
internal/scenario/contract_test.go
Normal 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...)}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user