feat: simplify SOP to immediate-effect configuration
This commit is contained in:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user