feat: simplify SOP to immediate-effect configuration

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

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine AS web-builder FROM node:22-alpine AS frontend-builder
WORKDIR /src/web WORKDIR /src/web
COPY web/package.json web/package-lock.json ./ COPY web/package.json web/package-lock.json ./
@@ -6,6 +6,12 @@ RUN npm ci
COPY web/ ./ COPY web/ ./
RUN npm run build RUN npm run build
WORKDIR /src/sdk
COPY sdk/package.json sdk/package-lock.json ./
RUN npm ci
COPY sdk/ ./
RUN npm run build
FROM golang:1.24-alpine AS go-builder FROM golang:1.24-alpine AS go-builder
ARG GOPROXY=https://goproxy.cn,direct ARG GOPROXY=https://goproxy.cn,direct
@@ -13,7 +19,8 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN GOPROXY=${GOPROXY} go mod download RUN GOPROXY=${GOPROXY} go mod download
COPY . ./ COPY . ./
COPY --from=web-builder /src/web/dist ./web/dist COPY --from=frontend-builder /src/web/dist ./web/dist
COPY --from=frontend-builder /src/sdk/dist ./sdk/dist
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/iqudo-top1 . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/iqudo-top1 .
FROM alpine:3.22 FROM alpine:3.22

View File

@@ -1,6 +1,7 @@
WEB_DIR := web WEB_DIR := web
SDK_DIR := sdk
.PHONY: web-install web-build build test vet check seed-pet-doctor docker-config docker-up docker-down docker-logs .PHONY: web-install web-build sdk-install sdk-build build test vet check seed-pet-doctor docker-config docker-up docker-down docker-logs
web-install: web-install:
cd $(WEB_DIR) && npm install cd $(WEB_DIR) && npm install
@@ -8,7 +9,13 @@ web-install:
web-build: web-build:
cd $(WEB_DIR) && npm run build cd $(WEB_DIR) && npm run build
build: web-build sdk-install:
cd $(SDK_DIR) && npm install
sdk-build:
cd $(SDK_DIR) && npm run build
build: web-build sdk-build
go build -o bin/iqudo-top1 . go build -o bin/iqudo-top1 .
test: test:

View File

@@ -39,8 +39,8 @@ make seed-pet-doctor
该命令会幂等地创建或完善以下配置: 该命令会幂等地创建或完善以下配置:
- 宠物医生问诊问药场景 - 宠物医生问诊问药场景
- 47 个动态问诊字段,覆盖咨询背景、宠物档案、主诉、生命状态、既往史和用药详情 - 53 个场景输入字段,覆盖订单、商品图片、商品症状标签、客户、宠物、主诉、生命状态、既往史和用药详情
- 线上问诊边界、急症红旗、用药安全、观察与复诊 4 张知识卡 - 线上问诊边界、急症红旗、用药安全、观察与复诊 4 个场景知识实体
- 包含普通问诊、急症转诊、常规用药咨询和用药风险转诊分支的已发布 SOP - 包含宠物信息收集、症状与疾病确认、方案与商品推荐的可用 SOP
示范场景是配置数据,通用流程引擎不包含宠物行业专用判断。 示范场景是配置数据,通用流程引擎不包含宠物行业专用判断。

View File

@@ -24,3 +24,23 @@ seed:
admin_username: admin admin_username: admin
admin_password: "" admin_password: ""
admin_display_name: 平台管理员 admin_display_name: 平台管理员
multitable:
enabled: false
base_url: https://table.iwork-ai.com/open/v1
api_key: ""
sync_interval: 5s
request_timeout: 10s
batch_size: 20
max_attempts: 12
tables:
scenarios: 0
scenario_fields: 0
scenario_rules: 0
sop_versions: 0
sop_nodes: 0
sop_edges: 0
knowledge_items: 0
knowledge_relations: 0
knowledge_versions: 0
runs: 0
feedback: 0

View File

@@ -24,3 +24,23 @@ seed:
admin_username: admin admin_username: admin
admin_password: admin123 admin_password: admin123
admin_display_name: 平台管理员 admin_display_name: 平台管理员
multitable:
enabled: false
base_url: https://table.iwork-ai.com/open/v1
api_key: ""
sync_interval: 5s
request_timeout: 10s
batch_size: 20
max_attempts: 12
tables:
scenarios: 0
scenario_fields: 0
scenario_rules: 0
sop_versions: 0
sop_nodes: 0
sop_edges: 0
knowledge_items: 0
knowledge_relations: 0
knowledge_versions: 0
runs: 0
feedback: 0

7
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/ilyakaznacheev/cleanenv v1.5.0 github.com/ilyakaznacheev/cleanenv v1.5.0
github.com/xuri/excelize/v2 v2.9.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.36.0
gorm.io/datatypes v1.2.7 gorm.io/datatypes v1.2.7
@@ -40,9 +41,15 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
go.uber.org/atomic v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect

17
go.sum
View File

@@ -113,6 +113,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -125,6 +127,10 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -135,12 +141,19 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
@@ -162,6 +175,8 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=

View File

@@ -2,6 +2,8 @@ package audit
import ( import (
"encoding/json" "encoding/json"
"fmt"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth" "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/model"
@@ -11,10 +13,6 @@ import (
) )
func Record(db *gorm.DB, principal auth.Principal, action, resource string, resourceID uint64, payload interface{}) error { 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{ fields := []zap.Field{
zap.Uint64("tenant_id", principal.TenantID), zap.Uint64("tenant_id", principal.TenantID),
zap.Uint64("user_id", principal.UserID), 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.String("resource", resource),
zap.Uint64("resource_id", resourceID), 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))...) zap.L().Error("persist business event", append(fields, zap.Error(err))...)
return err return err
} }
@@ -30,6 +30,43 @@ func Record(db *gorm.DB, principal auth.Principal, action, resource string, reso
return nil 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) { func logBusinessEvent(fields []zap.Field) {
zap.L().Info("business event", fields...) zap.L().Info("business event", fields...)
} }

View File

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

View File

@@ -17,6 +17,7 @@ type Config struct {
Database DatabaseConfig `yaml:"database"` Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"` Auth AuthConfig `yaml:"auth"`
Seed SeedConfig `yaml:"seed"` Seed SeedConfig `yaml:"seed"`
MultiTable MultiTableConfig `yaml:"multitable"`
} }
type AppConfig struct { type AppConfig struct {
@@ -63,6 +64,32 @@ type SeedConfig struct {
AdminDisplayName string `yaml:"admin_display_name" env:"APP_SEED_ADMIN_DISPLAY_NAME"` 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 { type LoadOptions struct {
Environment string Environment string
ConfigDir string ConfigDir string
@@ -138,5 +165,33 @@ func (c Config) Validate() error {
if c.App.Env == "prod" && c.Database.Password == "" { if c.App.Env == "prod" && c.Database.Password == "" {
return errors.New("database password is required in production") 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 return nil
} }

View File

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

View File

@@ -20,13 +20,17 @@ import (
"gorm.io/gorm" "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" { if environment == "prod" {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
router := gin.New() router := gin.New()
router.Use(middleware.CORS())
router.Use(middleware.RequestLogger(log.Named("http")), middleware.Recovery(log.Named("recovery"))) 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) authHandler := auth.NewHandler(authService)
scenarioHandler := scenario.NewHandler(db) scenarioHandler := scenario.NewHandler(db)
sopHandler := sop.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/login", authHandler.Login)
api.POST("/auth/refresh", authHandler.Refresh) 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 := api.Group("")
protected.Use(middleware.Authenticate(authService)) 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.POST("/scenarios/:id/fields", middleware.RequirePermission("scenario.edit"), scenarioHandler.CreateField)
protected.PUT("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.UpdateField) protected.PUT("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.UpdateField)
protected.DELETE("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.DeleteField) 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("/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
protected.GET("/scenarios/:id/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.POST("/scenarios/:id/sops", middleware.RequirePermission("sop.edit"), sopHandler.Create)
protected.GET("/sops/:id", middleware.RequirePermission("sop.view"), sopHandler.Get) 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/graph", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
protected.PUT("/sops/:id/draft", middleware.RequirePermission("sop.edit"), sopHandler.SaveGraph)
protected.POST("/sops/:id/validate", middleware.RequireAnyPermission("sop.view", "sop.edit"), sopHandler.Validate) 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", 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.GET("/runs/options", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Options)
protected.POST("/runs", middleware.RequirePermission("sop.execute"), runHandler.Start) 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/finish", middleware.RequirePermission("sop.execute"), runHandler.Finish)
protected.POST("/runs/:id/feedback", middleware.RequirePermission("runs.feedback"), runHandler.Feedback) protected.POST("/runs/:id/feedback", middleware.RequirePermission("runs.feedback"), runHandler.Feedback)
protected.GET("/knowledge-cards", middleware.RequirePermission("knowledge.view"), knowledgeHandler.List) protected.GET("/scenarios/:id/knowledge-graph", middleware.RequirePermission("knowledge.view"), knowledgeHandler.GetGraph)
protected.POST("/knowledge-cards", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Create) protected.PUT("/scenarios/:id/knowledge-graph", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.ReplaceGraph)
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)
router.NoRoute(spaHandler(frontend)) router.NoRoute(spaHandler(frontend))
return router return router

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,205 +1,13 @@
package knowledge package knowledge
import ( import "gorm.io/gorm"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access" // Handler exposes only the scenario knowledge-graph API. Legacy knowledge-card
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit" // tables are retained as read-only storage for historical run rendering.
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth" type Handler struct {
"git.iwork-ai.com/xdc/iqudo-top1/internal/model" db *gorm.DB
"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"`
} }
type updateInput struct { func NewHandler(db *gorm.DB) *Handler {
Title string `json:"title" binding:"required,max=128"` return &Handler{db: db}
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
} }

View File

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

View File

@@ -13,6 +13,22 @@ import (
"go.uber.org/zap" "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 { func RequestLogger(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
started := time.Now() started := time.Now()

View File

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

View File

@@ -46,6 +46,9 @@ type TenantMember struct {
type Scenario struct { type Scenario struct {
Base Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"` 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"` Name string `json:"name" gorm:"size:128;not null"`
Industry string `json:"industry" gorm:"size:64;not null"` Industry string `json:"industry" gorm:"size:64;not null"`
RoleName string `json:"role_name" gorm:"size:64;not null"` RoleName string `json:"role_name" gorm:"size:64;not null"`
@@ -54,6 +57,9 @@ type Scenario struct {
Visibility string `json:"visibility" gorm:"size:24;not null"` Visibility string `json:"visibility" gorm:"size:24;not null"`
Status string `json:"status" gorm:"size:24;not null"` Status string `json:"status" gorm:"size:24;not null"`
CreatedBy uint64 `json:"created_by" gorm:"not null"` CreatedBy uint64 `json:"created_by" gorm:"not null"`
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 { type ScenarioField struct {
@@ -62,6 +68,7 @@ type ScenarioField struct {
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"` ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
FieldKey string `json:"field_key" gorm:"size:64;not null"` FieldKey string `json:"field_key" gorm:"size:64;not null"`
FieldName string `json:"field_name" gorm:"size:128;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"` FieldType string `json:"field_type" gorm:"size:32;not null"`
Required bool `json:"required" gorm:"not null"` Required bool `json:"required" gorm:"not null"`
Options datatypes.JSON `json:"options" gorm:"type:json;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"` 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 { type SOP struct {
Base Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"` 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"` StartNodeKey string `json:"start_node_key" gorm:"size:64;not null"`
PublishedAt *time.Time `json:"published_at"` PublishedAt *time.Time `json:"published_at"`
CreatedBy uint64 `json:"created_by" gorm:"not null"` CreatedBy uint64 `json:"created_by" gorm:"not null"`
ReviewedBy *uint64 `json:"reviewed_by"`
} }
type SOPNode struct { type SOPNode struct {
@@ -132,16 +150,45 @@ type KnowledgeCardVersion struct {
Status string `json:"status" gorm:"size:24;not null"` 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 { type SOPRun struct {
Base Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"` TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
SOPID uint64 `json:"sop_id" gorm:"not null;index"` SOPID uint64 `json:"sop_id" gorm:"not null;index"`
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"` SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
OperatorID uint64 `json:"operator_id" gorm:"not null;index"` OperatorID uint64 `json:"operator_id" gorm:"not null;index"`
ExternalRef string `json:"external_ref" gorm:"size:191;not null;index"`
CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"` CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"`
Status string `json:"status" gorm:"size:24;not null"` Status string `json:"status" gorm:"size:24;not null"`
Answers datatypes.JSON `json:"answers" gorm:"type:json;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"` Result string `json:"result" gorm:"size:64;not null"`
FinalResult datatypes.JSON `json:"final_result" gorm:"type:json"`
StartedAt time.Time `json:"started_at"` StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"` CompletedAt *time.Time `json:"completed_at"`
} }
@@ -155,6 +202,14 @@ type SOPRunEvent struct {
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"` 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 { type SOPFeedback struct {
Base Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"` 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"` 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 { type RefreshToken struct {
Base Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"` TenantID uint64 `json:"tenant_id" gorm:"not null;index"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package run package run
import ( import (
"encoding/json"
"net/http" "net/http"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth" "git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
@@ -14,7 +15,6 @@ type DetailHeader struct {
model.SOPRun model.SOPRun
SOPName string `json:"sop_name"` SOPName string `json:"sop_name"`
ScenarioName string `json:"scenario_name"` ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
OperatorName string `json:"operator_name"` OperatorName string `json:"operator_name"`
} }
@@ -25,6 +25,7 @@ type DetailEvent struct {
NodeContent string `json:"node_content"` NodeContent string `json:"node_content"`
NodeConfig datatypes.JSON `json:"-"` NodeConfig datatypes.JSON `json:"-"`
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"` Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"`
} }
type DetailFeedback struct { type DetailFeedback struct {
@@ -40,8 +41,8 @@ func (h *Handler) Detail(c *gin.Context) {
} }
var header DetailHeader var header DetailHeader
err := h.db.Table("sop_runs r").Select( 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", "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 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 ).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) { if err != nil || header.ID == 0 || !canViewRun(principal, header.SOPRun) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在") response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return return
@@ -52,6 +53,14 @@ func (h *Handler) Detail(c *gin.Context) {
return return
} }
knowledgeCache := map[string]*KnowledgeView{} 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 { for i := range events {
if events[i].NodeType != "knowledge" { if events[i].NodeType != "knowledge" {
continue continue
@@ -61,9 +70,23 @@ func (h *Handler) Detail(c *gin.Context) {
events[i].Knowledge = cached events[i].Knowledge = cached
continue 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 { if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的知识卡版本不存在") response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的历史知识内容不存在")
return return
} }
events[i].Knowledge = &knowledge events[i].Knowledge = &knowledge

View File

@@ -66,7 +66,7 @@ func matchRule(rule map[string]interface{}, answers map[string]interface{}) (boo
if field == "" || operator == "" { if field == "" || operator == "" {
return false, fmt.Errorf("condition field and operator are required") return false, fmt.Errorf("condition field and operator are required")
} }
actual, exists := answers[field] actual, exists := lookupContextValue(answers, field)
expected := rule["value"] expected := rule["value"]
switch operator { switch operator {
case "exists": 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{} { func normalizeValue(value interface{}) interface{} {
switch typed := value.(type) { switch typed := value.(type) {
case json.Number: case json.Number:

View File

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

View File

@@ -13,7 +13,9 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth" "git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model" "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/response"
"git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -27,7 +29,6 @@ type listItem struct {
model.SOPRun model.SOPRun
SOPName string `json:"sop_name"` SOPName string `json:"sop_name"`
ScenarioName string `json:"scenario_name"` ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
OperatorName string `json:"operator_name"` OperatorName string `json:"operator_name"`
} }
@@ -40,7 +41,7 @@ func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db} return &Handler{db: db}
} }
func (h *Handler) PublishedSOPs(c *gin.Context) { func (h *Handler) AvailableSOPs(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c) p, _ := auth.PrincipalFromContext(c)
type item struct { type item struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
@@ -48,14 +49,13 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
Description string `json:"description"` Description string `json:"description"`
ScenarioID uint64 `json:"scenario_id"` ScenarioID uint64 `json:"scenario_id"`
ScenarioName string `json:"scenario_name"` ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
} }
items := make([]item, 0) items := make([]item, 0)
query := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name, 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") query = access.ScopeScenarios(query, p, "sc")
err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
if err != nil { if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可执行 SOP 失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可 SOP 失败")
return return
} }
response.OK(c, gin.H{"items": items, "total": len(items)}) response.OK(c, gin.H{"items": items, "total": len(items)})
@@ -65,26 +65,89 @@ func (h *Handler) Start(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c) p, _ := auth.PrincipalFromContext(c)
var input struct { 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 { if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP") response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP")
return return
} }
if !access.CanViewSOP(h.db, p, input.SOPID) { 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 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 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 { 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 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()} fields := make([]model.ScenarioField, 0)
err := h.db.Transaction(func(tx *gorm.DB) error { 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 { if err := tx.Create(&run).Error; err != nil {
return err 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 { if err != nil {
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动 SOP 失败") 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"). query := scopeRuns(h.db.Table("sop_runs r").
Joins("JOIN sops s ON s.id = r.sop_id"). Joins("JOIN sops s ON s.id = r.sop_id").
Joins("JOIN scenarios sc ON sc.id = s.scenario_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") Joins("JOIN users u ON u.id = r.operator_id"), p, "r")
if status := c.Query("status"); status != "" { if status := c.Query("status"); status != "" {
query = query.Where("r.status = ?", status) query = query.Where("r.status = ?", status)
@@ -144,7 +206,10 @@ func (h *Handler) List(c *gin.Context) {
return return
} }
items := make([]listItem, 0) 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", "查询执行记录失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
return return
} }
@@ -220,6 +285,9 @@ func (h *Handler) Answer(c *gin.Context) {
for key, value := range input.Answers { for key, value := range input.Answers {
answers[key] = value answers[key] = value
} }
if err := validateKnowledgeSelections(currentNode, updated, answers); err != nil {
return err
}
var edges []model.SOPEdge var edges []model.SOPEdge
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil { if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
return err 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 { func defaultCondition(raw []byte) bool {
if len(raw) == 0 { if len(raw) == 0 {
return true return true
@@ -302,10 +379,11 @@ func (h *Handler) Finish(c *gin.Context) {
return return
} }
var input struct { 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 { if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果") response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "执行结果格式不正确")
return return
} }
var run model.SOPRun var run model.SOPRun
@@ -317,20 +395,51 @@ func (h *Handler) Finish(c *gin.Context) {
if !canOperateRun(p, run) { if !canOperateRun(p, run) {
return gorm.ErrRecordNotFound return gorm.ErrRecordNotFound
} }
if run.Status != "running" { if run.Status != "running" && run.Status != "completed" {
return runCompletedErr return runCompletedErr
} }
now := time.Now() finalResult, err := parseFinalResult(input.FinalResult)
payload, _ := json.Marshal(input) if err != nil {
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err 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 return err
} }
run.Status = "completed" run.Status = "completed"
run.Result = input.Result run.Result = input.Result
run.CompletedAt = &now run.FinalResult = datatypes.JSON(finalRaw)
run.CompletedAt = completedAt
return nil return nil
}) })
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -342,7 +451,7 @@ func (h *Handler) Finish(c *gin.Context) {
return return
} }
if err != nil { if err != nil {
response.Error(c, http.StatusInternalServerError, "FINISH_FAILED", "结束执行失败") response.Error(c, http.StatusUnprocessableEntity, "FINISH_FAILED", err.Error())
return return
} }
_ = audit.Record(h.db, p, "finish", "sop_run", id, input) _ = 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", "当前流程节点不存在") response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return 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 { if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的知识卡版本不存在") response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的历史知识内容不存在")
return 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) 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 { 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", "查询场景字段失败") response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
return 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) { func runID(c *gin.Context) (uint64, bool) {

View File

@@ -2,11 +2,18 @@ package run
import ( import (
"encoding/json" "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" "git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm" "gorm.io/gorm"
) )
var knowledgePlaceholderPattern = regexp.MustCompile(`\{\{\s*(?:input|derived|form)\.([A-Za-z][A-Za-z0-9_]*)\s*\}\}`)
type KnowledgeView struct { type KnowledgeView struct {
CardID uint64 `json:"card_id"` CardID uint64 `json:"card_id"`
CardVersionID uint64 `json:"card_version_id"` CardVersionID uint64 `json:"card_version_id"`
@@ -15,24 +22,153 @@ type KnowledgeView struct {
StandardCopy string `json:"standard_copy"` StandardCopy string `json:"standard_copy"`
ForbiddenCopy string `json:"forbidden_copy"` ForbiddenCopy string `json:"forbidden_copy"`
RiskNote string `json:"risk_note"` RiskNote string `json:"risk_note"`
Copies []KnowledgeCopy `json:"copies"`
}
type KnowledgeCopy struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
} }
type NodeView struct { type NodeView struct {
model.SOPNode 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"` 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 { type knowledgeNodeConfig struct {
KnowledgeCardID uint64 `json:"knowledge_card_id"` KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_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) { type knowledgeCollectionConfig struct {
view := NodeView{SOPNode: node} 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" { if node.Type != "knowledge" {
return view, nil 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 { if err != nil {
return view, err return view, err
} }
@@ -40,7 +176,366 @@ func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (No
return view, nil 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 var config knowledgeNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil { if err := json.Unmarshal(node.Config, &config); err != nil {
return KnowledgeView{}, err 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 { if err := query.First(&version).Error; err != nil {
return KnowledgeView{}, err return KnowledgeView{}, err
} }
var content struct { content, err := knowledgecontent.ParseContent(version.Content)
StandardCopy string `json:"standard_copy"` if err != nil {
ForbiddenCopy string `json:"forbidden_copy"`
RiskNote string `json:"risk_note"`
}
if err := json.Unmarshal(version.Content, &content); err != nil {
return KnowledgeView{}, err 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{ return KnowledgeView{
CardID: version.KnowledgeCardID, CardVersionID: version.ID, Version: version.Version, Title: version.Title, 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 }, nil
} }

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

@@ -12,10 +12,37 @@ import (
type answerNodeConfig struct { type answerNodeConfig struct {
FieldKey string `json:"field_key"` FieldKey string `json:"field_key"`
FieldKeys []string `json:"field_keys"` FieldKeys []string `json:"field_keys"`
RequiredFieldKeys []string `json:"required_field_keys"`
Required bool `json:"required"` Required bool `json:"required"`
Options []string `json:"options"` 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 { func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
var config answerNodeConfig var config answerNodeConfig
if len(node.Config) > 0 { if len(node.Config) > 0 {
@@ -39,9 +66,12 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
return fmt.Errorf("当前表单没有配置采集字段") return fmt.Errorf("当前表单没有配置采集字段")
} }
for _, key := range config.FieldKeys { for _, key := range config.FieldKeys {
expected[key] = false expected[key] = containsString(config.RequiredFieldKeys, key)
} }
default: default:
if node.Type == "knowledge" {
return validateKnowledgeNodeAnswers(node, fieldMap, answers)
}
if len(answers) > 0 { if len(answers) > 0 {
return fmt.Errorf("当前节点不接受字段回答") return fmt.Errorf("当前节点不接受字段回答")
} }
@@ -76,6 +106,87 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
return nil 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 { func stringAllowed(options []string, selected string) bool {
for _, option := range options { for _, option := range options {
if option == selected { if option == selected {
@@ -140,6 +251,15 @@ func validateFieldValue(field model.ScenarioField, value interface{}) error {
return fmt.Errorf("%s包含不正确的选项", field.FieldName) 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": case "date":
text, ok := value.(string) text, ok := value.(string)
if !ok || !validDate(text) { if !ok || !validDate(text) {

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response" "git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -33,12 +34,15 @@ type scenarioInput struct {
Goal string `json:"goal" binding:"required,max=2000"` Goal string `json:"goal" binding:"required,max=2000"`
TriggerText string `json:"trigger_text" binding:"required,max=2000"` TriggerText string `json:"trigger_text" binding:"required,max=2000"`
Visibility string `json:"visibility" binding:"omitempty,oneof=private team tenant"` 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 { type fieldInput struct {
FieldKey string `json:"field_key" binding:"required,max=64"` FieldKey string `json:"field_key" binding:"required,max=64"`
FieldName string `json:"field_name" binding:"required,max=128"` 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"` Required bool `json:"required"`
Options json.RawMessage `json:"options"` Options json.RawMessage `json:"options"`
Validation json.RawMessage `json:"validation"` Validation json.RawMessage `json:"validation"`
@@ -46,6 +50,24 @@ type fieldInput struct {
} }
var fieldKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) 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) { func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c) p, _ := auth.PrincipalFromContext(c)
@@ -80,7 +102,7 @@ func (h *Handler) Create(c *gin.Context) {
if visibility == "" { if visibility == "" {
visibility = "tenant" 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 { if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建场景失败") response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建场景失败")
return return
@@ -107,6 +129,7 @@ func (h *Handler) Get(c *gin.Context) {
fields := make([]model.ScenarioField, 0) fields := make([]model.ScenarioField, 0)
sops := make([]model.SOP, 0) sops := make([]model.SOP, 0)
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("sort_order, id").Find(&fields) 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") { if auth.HasPermission(p, "sop.view") {
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops) 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" 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} 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) 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 { if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在") 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", "场景不存在或不可归档") response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可归档")
return 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 { 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 { if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error; err != nil {
return err return err
@@ -193,11 +213,15 @@ func (h *Handler) CreateField(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error()) response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return 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 { if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusConflict, "CREATE_FAILED", "字段标识已存在或配置不正确") response.Error(c, http.StatusConflict, "CREATE_FAILED", "字段标识已存在或配置不正确")
return 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) _ = audit.Record(h.db, p, "create", "scenario_field", item.ID, input)
response.Created(c, item) response.Created(c, item)
} }
@@ -214,7 +238,7 @@ func (h *Handler) UpdateField(c *gin.Context) {
return return
} }
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) { 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 return
} }
var input fieldInput var input fieldInput
@@ -226,12 +250,16 @@ func (h *Handler) UpdateField(c *gin.Context) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error()) response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
return 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) result := h.db.Model(&model.ScenarioField{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(updates)
if result.Error != nil || result.RowsAffected == 0 { if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在") response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
return 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) _ = audit.Record(h.db, p, "update", "scenario_field", id, input)
var item model.ScenarioField var item model.ScenarioField
h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item) h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item)
@@ -250,7 +278,7 @@ func (h *Handler) DeleteField(c *gin.Context) {
return return
} }
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) { 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 return
} }
result := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioField{}) 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", "字段不存在") response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
return 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}) response.OK(c, gin.H{"id": id})
} }
@@ -273,6 +315,9 @@ func validateFieldInput(input fieldInput) error {
if !fieldKeyPattern.MatchString(input.FieldKey) { if !fieldKeyPattern.MatchString(input.FieldKey) {
return errors.New("字段标识必须以字母开头,且只能包含字母、数字和下划线") return errors.New("字段标识必须以字母开头,且只能包含字母、数字和下划线")
} }
if input.SourcePath != "" && !sourcePathPattern.MatchString(input.SourcePath) {
return errors.New("数据路径格式不正确,例如 customer.name 或 order.items[*].product_id")
}
if len(input.Options) > 0 { if len(input.Options) > 0 {
var options []string var options []string
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil { if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {

View File

@@ -5,7 +5,6 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access" "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/audit"
@@ -15,7 +14,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type Handler struct { type Handler struct {
@@ -54,6 +52,10 @@ type edgeInput struct {
Priority int `json:"priority"` Priority int `json:"priority"`
} }
type editState struct {
StartNodeKey string `json:"start_node_key"`
}
func (h *Handler) List(c *gin.Context) { func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c) p, _ := auth.PrincipalFromContext(c)
items := make([]model.SOP, 0) items := make([]model.SOP, 0)
@@ -96,11 +98,11 @@ func (h *Handler) Create(c *gin.Context) {
var item model.SOP var item model.SOP
var version model.SOPVersion var version model.SOPVersion
err := h.db.Transaction(func(tx *gorm.DB) error { err := h.db.Transaction(func(tx *gorm.DB) error {
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "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 { if err := tx.Create(&item).Error; err != nil {
return err 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 { if err := tx.Create(&version).Error; err != nil {
return err 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: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0}, {TenantID: p.TenantID, 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 { if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败") response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败")
return return
} }
_ = audit.Record(h.db, p, "create", "sop", item.ID, input) _ = audit.Record(h.db, p, "create", "sop", item.ID, input)
response.Created(c, gin.H{"sop": item, "version": version}) response.Created(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}})
} }
func (h *Handler) Get(c *gin.Context) { func (h *Handler) Get(c *gin.Context) {
@@ -140,17 +145,7 @@ func (h *Handler) Get(c *gin.Context) {
var version model.SOPVersion var version model.SOPVersion
var nodes []model.SOPNode var nodes []model.SOPNode
var edges []model.SOPEdge var edges []model.SOPEdge
var err error item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
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)
}
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在") response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
@@ -159,36 +154,7 @@ func (h *Handler) Get(c *gin.Context) {
} }
return return
} }
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges}) response.OK(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}, "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)})
} }
func (h *Handler) SaveGraph(c *gin.Context) { 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", "流程配置不完整") response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
return return
} }
var version model.SOPVersion item, version, _, _, err := h.loadLatest(id, p.TenantID)
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Order("version DESC").First(&version).Error; err != nil { if err != nil {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可编辑的草稿版本") response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return return
} }
nodes, edges := toModels(p.TenantID, version.ID, input) 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 { if len(problems) > 0 {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0]) response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
return return
} }
err := h.db.Transaction(func(tx *gorm.DB) error { err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil { if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
return err return err
} }
@@ -232,13 +202,22 @@ func (h *Handler) SaveGraph(c *gin.Context) {
return err 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 { if err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败") response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败")
return 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) h.Get(c)
} }
@@ -256,17 +235,7 @@ func (h *Handler) Validate(c *gin.Context) {
var version model.SOPVersion var version model.SOPVersion
var nodes []model.SOPNode var nodes []model.SOPNode
var edges []model.SOPEdge var edges []model.SOPEdge
var err error item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
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)
}
if err != nil { if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在") response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return return
@@ -279,273 +248,6 @@ func (h *Handler) Validate(c *gin.Context) {
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems}) response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
} }
func (h *Handler) 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) { func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
var item model.SOP var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil { if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
@@ -566,60 +268,20 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
return item, version, nodes, edges, nil 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) { func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
fields := make([]model.ScenarioField, 0) fields := make([]model.ScenarioField, 0)
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil { if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
return nil, err return nil, err
} }
var cards []model.KnowledgeCard var knowledgeItems []model.KnowledgeItem
if err := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, tenantID, "published").Find(&cards).Error; err != nil { if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeItems).Error; err != nil {
return nil, err return nil, err
} }
cardIDs := make(map[uint64]bool, len(cards)) var knowledgeRelations []model.KnowledgeRelation
for _, card := range cards { if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeRelations).Error; err != nil {
cardIDs[card.ID] = true 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) { func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,8 @@ var allowedConditionOperators = map[string]bool{"equals": true, "not_equals": tr
type ValidationContext struct { type ValidationContext struct {
Fields []model.ScenarioField Fields []model.ScenarioField
PublishedKnowledgeCardIDs map[uint64]bool KnowledgeItems []model.KnowledgeItem
KnowledgeRelations []model.KnowledgeRelation
} }
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string { 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 { for _, field := range context.Fields {
fieldMap[field.FieldKey] = field 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{} collected := map[string]bool{}
nodeMap := make(map[string]model.SOPNode, len(nodes)) nodeMap := make(map[string]model.SOPNode, len(nodes))
adjacency := make(map[string][]string) adjacency := make(map[string][]string)
@@ -174,14 +192,38 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
collected[fieldKey] = true collected[fieldKey] = true
} }
case "knowledge": case "knowledge":
cardID := uint64FromJSON(config["knowledge_card_id"]) selector, ok := config["knowledge_selector"].(map[string]interface{})
if cardID == 0 || !context.PublishedKnowledgeCardIDs[cardID] { if !ok {
problems = append(problems, fmt.Sprintf("节点“%s”没有关联已发布的知识卡", node.Title)) 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 { 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)) problems = append(problems, fmt.Sprintf("必填字段“%s”没有对应的采集节点", field.FieldName))
} }
} }
@@ -312,20 +354,61 @@ func isDefaultCondition(value []byte) bool {
return ok && len(object) == 0 return ok && len(object) == 0
} }
func uint64FromJSON(value interface{}) uint64 { func validateKnowledgeSelector(node model.SOPNode, selector map[string]interface{}, items map[string]model.KnowledgeItem, availableTypes, availableRelations map[string]bool, problems *[]string) {
switch typed := value.(type) { derivedField, _ := selector["derived_field"].(string)
case float64: answerField, _ := selector["answer_field"].(string)
if typed > 0 { keys, keysValid := selectorStrings(selector, "knowledge_keys")
return uint64(typed) 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
} }
case uint64: if derivedField == "" && answerField == "" && len(keys) == 0 {
return typed *problems = append(*problems, fmt.Sprintf("节点“%s”没有配置派生知识字段或固定知识 key", node.Title))
case int: }
if typed > 0 { allowedTypes := make(map[string]bool, len(types))
return uint64(typed) 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 { func canReachType(start, nodeType string, nodes map[string]model.SOPNode, adjacency map[string][]string) bool {

View File

@@ -15,6 +15,14 @@ func TestValidateForPublishValidHighRiskFlow(t *testing.T) {
} }
} }
func TestValidateForPublishAllowsRequiredExternalInput(t *testing.T) {
nodes, edges, context := validPublishGraph()
context.Fields = append(context.Fields, model.ScenarioField{FieldKey: "order_id", FieldName: "订单号", SourcePath: "order.id", Required: true})
if problems := ValidateForPublish("start", nodes, edges, context); len(problems) != 0 {
t.Fatalf("required external input should not need a collection node: %v", problems)
}
}
func TestValidateForPublishBusinessRules(t *testing.T) { func TestValidateForPublishBusinessRules(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -30,9 +38,15 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
{name: "invalid operator", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) { {name: "invalid operator", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"matches","value":true}`) (*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"matches","value":true}`)
}, want: "不支持的运算符 matches"}, }, want: "不支持的运算符 matches"},
{name: "unpublished knowledge", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) { {name: "missing knowledge key", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
context.PublishedKnowledgeCardIDs = map[uint64]bool{} context.KnowledgeItems = nil
}, want: "没有关联已发布的知识卡"}, }, 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) { {name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
(*edges)[1].Condition = jsonData(`{}`) (*edges)[1].Condition = jsonData(`{}`)
}, want: "配置了多条默认路径"}, }, want: "配置了多条默认路径"},
@@ -109,7 +123,7 @@ func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
nodes := []model.SOPNode{ nodes := []model.SOPNode{
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)}, {NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
{NodeKey: "screen", Type: "form", Title: "急症筛查", Config: jsonData(`{"field_keys":["emergency"],"risk_level":"high"}`)}, {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: "escalate", Type: "escalate", Title: "转诊", Config: jsonData(`{}`)},
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)}, {NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
} }
@@ -121,7 +135,11 @@ func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
} }
context := ValidationContext{ context := ValidationContext{
Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}}, Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}},
PublishedKnowledgeCardIDs: map[uint64]bool{1: 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 return nodes, edges, context
} }

28
main.go
View File

@@ -17,6 +17,7 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/httpserver" "git.iwork-ai.com/xdc/iqudo-top1/internal/httpserver"
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger" "git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
"git.iwork-ai.com/xdc/iqudo-top1/internal/migration" "git.iwork-ai.com/xdc/iqudo-top1/internal/migration"
"git.iwork-ai.com/xdc/iqudo-top1/internal/multitable"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -26,6 +27,9 @@ var migrationFiles embed.FS
//go:embed web/dist/* //go:embed web/dist/*
var frontendFiles embed.FS var frontendFiles embed.FS
//go:embed sdk/dist/*
var sdkFiles embed.FS
func main() { func main() {
environment := flag.String("env", "", "runtime environment: development, test, prod") environment := flag.String("env", "", "runtime environment: development, test, prod")
configDir := flag.String("config-dir", "configs", "configuration directory") configDir := flag.String("config-dir", "configs", "configuration directory")
@@ -55,13 +59,29 @@ func main() {
if err := auth.Seed(db, cfg.Seed); err != nil { if err := auth.Seed(db, cfg.Seed); err != nil {
log.Fatal("seed administrator", zap.Error(err)) log.Fatal("seed administrator", zap.Error(err))
} }
workerDone := make(chan struct{})
workerCtx, stopWorker := context.WithCancel(context.Background())
if cfg.MultiTable.Enabled {
worker := multitable.NewWorker(db, cfg.MultiTable, log)
go func() {
defer close(workerDone)
log.Info("multitable synchronization started", zap.String("worker", worker.String()))
worker.Run(workerCtx)
}()
} else {
close(workerDone)
}
frontend, err := fs.Sub(frontendFiles, "web/dist") frontend, err := fs.Sub(frontendFiles, "web/dist")
if err != nil { if err != nil {
log.Fatal("load embedded frontend", zap.Error(err)) log.Fatal("load embedded frontend", zap.Error(err))
} }
sdk, err := fs.Sub(sdkFiles, "sdk/dist")
if err != nil {
log.Fatal("load embedded sdk", zap.Error(err))
}
authService := auth.NewService(db, cfg.Auth) authService := auth.NewService(db, cfg.Auth)
handler := httpserver.New(db, authService, frontend, log, cfg.App.Env) handler := httpserver.New(db, authService, frontend, sdk, log, cfg.App.Env)
server := &http.Server{Addr: cfg.Server.Address(), Handler: handler, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout} server := &http.Server{Addr: cfg.Server.Address(), Handler: handler, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout}
go func() { go func() {
@@ -74,10 +94,16 @@ func main() {
stop := make(chan os.Signal, 1) stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop <-stop
stopWorker()
ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout) ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout)
defer cancel() defer cancel()
if err := server.Shutdown(ctx); err != nil { if err := server.Shutdown(ctx); err != nil {
log.Error("graceful shutdown failed", zap.Error(err)) log.Error("graceful shutdown failed", zap.Error(err))
} }
select {
case <-workerDone:
case <-ctx.Done():
log.Warn("multitable worker did not stop before shutdown timeout")
}
log.Info("server stopped") log.Info("server stopped")
} }

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS multitable_outbox;

View File

@@ -0,0 +1,23 @@
CREATE TABLE multitable_outbox (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
audit_log_id BIGINT UNSIGNED NULL,
resource VARCHAR(64) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
dedupe_key VARCHAR(191) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
available_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
last_error TEXT NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_multitable_outbox_audit (audit_log_id),
UNIQUE KEY uk_multitable_outbox_dedupe (dedupe_key),
KEY idx_multitable_outbox_pending (status, available_at),
KEY idx_multitable_outbox_resource (tenant_id, resource, resource_id),
CONSTRAINT fk_multitable_outbox_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_multitable_outbox_audit FOREIGN KEY (audit_log_id) REFERENCES audit_logs(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1 @@
-- The review workflow is intentionally not restored by rollback.

View File

@@ -0,0 +1,15 @@
UPDATE sop_versions
SET status = 'draft', reviewed_by = NULL
WHERE status = 'reviewing';
UPDATE sops
SET status = CASE
WHEN EXISTS (
SELECT 1
FROM sop_versions
WHERE sop_versions.sop_id = sops.id
AND sop_versions.status = 'published'
) THEN 'published'
ELSE 'draft'
END
WHERE status = 'reviewing';

View File

@@ -0,0 +1,4 @@
ALTER TABLE sop_runs DROP INDEX uk_sop_runs_external_ref;
ALTER TABLE sop_runs DROP COLUMN outputs, DROP COLUMN derived, DROP COLUMN input, DROP COLUMN external_ref;
ALTER TABLE scenario_fields DROP COLUMN source_path;
ALTER TABLE scenarios DROP COLUMN output_schema, DROP COLUMN input_schema;

View File

@@ -0,0 +1,20 @@
ALTER TABLE scenarios
ADD COLUMN input_schema JSON NULL AFTER created_by,
ADD COLUMN output_schema JSON NULL AFTER input_schema;
UPDATE scenarios SET input_schema = JSON_OBJECT('fields', JSON_ARRAY()), output_schema = JSON_OBJECT('fields', JSON_ARRAY());
ALTER TABLE scenarios MODIFY input_schema JSON NOT NULL, MODIFY output_schema JSON NOT NULL;
ALTER TABLE scenario_fields
ADD COLUMN source_path VARCHAR(255) NOT NULL DEFAULT '' AFTER field_name;
ALTER TABLE sop_runs
ADD COLUMN external_ref VARCHAR(191) NULL AFTER operator_id,
ADD COLUMN input JSON NULL AFTER answers,
ADD COLUMN derived JSON NULL AFTER input,
ADD COLUMN outputs JSON NULL AFTER derived;
UPDATE sop_runs SET input = answers, derived = JSON_OBJECT(), outputs = JSON_ARRAY();
ALTER TABLE sop_runs MODIFY input JSON NOT NULL, MODIFY derived JSON NOT NULL, MODIFY outputs JSON NOT NULL;
ALTER TABLE sop_runs ADD UNIQUE KEY uk_sop_runs_external_ref (tenant_id, sop_id, external_ref);

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS knowledge_relations;
DROP TABLE IF EXISTS knowledge_items;

View File

@@ -0,0 +1,38 @@
CREATE TABLE knowledge_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
scenario_id BIGINT UNSIGNED NOT NULL,
item_key VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
type VARCHAR(64) NOT NULL,
content JSON NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_knowledge_items_key (scenario_id, item_key),
KEY idx_knowledge_items_type (tenant_id, scenario_id, type, status),
CONSTRAINT fk_knowledge_items_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_knowledge_items_scenario FOREIGN KEY (scenario_id) REFERENCES scenarios(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE knowledge_relations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
scenario_id BIGINT UNSIGNED NOT NULL,
from_knowledge_id BIGINT UNSIGNED NOT NULL,
relation_type VARCHAR(64) NOT NULL,
to_knowledge_id BIGINT UNSIGNED NOT NULL,
`condition` JSON NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_knowledge_relation (scenario_id, from_knowledge_id, relation_type, to_knowledge_id),
KEY idx_knowledge_relation_from (tenant_id, scenario_id, from_knowledge_id, relation_type),
CONSTRAINT fk_knowledge_relations_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_knowledge_relations_scenario FOREIGN KEY (scenario_id) REFERENCES scenarios(id) ON DELETE CASCADE,
CONSTRAINT fk_knowledge_relations_from FOREIGN KEY (from_knowledge_id) REFERENCES knowledge_items(id) ON DELETE CASCADE,
CONSTRAINT fk_knowledge_relations_to FOREIGN KEY (to_knowledge_id) REFERENCES knowledge_items(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS public_run_sessions;
ALTER TABLE scenarios DROP INDEX uk_scenarios_public_key, DROP INDEX uk_scenarios_scenario_key;
ALTER TABLE scenarios DROP COLUMN allowed_origins, DROP COLUMN public_key, DROP COLUMN scenario_key;

View File

@@ -0,0 +1,31 @@
ALTER TABLE scenarios
ADD COLUMN scenario_key VARCHAR(128) NULL AFTER tenant_id,
ADD COLUMN public_key VARCHAR(128) NULL AFTER scenario_key,
ADD COLUMN allowed_origins JSON NULL AFTER public_key;
UPDATE scenarios
SET scenario_key = CONCAT('scenario-', id),
public_key = CONCAT('pk_', REPLACE(UUID(), '-', '')),
allowed_origins = JSON_ARRAY();
ALTER TABLE scenarios
MODIFY scenario_key VARCHAR(128) NOT NULL,
MODIFY public_key VARCHAR(128) NOT NULL,
MODIFY allowed_origins JSON NOT NULL,
ADD UNIQUE KEY uk_scenarios_scenario_key (scenario_key),
ADD UNIQUE KEY uk_scenarios_public_key (public_key);
CREATE TABLE public_run_sessions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
run_id BIGINT UNSIGNED NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expires_at DATETIME(3) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_public_run_sessions_token (token_hash),
KEY idx_public_run_sessions_run (tenant_id, run_id),
CONSTRAINT fk_public_sessions_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_public_sessions_run FOREIGN KEY (run_id) REFERENCES sop_runs(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS scenario_rules;

View File

@@ -0,0 +1,18 @@
CREATE TABLE scenario_rules (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
scenario_id BIGINT UNSIGNED NOT NULL,
rule_key VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
`condition` JSON NOT NULL,
actions JSON NOT NULL,
priority INT NOT NULL DEFAULT 0,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_scenario_rules_key (scenario_id, rule_key),
KEY idx_scenario_rules_active (tenant_id, scenario_id, status, priority),
CONSTRAINT fk_scenario_rules_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
CONSTRAINT fk_scenario_rules_scenario FOREIGN KEY (scenario_id) REFERENCES scenarios(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1 @@
ALTER TABLE sop_runs DROP COLUMN knowledge_snapshot;

View File

@@ -0,0 +1,3 @@
ALTER TABLE sop_runs ADD COLUMN knowledge_snapshot JSON NULL AFTER outputs;
UPDATE sop_runs SET knowledge_snapshot = JSON_OBJECT('items', JSON_ARRAY(), 'relations', JSON_ARRAY());
ALTER TABLE sop_runs MODIFY knowledge_snapshot JSON NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE sop_runs DROP COLUMN final_result;
ALTER TABLE scenarios DROP COLUMN result_schema;

View File

@@ -0,0 +1,8 @@
ALTER TABLE scenarios
ADD COLUMN result_schema JSON NULL AFTER output_schema;
UPDATE scenarios SET result_schema = JSON_OBJECT('fields', JSON_ARRAY());
ALTER TABLE scenarios MODIFY result_schema JSON NOT NULL;
ALTER TABLE sop_runs
ADD COLUMN final_result JSON NULL AFTER result;

View File

@@ -0,0 +1,39 @@
package main
import (
"flag"
"fmt"
"os"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"git.iwork-ai.com/xdc/iqudo-top1/internal/database"
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
"git.iwork-ai.com/xdc/iqudo-top1/internal/multitable"
)
func main() {
configDir := flag.String("config-dir", "configs", "configuration directory")
flag.Parse()
cfg, err := config.Load(config.LoadOptions{ConfigDir: *configDir})
if err != nil {
fmt.Fprintf(os.Stderr, "load configuration: %v\n", err)
os.Exit(1)
}
log, err := logger.New(cfg.App.Env, cfg.App.Name)
if err != nil {
fmt.Fprintf(os.Stderr, "create logger: %v\n", err)
os.Exit(1)
}
defer log.Sync()
db, err := database.Open(cfg.Database, log)
if err != nil {
fmt.Fprintf(os.Stderr, "connect database: %v\n", err)
os.Exit(1)
}
count, err := multitable.EnqueueBackfill(db)
if err != nil {
fmt.Fprintf(os.Stderr, "enqueue backfill: %v\n", err)
os.Exit(1)
}
fmt.Printf("queued %d multitable projection events\n", count)
}

View File

@@ -0,0 +1,320 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"github.com/xuri/excelize/v2"
"gorm.io/datatypes"
"gorm.io/gorm"
)
const petKnowledgeSource = "症状-疾病-方案.xlsx"
const petScriptSource = "症状-疾病-话术.json"
func resetPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64) error {
var ids []uint64
if err := tx.Model(&model.KnowledgeItem{}).Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Pluck("id", &ids).Error; err != nil {
return fmt.Errorf("list old pet knowledge: %w", err)
}
if len(ids) > 0 {
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND (from_knowledge_id IN ? OR to_knowledge_id IN ?)", tenantID, scenarioID, ids, ids).Delete(&model.KnowledgeRelation{}).Error; err != nil {
return fmt.Errorf("delete old pet knowledge relations: %w", err)
}
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND id IN ?", tenantID, scenarioID, ids).Delete(&model.KnowledgeItem{}).Error; err != nil {
return fmt.Errorf("delete old pet knowledge items: %w", err)
}
}
return nil
}
type petScriptDisease struct {
Plans []struct {
Plan string `json:"plan"`
Products []struct {
SKUCode string `json:"sku_code"`
ProductName string `json:"product_name"`
} `json:"products"`
} `json:"plans"`
Combinations []struct {
Name string `json:"name"`
Weight int `json:"weight"`
Products []struct {
SKUCode string `json:"sku_code"`
ProductName string `json:"product_name"`
} `json:"products"`
Script string `json:"script"`
} `json:"combinations"`
}
func importPetScripts(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error {
raw, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("read pet scripts %s: %w", filename, err)
}
var source map[string]map[string]petScriptDisease
if err := json.Unmarshal(raw, &source); err != nil {
return fmt.Errorf("parse pet scripts %s: %w", filename, err)
}
for symptom, diseases := range source {
for disease, definition := range diseases {
diseaseKey := petKnowledgeKey("disease", disease)
if err := upsertPetScriptItems(tx, tenantID, scenarioID, symptom, disease, diseaseKey, definition); err != nil {
return err
}
}
}
return nil
}
func upsertPetScriptItems(tx *gorm.DB, tenantID, scenarioID uint64, symptom, disease, diseaseKey string, definition petScriptDisease) error {
symptomKey := petKnowledgeKey("symptom", symptom)
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, symptomKey, symptom, "symptom", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
return err
}
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, diseaseKey, disease, "disease", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
return err
}
if err := upsertPetRelation(tx, scenarioID, symptomKey, "possible_disease", diseaseKey, 3000); err != nil {
return err
}
for index, combination := range definition.Combinations {
copyName := fmt.Sprintf("%s-%s话术", disease, combination.Name)
copyKey := petKnowledgeKey("copy", "json_"+symptom+"_"+disease+"_"+combination.Name)
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "template": strings.ReplaceAll(combination.Script, "{{purchased_products}}", "{{input.product_names}}"), "combination": combination.Name, "weight": combination.Weight})
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, copyKey, copyName, "copy", content); err != nil {
return err
}
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_copy", copyKey, 3000+index); err != nil {
return err
}
for productIndex, product := range combination.Products {
if strings.TrimSpace(product.SKUCode) == "" {
continue
}
productKey := petKnowledgeKey("product", product.SKUCode)
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName})
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil {
return err
}
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3400+index*100+productIndex); err != nil {
return err
}
}
}
for index, plan := range definition.Plans {
if strings.TrimSpace(plan.Plan) == "" {
continue
}
planKey := petKnowledgeKey("plan", plan.Plan)
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, planKey, plan.Plan, "plan", []byte(`{"source":"`+petScriptSource+`"}`)); err != nil {
return err
}
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_plan", planKey, 3200+index); err != nil {
return err
}
for productIndex, product := range plan.Products {
if strings.TrimSpace(product.SKUCode) == "" {
continue
}
productKey := petKnowledgeKey("product", product.SKUCode)
content, _ := json.Marshal(map[string]interface{}{"source": petScriptSource, "sku_code": product.SKUCode, "product_name": product.ProductName})
if err := upsertPetKnowledgeItem(tx, tenantID, scenarioID, productKey, product.ProductName, "product", content); err != nil {
return err
}
if err := upsertPetRelation(tx, scenarioID, diseaseKey, "recommended_product", productKey, 3600+index*100+productIndex); err != nil {
return err
}
}
}
return nil
}
func upsertPetKnowledgeItem(tx *gorm.DB, tenantID, scenarioID uint64, key, name, typeName string, content []byte) error {
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: name, Type: typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 3000}
return tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error
}
func upsertPetRelation(tx *gorm.DB, scenarioID uint64, fromKey, relation, toKey string, order int) error {
var from, to model.KnowledgeItem
if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, fromKey).First(&from).Error; err != nil {
return fmt.Errorf("find script relation source %s: %w", fromKey, err)
}
if err := tx.Where("scenario_id = ? AND item_key = ?", scenarioID, toKey).First(&to).Error; err != nil {
return fmt.Errorf("find script relation target %s: %w", toKey, err)
}
row := model.KnowledgeRelation{TenantID: from.TenantID, ScenarioID: scenarioID, FromKnowledgeID: from.ID, RelationType: relation, ToKnowledgeID: to.ID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order}
return tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, from.ID, relation, to.ID).Assign(row).FirstOrCreate(&row).Error
}
type petKnowledgeRow struct {
Symptom string
Disease string
Plan string
SKUCode string
ProductName string
}
func importPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, filename string) error {
rows, err := readPetKnowledge(filename)
if err != nil {
return err
}
return upsertPetKnowledge(tx, tenantID, scenarioID, rows)
}
func readPetKnowledge(filename string) ([]petKnowledgeRow, error) {
book, err := excelize.OpenFile(filename)
if err != nil {
return nil, fmt.Errorf("open pet knowledge %s: %w", filename, err)
}
defer book.Close()
sheets := book.GetSheetList()
if len(sheets) == 0 {
return nil, fmt.Errorf("pet knowledge %s has no worksheet", filename)
}
iterator, err := book.Rows(sheets[0])
if err != nil {
return nil, fmt.Errorf("read pet knowledge worksheet: %w", err)
}
defer iterator.Close()
columns := map[string]int{}
result := make([]petKnowledgeRow, 0)
rowNumber := 0
for iterator.Next() {
rowNumber++
values, err := iterator.Columns()
if err != nil {
return nil, fmt.Errorf("read pet knowledge row %d: %w", rowNumber, err)
}
if rowNumber == 1 {
for index, value := range values {
columns[strings.TrimSpace(value)] = index
}
for _, required := range []string{"症状", "疾病", "方案", "SKU_CODE", "商品标题"} {
if _, ok := columns[required]; !ok {
return nil, fmt.Errorf("pet knowledge is missing column %q", required)
}
}
continue
}
row := petKnowledgeRow{
Symptom: excelValue(values, columns["症状"]),
Disease: excelValue(values, columns["疾病"]),
Plan: excelValue(values, columns["方案"]),
SKUCode: excelValue(values, columns["SKU_CODE"]),
ProductName: excelValue(values, columns["商品标题"]),
}
if row.Symptom == "" && row.Disease == "" && row.Plan == "" {
continue
}
if row.Symptom == "" || row.Disease == "" {
return nil, fmt.Errorf("pet knowledge row %d must contain 场景 and 分型", rowNumber)
}
result = append(result, row)
}
if err := iterator.Error(); err != nil {
return nil, fmt.Errorf("iterate pet knowledge: %w", err)
}
if len(result) == 0 {
return nil, fmt.Errorf("pet knowledge contains no data")
}
return result, nil
}
func excelValue(values []string, index int) string {
if index >= len(values) {
return ""
}
return strings.TrimSpace(values[index])
}
func upsertPetKnowledge(tx *gorm.DB, tenantID, scenarioID uint64, rows []petKnowledgeRow) error {
type itemDefinition struct {
name, typeName string
content map[string]interface{}
}
items := map[string]itemDefinition{}
type relationDefinition struct{ from, relation, to string }
relations := map[relationDefinition]bool{}
for _, row := range rows {
symptomKey := petKnowledgeKey("symptom", row.Symptom)
diseaseKey := petKnowledgeKey("disease", row.Disease)
items[symptomKey] = itemDefinition{name: row.Symptom, typeName: "symptom"}
items[diseaseKey] = itemDefinition{name: row.Disease, typeName: "disease"}
relations[relationDefinition{symptomKey, "possible_disease", diseaseKey}] = true
for index, template := range []string{
"结合客户描述,目前更符合“%s”这一分型。建议先向客户说明判断依据再介绍对应方案。",
"针对“%s”这边建议按下面的方案进行护理和商品搭配如症状持续或加重应及时就医。",
} {
copyName := fmt.Sprintf("%s推荐话术%d", row.Disease, index+1)
copyKey := petKnowledgeKey("copy", copyName)
items[copyKey] = itemDefinition{name: copyName, typeName: "copy", content: map[string]interface{}{"template": fmt.Sprintf(template, row.Disease)}}
relations[relationDefinition{diseaseKey, "recommended_copy", copyKey}] = true
}
if row.Plan != "" {
planKey := petKnowledgeKey("plan", row.Plan)
items[planKey] = itemDefinition{name: row.Plan, typeName: "plan"}
relations[relationDefinition{diseaseKey, "recommended_plan", planKey}] = true
// The current SOP view expands one relation level from a symptom.
relations[relationDefinition{symptomKey, "recommended_plan", planKey}] = true
}
if row.SKUCode != "" {
productName := row.ProductName
if productName == "" {
productName = row.SKUCode
}
productKey := petKnowledgeKey("product", row.SKUCode)
items[productKey] = itemDefinition{name: productName, typeName: "product", content: map[string]interface{}{"sku_code": row.SKUCode, "product_name": productName}}
relations[relationDefinition{diseaseKey, "recommended_product", productKey}] = true
}
}
keys := make([]string, 0, len(items))
for key := range items {
keys = append(keys, key)
}
sort.Strings(keys)
ids := make(map[string]uint64, len(keys))
for order, key := range keys {
definition := items[key]
payload := map[string]interface{}{"source": petKnowledgeSource}
for name, value := range definition.content {
payload[name] = value
}
content, _ := json.Marshal(payload)
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: 1000 + order}
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil {
return fmt.Errorf("upsert imported knowledge item %s: %w", key, err)
}
ids[key] = row.ID
}
relationList := make([]relationDefinition, 0, len(relations))
for relation := range relations {
relationList = append(relationList, relation)
}
sort.Slice(relationList, func(i, j int) bool {
left, right := relationList[i], relationList[j]
return left.from+left.relation+left.to < right.from+right.relation+right.to
})
for order, definition := range relationList {
row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: ids[definition.from], RelationType: definition.relation, ToKnowledgeID: ids[definition.to], Condition: datatypes.JSON([]byte(`{}`)), SortOrder: 1000 + order}
if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, row.FromKnowledgeID, row.RelationType, row.ToKnowledgeID).Assign(row).FirstOrCreate(&row).Error; err != nil {
return fmt.Errorf("upsert imported knowledge relation: %w", err)
}
}
return nil
}
func petKnowledgeKey(typeName, name string) string {
digest := sha256.Sum256([]byte(typeName + "\x00" + strings.TrimSpace(name)))
return "xlsx_" + typeName + "_" + hex.EncodeToString(digest[:6])
}

View File

@@ -5,14 +5,12 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"reflect" "path/filepath"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config" "git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"git.iwork-ai.com/xdc/iqudo-top1/internal/database" "git.iwork-ai.com/xdc/iqudo-top1/internal/database"
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger" "git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model" "git.iwork-ai.com/xdc/iqudo-top1/internal/model"
sopservice "git.iwork-ai.com/xdc/iqudo-top1/internal/sop"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
@@ -21,6 +19,7 @@ import (
type fieldDefinition struct { type fieldDefinition struct {
Key string Key string
Name string Name string
SourcePath string
Type string Type string
Required bool Required bool
Options []string Options []string
@@ -45,6 +44,7 @@ type edgeDefinition struct {
func main() { func main() {
configDir := flag.String("config-dir", "configs", "configuration directory") configDir := flag.String("config-dir", "configs", "configuration directory")
environment := flag.String("env", "", "runtime environment") environment := flag.String("env", "", "runtime environment")
scriptFile := flag.String("script-file", filepath.Join("..", "docs", "症状-疾病-话术.json"), "pet script knowledge JSON file")
flag.Parse() flag.Parse()
cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir}) cfg, err := config.Load(config.LoadOptions{Environment: *environment, ConfigDir: *configDir})
@@ -63,13 +63,13 @@ func main() {
if err != nil { if err != nil {
log.Fatal("connect database", zap.Error(err)) log.Fatal("connect database", zap.Error(err))
} }
if err := seed(db); err != nil { if err := seed(db, *scriptFile); err != nil {
log.Fatal("seed pet doctor scenario", zap.Error(err)) log.Fatal("seed pet doctor scenario", zap.Error(err))
} }
log.Info("pet doctor scenario is ready") log.Info("pet doctor scenario is ready")
} }
func seed(db *gorm.DB) error { func seed(db *gorm.DB, scriptFile string) error {
return db.Transaction(func(tx *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error {
tenant, user, err := seedOwner(tx) tenant, user, err := seedOwner(tx)
if err != nil { if err != nil {
@@ -82,11 +82,19 @@ func seed(db *gorm.DB) error {
if err := seedFields(tx, tenant.ID, scenario.ID); err != nil { if err := seedFields(tx, tenant.ID, scenario.ID); err != nil {
return err return err
} }
knowledgeIDs, err := seedKnowledgeCards(tx, tenant.ID, user.ID, scenario.ID) if err := seedInputSchema(tx, tenant.ID, scenario.ID); err != nil {
if err != nil {
return err return err
} }
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID, knowledgeIDs); err != nil { if err := seedRulesAndOutput(tx, tenant.ID, scenario.ID); err != nil {
return err
}
if err := resetPetKnowledge(tx, tenant.ID, scenario.ID); err != nil {
return err
}
if err := importPetScripts(tx, tenant.ID, scenario.ID, scriptFile); err != nil {
return err
}
if err := seedSOP(tx, tenant.ID, user.ID, scenario.ID); err != nil {
return err return err
} }
return nil return nil
@@ -127,7 +135,9 @@ func seedScenario(tx *gorm.DB, tenantID, userID uint64) (model.Scenario, error)
func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error { func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
definitions := petFieldDefinitions() definitions := petFieldDefinitions()
keys := make([]string, 0, len(definitions))
for index, definition := range definitions { for index, definition := range definitions {
keys = append(keys, definition.Key)
options, _ := json.Marshal(definition.Options) options, _ := json.Marshal(definition.Options)
if definition.Options == nil { if definition.Options == nil {
options = []byte(`[]`) options = []byte(`[]`)
@@ -137,150 +147,142 @@ func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
validation = []byte(`{}`) validation = []byte(`{}`)
} }
var field model.ScenarioField var field model.ScenarioField
err := tx.Where("scenario_id = ? AND field_key = ?", scenarioID, definition.Key).Assign(model.ScenarioField{ attributes := map[string]interface{}{
TenantID: tenantID, ScenarioID: scenarioID, FieldName: definition.Name, FieldType: definition.Type, "tenant_id": tenantID, "scenario_id": scenarioID, "field_name": definition.Name, "source_path": definition.SourcePath,
Required: definition.Required, Options: datatypes.JSON(options), Validation: datatypes.JSON(validation), SortOrder: index, "field_type": definition.Type, "required": definition.Required, "options": datatypes.JSON(options),
}).FirstOrCreate(&field, model.ScenarioField{FieldKey: definition.Key}).Error "validation": datatypes.JSON(validation), "sort_order": index,
}
err := tx.Where("scenario_id = ? AND field_key = ?", scenarioID, definition.Key).Assign(attributes).FirstOrCreate(&field, model.ScenarioField{FieldKey: definition.Key}).Error
if err != nil { if err != nil {
return fmt.Errorf("seed field %s: %w", definition.Key, err) return fmt.Errorf("seed field %s: %w", definition.Key, err)
} }
} }
return tx.Where("tenant_id = ? AND scenario_id = ? AND field_key NOT IN ?", tenantID, scenarioID, keys).Delete(&model.ScenarioField{}).Error
}
func seedInputSchema(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
}
items := make([]map[string]interface{}, 0, len(fields))
for _, field := range fields {
items = append(items, map[string]interface{}{"key": field.FieldKey, "name": field.FieldName, "type": field.FieldType, "source_path": field.SourcePath, "required": field.Required, "options": json.RawMessage(field.Options), "validation": json.RawMessage(field.Validation)})
}
raw, _ := json.Marshal(map[string]interface{}{"fields": items})
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, tenantID).Update("input_schema", datatypes.JSON(raw)).Error
}
func petFieldDefinitions() []fieldDefinition {
return []fieldDefinition{
{Key: "order_id", Name: "订单号", Type: "text", SourcePath: "order.id"},
{Key: "customer_name", Name: "客户称呼", Type: "text", SourcePath: "customer.name"},
{Key: "product_ids", Name: "订单商品 ID", Type: "array", SourcePath: "order.items[*].product_id"},
{Key: "product_names", Name: "订单商品名称", Type: "array", SourcePath: "order.items[*].product_name"},
{Key: "product_images", Name: "订单商品图片", Type: "array", SourcePath: "order.items[*].image_url"},
{Key: "input_symptom_tags", Name: "商品症状标签", Type: "array", SourcePath: "order.items[*].symptom_tags[*]"},
{Key: "pet_name", Name: "宠物名称", Type: "text", SourcePath: "pet.name", Validation: map[string]interface{}{"max_length": 50}},
{Key: "pet_type", Name: "宠物种类", Type: "select", SourcePath: "pet.species", Options: []string{"犬", "猫", "其他"}},
{Key: "pet_age", Name: "宠物年龄", Type: "text", Validation: map[string]interface{}{"max_length": 30}},
{Key: "pet_weight", Name: "宠物体重kg", Type: "number", Validation: map[string]interface{}{"min": 0.1, "max": 200}},
{Key: "pet_sex", Name: "宠物性别", Type: "select", Options: []string{"公", "母", "未知"}},
{Key: "confirmed_symptoms", Name: "客户确认症状", Type: "array", Required: true},
{Key: "confirmed_diseases", Name: "确认疾病分型", Type: "array", Required: true},
}
}
func seedKnowledgeGraph(tx *gorm.DB, tenantID, scenarioID uint64) error {
definitions := map[string]struct {
name string
typeName string
content map[string]interface{}
}{
"consultation_boundary": {name: "线上问诊边界", typeName: "guidance", content: map[string]interface{}{
"standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。",
"forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。",
"risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。",
}},
"emergency_guidance": {name: "急症红旗征象", typeName: "guidance", content: map[string]interface{}{
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
}},
"medication_safety": {name: "问诊用药安全原则", typeName: "guidance", content: map[string]interface{}{
"standard_copy": "用药需要结合物种、年龄、体重、既往病史、正在使用的药物和必要检查,由宠物医生评估后确定。请勿自行增加剂量、混用药物或使用人用药。",
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
}},
"safety_net": {name: "观察与复诊提示", typeName: "guidance", content: map[string]interface{}{
"standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。",
"forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。",
"risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。",
}},
"soft_stool": {name: "软便", typeName: "symptom", content: map[string]interface{}{"label": "soft_stool"}},
"poor_appetite": {name: "食欲下降", typeName: "symptom", content: map[string]interface{}{"label": "poor_appetite"}},
"ask_stool": {name: "软便追问话术", typeName: "copy", content: map[string]interface{}{"template": "{{input.customer_name}}您好,想了解一下{{input.pet_name}}近期排便的频率和形态,是否伴随呕吐、精神变差或便中带血?"}},
"recommend_gut": {name: "肠胃护理推荐话术", typeName: "copy", content: map[string]interface{}{"template": "结合{{input.pet_name}}目前的表现,可以先向您介绍肠胃护理方向的产品和日常喂养注意事项,具体用药仍需宠物医生评估。"}},
"gi_discomfort": {name: "肠胃不适风险", typeName: "disease", content: map[string]interface{}{"risk_level": "normal", "label": "gi_discomfort"}},
"parasite_risk": {name: "寄生虫风险", typeName: "disease", content: map[string]interface{}{"risk_level": "medium", "label": "parasite_risk"}},
}
index := 0
for key, definition := range definitions {
content, _ := json.Marshal(definition.content)
row := model.KnowledgeItem{TenantID: tenantID, ScenarioID: scenarioID, ItemKey: key, Name: definition.name, Type: definition.typeName, Content: datatypes.JSON(content), Status: "active", SortOrder: index}
if err := tx.Where("tenant_id = ? AND scenario_id = ? AND item_key = ?", tenantID, scenarioID, key).Assign(row).FirstOrCreate(&row).Error; err != nil {
return fmt.Errorf("seed knowledge item %s: %w", key, err)
}
index++
}
relations := []struct{ from, relation, to string }{
{"soft_stool", "recommended_copy", "ask_stool"}, {"soft_stool", "recommended_copy", "recommend_gut"},
{"soft_stool", "possible_disease", "gi_discomfort"}, {"soft_stool", "possible_disease", "parasite_risk"},
{"poor_appetite", "recommended_copy", "recommend_gut"}, {"poor_appetite", "possible_disease", "gi_discomfort"},
}
ids := map[string]uint64{}
var items []model.KnowledgeItem
if err := tx.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Find(&items).Error; err != nil {
return err
}
for _, item := range items {
ids[item.ItemKey] = item.ID
}
for order, relation := range relations {
fromID, fromOK := ids[relation.from]
toID, toOK := ids[relation.to]
if !fromOK || !toOK {
return fmt.Errorf("knowledge relation references missing item: %s -> %s", relation.from, relation.to)
}
row := model.KnowledgeRelation{TenantID: tenantID, ScenarioID: scenarioID, FromKnowledgeID: fromID, RelationType: relation.relation, ToKnowledgeID: toID, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: order}
if err := tx.Where("scenario_id = ? AND from_knowledge_id = ? AND relation_type = ? AND to_knowledge_id = ?", scenarioID, fromID, relation.relation, toID).Assign(row).FirstOrCreate(&row).Error; err != nil {
return fmt.Errorf("seed knowledge relation: %w", err)
}
}
return nil return nil
} }
func petFieldDefinitions() []fieldDefinition { func seedRulesAndOutput(tx *gorm.DB, tenantID, scenarioID uint64) error {
return []fieldDefinition{ rule := model.ScenarioRule{TenantID: tenantID, ScenarioID: scenarioID, RuleKey: "derive-matched-symptoms", Name: "从商品标签提取症状", Condition: datatypes.JSON([]byte(`{"field":"input.input_symptom_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_symptoms","value_from":"input.input_symptom_tags"}]`)), Priority: 100, Status: "active"}
{Key: "consultation_channel", Name: "咨询渠道", Type: "select", Required: true, Options: []string{"到店", "电话", "在线图文", "视频"}}, if err := tx.Where("scenario_id = ? AND rule_key = ?", scenarioID, rule.RuleKey).Assign(rule).FirstOrCreate(&rule).Error; err != nil {
{Key: "visit_goal", Name: "本次咨询目的", Type: "select", Required: true, Options: []string{"症状咨询", "用药咨询", "复诊随访", "检查报告解读", "其他"}},
{Key: "pet_name", Name: "宠物名称", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 50}},
{Key: "pet_type", Name: "宠物种类", Type: "select", Required: true, Options: []string{"犬", "猫", "其他"}},
{Key: "pet_breed", Name: "品种", Type: "text", Validation: map[string]interface{}{"max_length": 50}},
{Key: "pet_age", Name: "年龄(岁)", Type: "number", Validation: map[string]interface{}{"min": 0, "max": 50}},
{Key: "pet_weight", Name: "体重kg", Type: "number", Validation: map[string]interface{}{"min": 0.01, "max": 200}},
{Key: "pet_sex", Name: "性别", Type: "select", Options: []string{"公", "母", "未知"}},
{Key: "is_neutered", Name: "是否绝育", Type: "boolean"},
{Key: "pregnancy_status", Name: "妊娠或哺乳情况", Type: "select", Options: []string{"不适用", "否", "可能妊娠", "妊娠中", "哺乳中", "不清楚"}},
{Key: "symptom", Name: "主要症状", Type: "textarea", Required: true, Validation: map[string]interface{}{"max_length": 1000}},
{Key: "symptom_duration", Name: "症状开始时间及持续时长", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 100}},
{Key: "symptom_trend", Name: "症状变化趋势", Type: "select", Required: true, Options: []string{"好转", "稳定", "反复", "加重", "不清楚"}},
{Key: "prior_visit", Name: "本次问题是否已就诊", Type: "boolean"},
{Key: "breathing_difficulty", Name: "是否呼吸困难", Type: "boolean", Required: true},
{Key: "active_bleeding", Name: "是否持续出血", Type: "boolean", Required: true},
{Key: "convulsion", Name: "是否抽搐或意识异常", Type: "boolean", Required: true},
{Key: "severe_trauma", Name: "是否遭遇严重外伤或高处坠落", Type: "boolean", Required: true},
{Key: "unable_to_urinate", Name: "是否无法排尿", Type: "boolean", Required: true},
{Key: "toxin_exposure", Name: "是否可能误食毒物或异物", Type: "boolean", Required: true},
{Key: "repeated_vomiting", Name: "是否持续呕吐或干呕", Type: "boolean", Required: true},
{Key: "severe_pain", Name: "是否剧烈疼痛或腹部明显膨大", Type: "boolean", Required: true},
{Key: "heatstroke_sign", Name: "是否疑似中暑或体温异常升高", Type: "boolean", Required: true},
{Key: "unable_to_stand", Name: "是否虚弱到无法站立", Type: "boolean", Required: true},
{Key: "has_emergency_sign", Name: "是否存在其他危及生命的表现", Type: "boolean", Required: true},
{Key: "appetite", Name: "食欲情况", Type: "select", Options: []string{"正常", "下降", "完全不吃"}},
{Key: "water_intake", Name: "饮水情况", Type: "select", Options: []string{"正常", "增多", "减少", "完全不喝", "不清楚"}},
{Key: "spirit_status", Name: "精神状态", Type: "select", Options: []string{"正常", "较差", "嗜睡或无法站立"}},
{Key: "vomiting", Name: "是否呕吐", Type: "boolean"},
{Key: "diarrhea", Name: "是否腹泻", Type: "boolean"},
{Key: "urination_status", Name: "排尿情况", Type: "select", Options: []string{"正常", "增多", "减少", "频繁少量", "带血", "不清楚"}},
{Key: "stool_status", Name: "排便情况", Type: "select", Options: []string{"正常", "稀软", "水样", "带血或黑便", "便秘", "不清楚"}},
{Key: "measured_temperature", Name: "实测体温(℃)", Type: "number", Validation: map[string]interface{}{"min": 30, "max": 45}},
{Key: "chronic_disease_history", Name: "既往疾病及手术史", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
{Key: "vaccination_status", Name: "免疫情况", Type: "select", Options: []string{"按期完成", "未完成", "逾期", "不清楚", "不适用"}},
{Key: "medication_history", Name: "近期用药、驱虫药和保健品", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
{Key: "allergy_history", Name: "已知药物过敏或不良反应史", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
{Key: "wants_medication", Name: "是否咨询具体用药", Type: "boolean", Required: true},
{Key: "medication_request_type", Name: "用药咨询类型", Type: "select", Options: []string{"新症状想用药", "询问现有处方用法", "续方或复购", "漏服或多服", "服药后异常", "其他"}},
{Key: "requested_medication", Name: "咨询的药品名称及规格", Type: "text", Validation: map[string]interface{}{"max_length": 200}},
{Key: "prescribed_by_vet", Name: "是否由宠物医生开具", Type: "boolean"},
{Key: "has_taken_medication", Name: "是否已经使用该药", Type: "boolean"},
{Key: "dose_and_time", Name: "已用剂量、次数及最后用药时间", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
{Key: "human_medication_exposure", Name: "是否使用或误食人用药", Type: "boolean", Required: true},
{Key: "suspected_overdose", Name: "是否可能过量或重复用药", Type: "boolean", Required: true},
{Key: "adverse_reaction", Name: "用药后是否出现异常反应", Type: "boolean", Required: true},
{Key: "adverse_reaction_detail", Name: "异常反应描述", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
}
}
func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[string]uint64, error) {
definitions := map[string]map[string]interface{}{
"线上问诊边界": {
"standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。",
"forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。",
"risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。",
},
"急症红旗征象": {
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
},
"问诊用药安全原则": {
"standard_copy": "用药需要结合物种、年龄、体重、既往病史、正在使用的药物和必要检查,由宠物医生评估后确定。请勿自行增加剂量、混用药物或使用人用药。",
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
},
"观察与复诊提示": {
"standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。",
"forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。",
"risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。",
},
}
result := make(map[string]uint64, len(definitions))
for title, content := range definitions {
var card model.KnowledgeCard
err := tx.Where("tenant_id = ? AND scenario_id = ? AND title = ?", tenantID, scenarioID, title).FirstOrCreate(&card, model.KnowledgeCard{
TenantID: tenantID, ScenarioID: scenarioID, Title: title, Status: "published", CreatedBy: userID,
}).Error
if err != nil {
return nil, fmt.Errorf("seed knowledge card %s: %w", title, err)
}
if err := tx.Model(&card).Update("status", "published").Error; err != nil {
return nil, err
}
contentJSON, _ := json.Marshal(content)
if err := publishSeedKnowledgeVersion(tx, tenantID, card.ID, contentJSON); err != nil {
return nil, fmt.Errorf("seed knowledge card version %s: %w", title, err)
}
result[title] = card.ID
}
return result, nil
}
func publishSeedKnowledgeVersion(tx *gorm.DB, tenantID, cardID uint64, contentJSON []byte) error {
var latest model.KnowledgeCardVersion
err := tx.Where("knowledge_card_id = ? AND tenant_id = ?", cardID, tenantID).Order("version DESC").First(&latest).Error
if err == gorm.ErrRecordNotFound {
return tx.Create(&model.KnowledgeCardVersion{
TenantID: tenantID, KnowledgeCardID: cardID, Version: 1,
Content: datatypes.JSON(contentJSON), Status: "published",
}).Error
}
if err != nil {
return err return err
} }
if sameJSON(latest.Content, contentJSON) { out := map[string]interface{}{"fields": []map[string]interface{}{
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND id <> ? AND status = ?", cardID, tenantID, latest.ID, "published").Update("status", "superseded").Error; err != nil { {"key": "matched_symptoms", "name": "命中症状", "type": "array", "source": "derived", "source_field": "matched_symptoms"},
return err {"key": "product_names", "name": "订单商品", "type": "array", "source": "input", "source_field": "product_names"},
} }}
return tx.Model(&latest).Update("status", "published").Error result := map[string]interface{}{"fields": []map[string]interface{}{
} {"key": "recommended_products", "name": "成功推荐商品", "type": "array", "items": map[string]interface{}{"type": "object", "fields": []map[string]interface{}{
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", cardID, tenantID, "published").Update("status", "superseded").Error; err != nil { {"key": "product_id", "name": "商品 ID", "type": "string", "required": true},
return err {"key": "product_name", "name": "商品名称", "type": "string", "required": true},
} {"key": "quantity", "name": "推荐数量", "type": "integer"},
return tx.Create(&model.KnowledgeCardVersion{ }}},
TenantID: tenantID, KnowledgeCardID: cardID, Version: latest.Version + 1, {"key": "result_note", "name": "结果备注", "type": "text"},
Content: datatypes.JSON(contentJSON), Status: "published", }}
}).Error outputRaw, _ := json.Marshal(out)
resultRaw, _ := json.Marshal(result)
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, tenantID).Updates(map[string]interface{}{"output_schema": datatypes.JSON(outputRaw), "result_schema": datatypes.JSON(resultRaw)}).Error
} }
func sameJSON(left, right []byte) bool { func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64) error {
var leftValue interface{}
var rightValue interface{}
if json.Unmarshal(left, &leftValue) != nil || json.Unmarshal(right, &rightValue) != nil {
return false
}
return reflect.DeepEqual(leftValue, rightValue)
}
func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[string]uint64) error {
var sop model.SOP var sop model.SOP
err := tx.Where("tenant_id = ? AND scenario_id = ? AND name = ?", tenantID, scenarioID, "宠物问诊问药标准 SOP").FirstOrCreate(&sop, model.SOP{ err := tx.Where("tenant_id = ? AND scenario_id = ? AND name = ?", tenantID, scenarioID, "宠物问诊问药标准 SOP").FirstOrCreate(&sop, model.SOP{
TenantID: tenantID, ScenarioID: scenarioID, Name: "宠物问诊问药标准 SOP", CreatedBy: userID, TenantID: tenantID, ScenarioID: scenarioID, Name: "宠物问诊问药标准 SOP", CreatedBy: userID,
@@ -289,38 +291,36 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
return fmt.Errorf("find or create SOP: %w", err) return fmt.Errorf("find or create SOP: %w", err)
} }
if err := tx.Model(&sop).Updates(map[string]interface{}{ if err := tx.Model(&sop).Updates(map[string]interface{}{
"description": "标准化采集宠物主诉和病史,通过急症及用药风险双重分流,形成医生评估、转诊和复诊闭环。", "description": "根据订单商品携带的症状标签展示关联症状、话术和可能疾病,辅助销售完成商品推荐并记录结果。",
"status": "published", "status": "published",
}).Error; err != nil { }).Error; err != nil {
return err return err
} }
var seededPublished int64
if err := tx.Table("sop_nodes n").Joins("JOIN sop_versions v ON v.id = n.sop_version_id").Where(
"v.sop_id = ? AND n.node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.seed_key')) = ?",
sop.ID, "start", "pet-doctor-v5",
).Count(&seededPublished).Error; err != nil {
return err
}
if seededPublished > 0 {
return nil
}
var version model.SOPVersion var version model.SOPVersion
err = tx.Where("sop_id = ? AND tenant_id = ? AND status = ?", sop.ID, tenantID, "draft").Order("version DESC").First(&version).Error err = tx.Where("sop_id = ? AND tenant_id = ?", sop.ID, tenantID).Order("version DESC").First(&version).Error
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
var maxVersion int version = model.SOPVersion{TenantID: tenantID, SOPID: sop.ID, Version: 1, Status: "published", StartNodeKey: "start", CreatedBy: userID}
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ?", sop.ID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
return err
}
version = model.SOPVersion{TenantID: tenantID, SOPID: sop.ID, Version: maxVersion + 1, Status: "draft", StartNodeKey: "start", CreatedBy: userID}
if err := tx.Create(&version).Error; err != nil { if err := tx.Create(&version).Error; err != nil {
return err return err
} }
} else if err != nil { } else if err != nil {
return err return err
} }
var alreadySeeded int64
if err := tx.Model(&model.SOPNode{}).Where(
"sop_version_id = ? AND node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(config, '$.seed_key')) = ?",
version.ID, "start", "pet-doctor-v16-start-presentation-optional-pet",
).Count(&alreadySeeded).Error; err != nil {
return err
}
if alreadySeeded > 0 {
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ?", sop.ID, version.ID).Update("status", "superseded").Error; err != nil {
return err
}
return tx.Model(&version).Updates(map[string]interface{}{"status": "published", "start_node_key": "start"}).Error
}
nodes := petNodes(knowledgeIDs) nodes := petNodes()
edges := petEdges() edges := petEdges()
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil { if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
return err return err
@@ -348,91 +348,44 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err) return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
} }
} }
if err := sopservice.BindKnowledgeVersions(tx, tenantID, version.ID); err != nil { if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ?", sop.ID, version.ID).Update("status", "superseded").Error; err != nil {
return fmt.Errorf("bind knowledge versions: %w", err)
}
now := time.Now()
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ? AND status = ?", sop.ID, version.ID, "published").Update("status", "superseded").Error; err != nil {
return err return err
} }
if err := tx.Model(&version).Updates(map[string]interface{}{ if err := tx.Model(&version).Updates(map[string]interface{}{
"status": "published", "start_node_key": "start", "published_at": &now, "reviewed_by": userID, "status": "published", "start_node_key": "start",
}).Error; err != nil { }).Error; err != nil {
return err return err
} }
payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药", "version": version.Version}) payload, _ := json.Marshal(map[string]interface{}{"scenario": "宠物医生问诊问药"})
return tx.Create(&model.AuditLog{ return tx.Create(&model.AuditLog{
TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload), TenantID: tenantID, UserID: userID, Action: "seed", Resource: "sop", ResourceID: sop.ID, Payload: datatypes.JSON(payload),
}).Error }).Error
} }
func petNodes(knowledgeIDs map[string]uint64) []nodeDefinition { func petNodes() []nodeDefinition {
return []nodeDefinition{ return []nodeDefinition{
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v5"}}, {Key: "start", Type: "start", Title: "开始", Content: "确认订单和客户信息,使用开场话术开始沟通。", Config: map[string]interface{}{
{Key: "opening", Type: "message", Title: "说明问诊流程与边界", Content: "您好,我会先记录本次咨询目的、宠物基本信息和症状,优先排查是否需要立即就医,再将完整信息提交给宠物医生评估。线上沟通不能替代医生检查。", Config: map[string]interface{}{}}, "seed_key": "pet-doctor-v16-start-presentation-optional-pet",
{Key: "consultation_context", Type: "form", Title: "确认咨询背景", Content: "请确认本次咨询渠道和主要目的,便于医生判断沟通重点。", Config: map[string]interface{}{"field_keys": []string{"consultation_channel", "visit_goal"}}}, "presentation": map[string]interface{}{
{Key: "basic_info", Type: "form", Title: "采集宠物基本信息", Content: "请确认宠物名称、种类、品种、年龄、体重、性别、绝育以及妊娠或哺乳情况;不确定的信息可以留空。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_breed", "pet_age", "pet_weight", "pet_sex", "is_neutered", "pregnancy_status"}}}, "summary_field_keys": []string{"order_id", "customer_name", "pet_name", "pet_type"},
{Key: "chief_complaint", Type: "form", Title: "记录主诉与变化", Content: "请让宠物主人按时间顺序描述主要症状、开始时间、变化趋势,以及是否已经就诊。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration", "symptom_trend", "prior_visit"}}}, "item_field_keys": []string{"product_images", "product_names", "product_ids"},
{Key: "emergency_screen", Type: "form", Title: "筛查急症红旗", Content: "请逐项询问,不要仅凭主人主动描述判断。任意一项为“是”都应停止常规问诊并优先转诊。", Config: map[string]interface{}{"field_keys": []string{"breathing_difficulty", "active_bleeding", "convulsion", "severe_trauma", "unable_to_urinate", "toxin_exposure", "repeated_vomiting", "severe_pain", "heatstroke_sign", "unable_to_stand", "has_emergency_sign"}}}, "image_field_keys": []string{"product_images"},
{Key: "emergency_check", Type: "condition", Title: "判断是否需要立即转诊", Content: "系统根据急症筛查结果自动分流。", Config: map[string]interface{}{"risk_level": "high"}}, "opening_title": "开场话术",
{Key: "emergency_guidance", Type: "knowledge", Title: "告知急症处置原则", Content: "请按知识卡向宠物主人说明急症风险和立即就医要求。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["急症红旗征象"]}}, "opening_template": "您好,{{input.customer_name}}。我是宠物健康顾问,看到您购买了{{input.product_names}},想回访了解一下使用情况,也了解一下宠物目前的身体状况。",
{Key: "emergency_escalate", Type: "escalate", Title: "立即急诊或转诊", Content: "请停止常规线上问诊,建议立即前往最近的宠物急诊并提前联系接诊机构。保持宠物安静和通风,除接诊医生明确指导外,不要自行喂药、催吐或强行喂食,并携带可疑药物、毒物包装及既往资料。", Config: map[string]interface{}{"action": "urgent_referral"}}, },
{Key: "current_status", Type: "form", Title: "补充当前生命状态", Content: "继续了解食欲、饮水、精神、呕吐、腹泻、排尿、排便和实测体温。未测体温时请留空,不要凭触摸估计。", Config: map[string]interface{}{"field_keys": []string{"appetite", "water_intake", "spirit_status", "vomiting", "diarrhea", "urination_status", "stool_status", "measured_temperature"}}}, }},
{Key: "medical_history", Type: "form", Title: "补充既往史", Content: "请记录既往疾病和手术、免疫情况、近期所有药物及保健品,以及已知药物过敏或不良反应。", Config: map[string]interface{}{"field_keys": []string{"chronic_disease_history", "vaccination_status", "medication_history", "allergy_history"}}}, {Key: "pet_info", Type: "form", Title: "收集宠物信息", Content: "核对系统已带入的宠物信息,并根据客户回答补充名称、种类、年龄、体重和性别。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex"}, "required_field_keys": []string{"pet_name", "pet_type"}}},
{Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "确认宠物主人是否正在咨询具体药物、剂量、疗程、续方、漏服或用药后异常。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}}, {Key: "symptom", Type: "knowledge", Title: "确认宠物症状与疾病", Content: "商品标签是系统推断的候选症状;请结合客户回答确认,并可从场景知识中补充其他伴随症状。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"derived_field": "matched_symptoms", "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "relation_types": []string{"recommended_copy", "possible_disease"}, "relation_labels": map[string]string{"recommended_copy": "沟通话术", "possible_disease": "可能疾病"}}, "knowledge_collection": map[string]interface{}{"selection_title": "确认实际症状和疾病", "selection_hint": "系统推断项优先展示,也可搜索场景知识补充伴随症状", "steps": []map[string]interface{}{{"field_key": "confirmed_symptoms", "name": "客户确认症状", "root": true, "candidate_scope": "all", "knowledge_types": []string{"symptom"}, "required": true, "multiple": true}, {"field_key": "confirmed_diseases", "name": "确认疾病分型", "from_field": "confirmed_symptoms", "relation_type": "possible_disease", "required": true, "multiple": true}}}}},
{Key: "medication_details", Type: "form", Title: "采集用药详情", Content: "请核对药名和规格、咨询类型、是否由宠物医生开具、是否已经使用,以及已用剂量和时间。无法确认时请查看处方或药品包装,不要猜测。", Config: map[string]interface{}{"field_keys": []string{"medication_request_type", "requested_medication", "prescribed_by_vet", "has_taken_medication", "dose_and_time"}}}, {Key: "recommend_product", Type: "knowledge", Title: "推荐方案与商品", Content: "根据已确认的疾病分型,向客户讲解推荐话术、建议方案和可使用的商品。", Config: map[string]interface{}{"knowledge_selector": map[string]interface{}{"answer_field": "confirmed_diseases", "knowledge_types": []string{"disease"}, "relation_types": []string{"recommended_copy", "recommended_plan", "recommended_product"}, "relation_labels": map[string]string{"recommended_copy": "推荐话术", "recommended_plan": "建议方案", "recommended_product": "推荐商品"}}}},
{Key: "medication_risk_screen", Type: "form", Title: "筛查用药风险", Content: "请确认是否涉及人用药、可能过量或重复用药,以及用药后异常;如有异常,请记录出现时间和具体表现。", Config: map[string]interface{}{"field_keys": []string{"human_medication_exposure", "suspected_overdose", "adverse_reaction", "adverse_reaction_detail"}}}, {Key: "finish", Type: "finish", Title: "结束", Content: "本次话术执行完成,请保存最终推荐结果。", Config: map[string]interface{}{}},
{Key: "medication_risk_check", Type: "condition", Title: "判断是否存在用药急症风险", Content: "系统根据用药风险信息自动分流。", Config: map[string]interface{}{"risk_level": "high"}},
{Key: "medication_escalate", Type: "escalate", Title: "立即转医生或急诊评估", Content: "存在误用人用药、可能过量或用药后异常。请立即转宠物医生评估;如出现呼吸困难、意识异常、抽搐、虚脱或症状快速加重,应立即前往急诊。请保留药品包装、处方和实际用药时间记录。", Config: map[string]interface{}{"action": "medication_risk_referral"}},
{Key: "medication_safety", Type: "knowledge", Title: "说明用药安全原则", Content: "请按知识卡说明用药边界,在医生完成评估前不要承诺具体药名、剂量或疗程。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["问诊用药安全原则"]}},
{Key: "consultation_boundary", Type: "knowledge", Title: "说明线上问诊边界", Content: "请说明线上沟通的作用和限制,并确认宠物主人理解下一步需要医生评估。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["线上问诊边界"]}},
{Key: "assessment", Type: "message", Title: "提交医生评估并明确下一步", Content: "信息已记录。请由宠物医生结合病史、体格检查和必要检验判断后续方案;向宠物主人明确是立即到院、预约就诊、按原处方处理还是居家观察,并记录医生给出的时间要求。", Config: map[string]interface{}{}},
{Key: "safety_net", Type: "knowledge", Title: "确认观察和复诊提示", Content: "请按知识卡说明观察指标、复诊时间和立即就医触发条件,并请宠物主人复述确认。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["观察与复诊提示"]}},
{Key: "finish", Type: "finish", Title: "完成问诊记录", Content: "本次初步问诊信息已记录,请按宠物医生给出的时间和方式安排检查、复诊或治疗;情况变化时及时重新联系或立即就医。", Config: map[string]interface{}{}},
} }
} }
func petEdges() []edgeDefinition { func petEdges() []edgeDefinition {
emergencyRules := []interface{}{
map[string]interface{}{"field": "breathing_difficulty", "operator": "equals", "value": true},
map[string]interface{}{"field": "active_bleeding", "operator": "equals", "value": true},
map[string]interface{}{"field": "convulsion", "operator": "equals", "value": true},
map[string]interface{}{"field": "severe_trauma", "operator": "equals", "value": true},
map[string]interface{}{"field": "unable_to_urinate", "operator": "equals", "value": true},
map[string]interface{}{"field": "toxin_exposure", "operator": "equals", "value": true},
map[string]interface{}{"field": "repeated_vomiting", "operator": "equals", "value": true},
map[string]interface{}{"field": "severe_pain", "operator": "equals", "value": true},
map[string]interface{}{"field": "heatstroke_sign", "operator": "equals", "value": true},
map[string]interface{}{"field": "unable_to_stand", "operator": "equals", "value": true},
map[string]interface{}{"field": "has_emergency_sign", "operator": "equals", "value": true},
}
medicationRiskRules := []interface{}{
map[string]interface{}{"field": "human_medication_exposure", "operator": "equals", "value": true},
map[string]interface{}{"field": "suspected_overdose", "operator": "equals", "value": true},
map[string]interface{}{"field": "adverse_reaction", "operator": "equals", "value": true},
}
return []edgeDefinition{ return []edgeDefinition{
{Source: "start", Target: "opening", Condition: map[string]interface{}{}}, {Source: "start", Target: "pet_info", Condition: map[string]interface{}{}},
{Source: "opening", Target: "consultation_context", Condition: map[string]interface{}{}}, {Source: "pet_info", Target: "symptom", Condition: map[string]interface{}{}},
{Source: "consultation_context", Target: "basic_info", Condition: map[string]interface{}{}}, {Source: "symptom", Target: "recommend_product", Condition: map[string]interface{}{}},
{Source: "basic_info", Target: "chief_complaint", Condition: map[string]interface{}{}}, {Source: "recommend_product", Target: "finish", Condition: map[string]interface{}{}},
{Source: "chief_complaint", Target: "emergency_screen", Condition: map[string]interface{}{}},
{Source: "emergency_screen", Target: "emergency_check", Condition: map[string]interface{}{}},
{Source: "emergency_check", Target: "emergency_guidance", Condition: map[string]interface{}{"any": emergencyRules}, Priority: 0},
{Source: "emergency_check", Target: "current_status", Condition: map[string]interface{}{}, Priority: 100},
{Source: "emergency_guidance", Target: "emergency_escalate", Condition: map[string]interface{}{}},
{Source: "current_status", Target: "medical_history", Condition: map[string]interface{}{}},
{Source: "medical_history", Target: "medication_intent", Condition: map[string]interface{}{}},
{Source: "medication_intent", Target: "medication_details", Condition: map[string]interface{}{"field": "wants_medication", "operator": "equals", "value": true}, Priority: 0},
{Source: "medication_intent", Target: "consultation_boundary", Condition: map[string]interface{}{}, Priority: 100},
{Source: "medication_details", Target: "medication_risk_screen", Condition: map[string]interface{}{}},
{Source: "medication_risk_screen", Target: "medication_risk_check", Condition: map[string]interface{}{}},
{Source: "medication_risk_check", Target: "medication_escalate", Condition: map[string]interface{}{"any": medicationRiskRules}, Priority: 0},
{Source: "medication_risk_check", Target: "medication_safety", Condition: map[string]interface{}{}, Priority: 100},
{Source: "medication_safety", Target: "consultation_boundary", Condition: map[string]interface{}{}},
{Source: "consultation_boundary", Target: "assessment", Condition: map[string]interface{}{}},
{Source: "assessment", Target: "safety_net", Condition: map[string]interface{}{}},
{Source: "safety_net", Target: "finish", Condition: map[string]interface{}{}},
} }
} }

View File

@@ -11,25 +11,33 @@ import (
func TestPetDoctorSOPPassesPublishValidation(t *testing.T) { func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
fields := petFieldModels() fields := petFieldModels()
knowledgeIDs := map[string]uint64{ nodes := petNodeModels(petNodes())
"线上问诊边界": 1,
"急症红旗征象": 2,
"问诊用药安全原则": 3,
"观察与复诊提示": 4,
}
nodes := petNodeModels(petNodes(knowledgeIDs))
edges := petEdgeModels(petEdges()) edges := petEdgeModels(petEdges())
if len(fields) != 47 || len(nodes) != 21 || len(edges) != 21 { if len(fields) != 13 || len(nodes) != 5 || len(edges) != 4 {
t.Fatalf("unexpected definition size: fields=%d nodes=%d edges=%d", len(fields), len(nodes), len(edges)) t.Fatalf("unexpected definition size: fields=%d nodes=%d edges=%d", len(fields), len(nodes), len(edges))
} }
problems := sop.ValidateForPublish("start", nodes, edges, sop.ValidationContext{ problems := sop.ValidateForPublish("start", nodes, edges, sop.ValidationContext{
Fields: fields, Fields: fields,
PublishedKnowledgeCardIDs: map[uint64]bool{ KnowledgeItems: []model.KnowledgeItem{
1: true, {Base: model.Base{ID: 1}, ItemKey: "consultation_boundary", Type: "guidance", Status: "active"},
2: true, {Base: model.Base{ID: 2}, ItemKey: "emergency_guidance", Type: "guidance", Status: "active"},
3: true, {Base: model.Base{ID: 3}, ItemKey: "medication_safety", Type: "guidance", Status: "active"},
4: true, {Base: model.Base{ID: 4}, ItemKey: "safety_net", Type: "guidance", Status: "active"},
{Base: model.Base{ID: 5}, ItemKey: "soft_stool", Type: "symptom", Status: "active"},
{Base: model.Base{ID: 6}, ItemKey: "poor_appetite", Type: "symptom", Status: "active"},
{Base: model.Base{ID: 7}, ItemKey: "ask_stool", Type: "copy", Status: "active"},
{Base: model.Base{ID: 8}, ItemKey: "gi_discomfort", Type: "disease", Status: "active"},
{Base: model.Base{ID: 9}, ItemKey: "gut_plan", Type: "plan", Status: "active"},
{Base: model.Base{ID: 10}, ItemKey: "gut_product", Type: "product", Status: "active"},
},
KnowledgeRelations: []model.KnowledgeRelation{
{FromKnowledgeID: 5, ToKnowledgeID: 7, RelationType: "recommended_copy"},
{FromKnowledgeID: 5, ToKnowledgeID: 8, RelationType: "possible_disease"},
{FromKnowledgeID: 5, ToKnowledgeID: 9, RelationType: "recommended_plan"},
{FromKnowledgeID: 8, ToKnowledgeID: 7, RelationType: "recommended_copy"},
{FromKnowledgeID: 8, ToKnowledgeID: 9, RelationType: "recommended_plan"},
{FromKnowledgeID: 8, ToKnowledgeID: 10, RelationType: "recommended_product"},
}, },
}) })
if len(problems) > 0 { if len(problems) > 0 {
@@ -37,31 +45,27 @@ func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
} }
} }
func TestPetDoctorSOPHasBothRiskEscalations(t *testing.T) { func TestPetDoctorSOPIsFiveStepLinearFlow(t *testing.T) {
nodes := petNodes(map[string]uint64{ wantNodes := []string{"start", "pet_info", "symptom", "recommend_product", "finish"}
"线上问诊边界": 1, for index, node := range petNodes() {
"急症红旗征象": 2, if node.Key != wantNodes[index] {
"问诊用药安全原则": 3, t.Fatalf("node %d = %s, want %s", index, node.Key, wantNodes[index])
"观察与复诊提示": 4,
})
escalations := map[string]bool{}
for _, node := range nodes {
if node.Type == "escalate" {
escalations[node.Key] = true
} }
} }
for _, key := range []string{"emergency_escalate", "medication_escalate"} { wantEdges := [][2]string{{"start", "pet_info"}, {"pet_info", "symptom"}, {"symptom", "recommend_product"}, {"recommend_product", "finish"}}
if !escalations[key] { for index, edge := range petEdges() {
t.Fatalf("missing risk escalation node %s", key) if edge.Source != wantEdges[index][0] || edge.Target != wantEdges[index][1] {
t.Fatalf("edge %d = %s -> %s", index, edge.Source, edge.Target)
} }
} }
} }
func TestSameJSONIgnoresObjectOrderAndWhitespace(t *testing.T) { func TestPetKnowledgeKeyIsStableAndTypeScoped(t *testing.T) {
left := []byte(`{"standard_copy":"安全提示","risk_note":"立即就医"}`) if petKnowledgeKey("symptom", "腹泻") != petKnowledgeKey("symptom", " 腹泻 ") {
right := []byte(`{ "risk_note": "立即就医", "standard_copy": "安全提示" }`) t.Fatal("key should ignore surrounding whitespace")
if !sameJSON(left, right) { }
t.Fatal("equivalent JSON objects should match") if petKnowledgeKey("symptom", "腹泻") == petKnowledgeKey("disease", "腹泻") {
t.Fatal("key should include knowledge type")
} }
} }
@@ -74,6 +78,7 @@ func petFieldModels() []model.ScenarioField {
fields = append(fields, model.ScenarioField{ fields = append(fields, model.ScenarioField{
FieldKey: definition.Key, FieldKey: definition.Key,
FieldName: definition.Name, FieldName: definition.Name,
SourcePath: definition.SourcePath,
FieldType: definition.Type, FieldType: definition.Type,
Required: definition.Required, Required: definition.Required,
Options: datatypes.JSON(options), Options: datatypes.JSON(options),

121
sdk/dist/index.d.ts vendored Normal file
View File

@@ -0,0 +1,121 @@
export type ScenarioInput = Record<string, unknown>;
export type OutputField = {
key: string;
name: string;
type: string;
source: string;
value: unknown;
};
export type ResultField = {
key: string;
name: string;
type: string;
required?: boolean;
options?: unknown[];
fields?: ResultField[];
items?: Omit<ResultField, 'key' | 'name'>;
};
export type ResultSchema = {
fields: ResultField[];
};
export type NodeField = {
key: string;
name: string;
type: string;
required: boolean;
options: unknown[];
validation: Record<string, unknown>;
};
export type PresentationField = {
key: string;
label: string;
kind: 'text' | 'image';
value: unknown;
};
export type NodePresentation = {
summary: PresentationField[];
items: Array<{
fields: PresentationField[];
}>;
opening?: {
title: string;
content: string;
};
};
export type KnowledgeCollectionOption = {
value: string;
label: string;
parents?: string[];
suggested?: boolean;
};
export type KnowledgeCollectionStep = {
field_key: string;
name: string;
required: boolean;
multiple: boolean;
from_field?: string;
options: KnowledgeCollectionOption[];
};
export type KnowledgeCollection = {
context_fields: NodeField[];
context_title?: string;
context_hint?: string;
selection_title?: string;
selection_hint?: string;
steps: KnowledgeCollectionStep[];
};
export type NodeView = {
node_key: string;
type: string;
title: string;
content: string;
fields?: NodeField[];
presentation?: NodePresentation;
outputs?: unknown[];
collection?: KnowledgeCollection;
};
export type ScenarioState = {
run_id: number;
external_ref: string;
status: string;
final_result?: Record<string, unknown> | null;
result_schema: ResultSchema;
session_token?: string;
node: NodeView;
outputs: OutputField[];
};
export type SDKEvent = 'ready' | 'node_change' | 'form_submit' | 'finish' | 'error';
export type CreateOptions = {
baseURL?: string;
publicKey: string;
scenarioKey: string;
sopId?: number;
input?: ScenarioInput;
initialValues?: ScenarioInput;
externalRef?: string;
};
export type FinishOptions = {
result?: string;
finalResult?: Record<string, unknown> | null;
};
export declare class SalesScenario {
private readonly options;
private token;
private state?;
private handlers;
constructor(options: CreateOptions);
start(): Promise<ScenarioState>;
getState(): ScenarioState;
getCurrentNode(): Promise<ScenarioState>;
submit(values: ScenarioInput): Promise<ScenarioState>;
next(): Promise<ScenarioState>;
finish(options?: FinishOptions): Promise<ScenarioState>;
reset(): Promise<ScenarioState>;
destroy(): void;
on(event: SDKEvent, handler: (payload: unknown) => void): () => boolean;
private update;
private setState;
private emit;
private request;
}
export declare function create(options: CreateOptions): SalesScenario;

104
sdk/dist/index.iife.js vendored Normal file
View File

@@ -0,0 +1,104 @@
"use strict";
var IqudooSalesScenario = (() => {
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
SalesScenario: () => SalesScenario,
create: () => create
});
var SalesScenario = class {
constructor(options) {
this.options = options;
}
options;
token = "";
state;
handlers = /* @__PURE__ */ new Map();
async start() {
const state = await this.request(`/public/scenarios/${encodeURIComponent(this.options.scenarioKey)}/runs`, { method: "POST", body: JSON.stringify({ public_key: this.options.publicKey, sop_id: this.options.sopId, input: this.options.input || {}, initial_values: this.options.initialValues || {}, external_ref: this.options.externalRef || "" }) });
if (state.session_token) this.token = state.session_token;
this.setState(state);
this.emit("ready", state);
return state;
}
getState() {
if (!this.state) throw new Error("SDK has not started");
return this.state;
}
async getCurrentNode() {
return this.update(`/public/runs/${this.getState().run_id}/current`);
}
async submit(values) {
const state = await this.update(`/public/runs/${this.getState().run_id}/submit`, { method: "POST", body: JSON.stringify({ node_key: this.getState().node.node_key, answers: values }) });
this.emit("form_submit", { values, state });
return state;
}
async next() {
return this.update(`/public/runs/${this.getState().run_id}/next`, { method: "POST" });
}
async finish(options = {}) {
const state = await this.update(`/public/runs/${this.getState().run_id}/finish`, { method: "POST", body: JSON.stringify({ result: options.result || "", final_result: options.finalResult ?? null }) });
this.emit("finish", state);
return state;
}
async reset() {
return this.update(`/public/runs/${this.getState().run_id}/reset`, { method: "POST" });
}
destroy() {
this.handlers.clear();
this.token = "";
this.state = void 0;
}
on(event, handler) {
const handlers = this.handlers.get(event) || /* @__PURE__ */ new Set();
handlers.add(handler);
this.handlers.set(event, handlers);
return () => handlers.delete(handler);
}
async update(path, init = {}) {
const state = await this.request(path, init);
this.setState(state);
return state;
}
setState(state) {
this.state = state;
this.emit("node_change", state.node);
}
emit(event, payload) {
this.handlers.get(event)?.forEach((handler) => handler(payload));
}
async request(path, init) {
try {
const response = await fetch(`${(this.options.baseURL || "").replace(/\/$/, "")}${path}`, { ...init, headers: { "Content-Type": "application/json", ...this.token ? { Authorization: `Bearer ${this.token}` } : {} } });
const body = await response.json();
if (!response.ok) throw new Error(body.message || `Request failed: ${response.status}`);
return body.data || body;
} catch (error) {
this.emit("error", error);
throw error;
}
}
};
function create(options) {
return new SalesScenario(options);
}
return __toCommonJS(index_exports);
})();

35
sdk/dist/index.js vendored Normal file
View File

@@ -0,0 +1,35 @@
export class SalesScenario {
options;
token = '';
state;
handlers = new Map();
constructor(options) {
this.options = options;
}
async start() { const state = await this.request(`/public/scenarios/${encodeURIComponent(this.options.scenarioKey)}/runs`, { method: 'POST', body: JSON.stringify({ public_key: this.options.publicKey, sop_id: this.options.sopId, input: this.options.input || {}, initial_values: this.options.initialValues || {}, external_ref: this.options.externalRef || '' }) }); if (state.session_token)
this.token = state.session_token; this.setState(state); this.emit('ready', state); return state; }
getState() { if (!this.state)
throw new Error('SDK has not started'); return this.state; }
async getCurrentNode() { return this.update(`/public/runs/${this.getState().run_id}/current`); }
async submit(values) { const state = await this.update(`/public/runs/${this.getState().run_id}/submit`, { method: 'POST', body: JSON.stringify({ node_key: this.getState().node.node_key, answers: values }) }); this.emit('form_submit', { values, state }); return state; }
async next() { return this.update(`/public/runs/${this.getState().run_id}/next`, { method: 'POST' }); }
async finish(options = {}) { const state = await this.update(`/public/runs/${this.getState().run_id}/finish`, { method: 'POST', body: JSON.stringify({ result: options.result || '', final_result: options.finalResult ?? null }) }); this.emit('finish', state); return state; }
async reset() { return this.update(`/public/runs/${this.getState().run_id}/reset`, { method: 'POST' }); }
destroy() { this.handlers.clear(); this.token = ''; this.state = undefined; }
on(event, handler) { const handlers = this.handlers.get(event) || new Set(); handlers.add(handler); this.handlers.set(event, handlers); return () => handlers.delete(handler); }
async update(path, init = {}) { const state = await this.request(path, init); this.setState(state); return state; }
setState(state) { this.state = state; this.emit('node_change', state.node); }
emit(event, payload) { this.handlers.get(event)?.forEach(handler => handler(payload)); }
async request(path, init) { try {
const response = await fetch(`${(this.options.baseURL || '').replace(/\/$/, '')}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}) } });
const body = await response.json();
if (!response.ok)
throw new Error(body.message || `Request failed: ${response.status}`);
return (body.data || body);
}
catch (error) {
this.emit('error', error);
throw error;
} }
}
export function create(options) { return new SalesScenario(options); }

514
sdk/package-lock.json generated Normal file
View File

@@ -0,0 +1,514 @@
{
"name": "@iqudoo/sales-scenario-sdk",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@iqudoo/sales-scenario-sdk",
"version": "0.1.0",
"devDependencies": {
"esbuild": "^0.28.2",
"typescript": "5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

23
sdk/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@iqudoo/sales-scenario-sdk",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --format=iife --global-name=IqudooSalesScenario --outfile=dist/index.iife.js",
"build:esm": "tsc -p tsconfig.json",
"build:iife": "esbuild src/index.ts --bundle --format=iife --global-name=IqudooSalesScenario --outfile=dist/index.iife.js"
},
"devDependencies": {
"esbuild": "^0.28.2",
"typescript": "5.9.3"
}
}

37
sdk/src/index.ts Normal file
View File

@@ -0,0 +1,37 @@
export type ScenarioInput = Record<string, unknown>
export type OutputField = { key:string; name:string; type:string; source:string; value:unknown }
export type ResultField = { key:string; name:string; type:string; required?:boolean; options?:unknown[]; fields?:ResultField[]; items?:Omit<ResultField,'key'|'name'> }
export type ResultSchema = { fields:ResultField[] }
export type NodeField = { key:string; name:string; type:string; required:boolean; options:unknown[]; validation:Record<string,unknown> }
export type PresentationField = { key:string; label:string; kind:'text'|'image'; value:unknown }
export type NodePresentation = { summary:PresentationField[]; items:Array<{fields:PresentationField[]}>; opening?:{title:string; content:string} }
export type KnowledgeCollectionOption = { value:string; label:string; parents?:string[]; suggested?:boolean }
export type KnowledgeCollectionStep = { field_key:string; name:string; required:boolean; multiple:boolean; from_field?:string; options:KnowledgeCollectionOption[] }
export type KnowledgeCollection = { context_fields:NodeField[]; context_title?:string; context_hint?:string; selection_title?:string; selection_hint?:string; steps:KnowledgeCollectionStep[] }
export type NodeView = { node_key:string; type:string; title:string; content:string; fields?:NodeField[]; presentation?:NodePresentation; outputs?:unknown[]; collection?:KnowledgeCollection }
export type ScenarioState = { run_id:number; external_ref:string; status:string; final_result?:Record<string, unknown>|null; result_schema:ResultSchema; session_token?:string; node:NodeView; outputs:OutputField[] }
export type SDKEvent = 'ready'|'node_change'|'form_submit'|'finish'|'error'
export type CreateOptions = { baseURL?:string; publicKey:string; scenarioKey:string; sopId?:number; input?:ScenarioInput; initialValues?:ScenarioInput; externalRef?:string }
export type FinishOptions = { result?:string; finalResult?:Record<string, unknown>|null }
export class SalesScenario {
private token=''
private state?:ScenarioState
private handlers=new Map<SDKEvent,Set<(payload:unknown)=>void>>()
constructor(private readonly options:CreateOptions){}
async start(){const state=await this.request<ScenarioState>(`/public/scenarios/${encodeURIComponent(this.options.scenarioKey)}/runs`,{method:'POST',body:JSON.stringify({public_key:this.options.publicKey,sop_id:this.options.sopId,input:this.options.input||{},initial_values:this.options.initialValues||{},external_ref:this.options.externalRef||''})});if(state.session_token)this.token=state.session_token;this.setState(state);this.emit('ready',state);return state}
getState(){if(!this.state)throw new Error('SDK has not started');return this.state}
async getCurrentNode(){return this.update(`/public/runs/${this.getState().run_id}/current`)}
async submit(values:ScenarioInput){const state=await this.update(`/public/runs/${this.getState().run_id}/submit`,{method:'POST',body:JSON.stringify({node_key:this.getState().node.node_key,answers:values})});this.emit('form_submit',{values,state});return state}
async next(){return this.update(`/public/runs/${this.getState().run_id}/next`,{method:'POST'})}
async finish(options:FinishOptions={}){const state=await this.update(`/public/runs/${this.getState().run_id}/finish`,{method:'POST',body:JSON.stringify({result:options.result||'',final_result:options.finalResult??null})});this.emit('finish',state);return state}
async reset(){return this.update(`/public/runs/${this.getState().run_id}/reset`,{method:'POST'})}
destroy(){this.handlers.clear();this.token='';this.state=undefined}
on(event:SDKEvent,handler:(payload:unknown)=>void){const handlers=this.handlers.get(event)||new Set();handlers.add(handler);this.handlers.set(event,handlers);return()=>handlers.delete(handler)}
private async update(path:string,init:RequestInit={}){const state=await this.request<ScenarioState>(path,init);this.setState(state);return state}
private setState(state:ScenarioState){this.state=state;this.emit('node_change',state.node)}
private emit(event:SDKEvent,payload:unknown){this.handlers.get(event)?.forEach(handler=>handler(payload))}
private async request<T>(path:string,init:RequestInit){try{const response=await fetch(`${(this.options.baseURL||'').replace(/\/$/,'')}${path}`,{...init,headers:{'Content-Type':'application/json',...(this.token?{Authorization:`Bearer ${this.token}`}:{})}});const body=await response.json();if(!response.ok)throw new Error(body.message||`Request failed: ${response.status}`);return (body.data||body) as T}catch(error){this.emit('error',error);throw error}}
}
export function create(options:CreateOptions){return new SalesScenario(options)}

13
sdk/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*.ts"]
}

View File

@@ -1 +0,0 @@
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zm-260 72h96v209.9L621.5 312 572 347.4V136zm220 752H232V136h280v296.9c0 3.3 1 6.6 3 9.3a15.9 15.9 0 0022.3 3.7l83.8-59.9 81.4 59.4c2.7 2 6 3.1 9.4 3.1 8.8 0 16-7.2 16-16V136h64v752z`}}]},name:`book`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`BookOutlined`,a.inheritAttrs=!1;export{a as t};

View File

@@ -0,0 +1 @@
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z`}}]},name:`copy`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`CopyOutlined`,a.inheritAttrs=!1;export{a as t};

View File

@@ -1 +0,0 @@
.metric-strip[data-v-81958fcc]{grid-template-columns:repeat(4,1fr);display:grid;overflow:hidden}.metric[data-v-81958fcc]{border-right:1px solid var(--line);align-items:center;gap:16px;min-height:116px;padding:22px;display:flex}.metric[data-v-81958fcc]:last-child{border-right:0}.metric-icon[data-v-81958fcc],.completion[data-v-81958fcc]{border-radius:5px;place-items:center;width:42px;height:42px;font-size:19px;display:grid}.metric-icon.green[data-v-81958fcc]{color:#16775b;background:#e5f3ee}.metric-icon.coral[data-v-81958fcc]{color:#c4573f;background:#faece7}.metric-icon.amber[data-v-81958fcc]{color:#a86e1e;background:#f8efdf}.completion[data-v-81958fcc]{color:#202825;background:#e8ecea;font-size:12px;font-weight:800}.metric div[data-v-81958fcc]{flex-direction:column;display:flex}.metric b[data-v-81958fcc]{font-family:Noto Serif SC,serif;font-size:28px;line-height:1}.metric small[data-v-81958fcc]{color:var(--muted);margin-top:8px}.work-grid[data-v-81958fcc]{grid-template-columns:1.05fr .95fr;gap:18px;margin-top:18px;display:grid}.action-panel[data-v-81958fcc],.doctrine-panel[data-v-81958fcc]{padding:22px}.panel-kicker[data-v-81958fcc]{color:#7c8883;text-transform:uppercase;margin-bottom:14px;font-size:11px;font-weight:700}.action-panel button[data-v-81958fcc]{width:100%;color:var(--ink);text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:0 0;justify-content:space-between;align-items:center;padding:15px 0;display:flex}.action-panel button[data-v-81958fcc]:last-child{border-bottom:0}.action-panel button[data-v-81958fcc]:hover{color:var(--green)}.action-panel button span[data-v-81958fcc]{flex-direction:column;font-weight:650;display:flex}.action-panel button small[data-v-81958fcc]{color:var(--muted);margin-top:4px;font-weight:400}.doctrine-panel[data-v-81958fcc]{color:#fff;background:#202825;border-color:#202825}.doctrine-panel .panel-kicker[data-v-81958fcc]{color:#89a099}blockquote[data-v-81958fcc]{margin:30px 0 42px;font-family:Noto Serif SC,serif;font-size:22px;line-height:1.65}.flow-note[data-v-81958fcc]{color:#9cafaa;align-items:center;font-size:12px;display:flex}.flow-note i[data-v-81958fcc]{background:#4a5a54;flex:1;height:1px;margin:0 10px}@media (width<=900px){.metric-strip[data-v-81958fcc]{grid-template-columns:repeat(2,1fr)}.metric[data-v-81958fcc]:nth-child(2){border-right:0}.metric[data-v-81958fcc]:nth-child(-n+2){border-bottom:1px solid var(--line)}.work-grid[data-v-81958fcc]{grid-template-columns:1fr}}@media (width<=520px){.metric[data-v-81958fcc]{min-height:100px;padding:16px}.metric b[data-v-81958fcc]{font-size:24px}.metric-icon[data-v-81958fcc],.completion[data-v-81958fcc]{display:none}}

View File

@@ -0,0 +1 @@
.metric-strip[data-v-46574c4c]{grid-template-columns:repeat(4,1fr);display:grid;overflow:hidden}.metric[data-v-46574c4c]{border-right:1px solid var(--line);align-items:center;gap:16px;min-height:116px;padding:22px;display:flex}.metric[data-v-46574c4c]:last-child{border-right:0}.metric-icon[data-v-46574c4c],.completion[data-v-46574c4c]{border-radius:5px;place-items:center;width:42px;height:42px;font-size:19px;display:grid}.metric-icon.green[data-v-46574c4c]{color:#16775b;background:#e5f3ee}.metric-icon.coral[data-v-46574c4c]{color:#c4573f;background:#faece7}.metric-icon.amber[data-v-46574c4c]{color:#a86e1e;background:#f8efdf}.completion[data-v-46574c4c]{color:#202825;background:#e8ecea;font-size:12px;font-weight:800}.metric div[data-v-46574c4c]{flex-direction:column;display:flex}.metric b[data-v-46574c4c]{font-family:Noto Serif SC,serif;font-size:28px;line-height:1}.metric small[data-v-46574c4c]{color:var(--muted);margin-top:8px}.work-grid[data-v-46574c4c]{grid-template-columns:1.05fr .95fr;gap:18px;margin-top:18px;display:grid}.action-panel[data-v-46574c4c],.doctrine-panel[data-v-46574c4c]{padding:22px}.panel-kicker[data-v-46574c4c]{color:#7c8883;text-transform:uppercase;margin-bottom:14px;font-size:11px;font-weight:700}.action-panel button[data-v-46574c4c]{width:100%;color:var(--ink);text-align:left;border:0;border-bottom:1px solid var(--line);cursor:pointer;background:0 0;justify-content:space-between;align-items:center;padding:15px 0;display:flex}.action-panel button[data-v-46574c4c]:last-child{border-bottom:0}.action-panel button[data-v-46574c4c]:hover{color:var(--green)}.action-panel button span[data-v-46574c4c]{flex-direction:column;font-weight:650;display:flex}.action-panel button small[data-v-46574c4c]{color:var(--muted);margin-top:4px;font-weight:400}.doctrine-panel[data-v-46574c4c]{color:#fff;background:#202825;border-color:#202825}.doctrine-panel .panel-kicker[data-v-46574c4c]{color:#89a099}blockquote[data-v-46574c4c]{margin:30px 0 42px;font-family:Noto Serif SC,serif;font-size:22px;line-height:1.65}.flow-note[data-v-46574c4c]{color:#9cafaa;align-items:center;font-size:12px;display:flex}.flow-note i[data-v-46574c4c]{background:#4a5a54;flex:1;height:1px;margin:0 10px}@media (width<=900px){.metric-strip[data-v-46574c4c]{grid-template-columns:repeat(2,1fr)}.metric[data-v-46574c4c]:nth-child(2){border-right:0}.metric[data-v-46574c4c]:nth-child(-n+2){border-bottom:1px solid var(--line)}.work-grid[data-v-46574c4c]{grid-template-columns:1fr}}@media (width<=520px){.metric[data-v-46574c4c]{min-height:100px;padding:16px}.metric b[data-v-46574c4c]{font-size:24px}.metric-icon[data-v-46574c4c],.completion[data-v-46574c4c]{display:none}}

View File

@@ -1 +0,0 @@
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,i as l,j as u,lt as d,r as f,t as p,tt as m,vt as h}from"./client-Bk7n1O1W.js";import{t as g}from"./auth-D6Tj4g1X.js";import{t as _}from"./PlusOutlined-CosM7zFe.js";import{n as v,t as y}from"./AppstoreOutlined-BD2QJkU0.js";import{t as b}from"./PlayCircleOutlined-DjeS67m_.js";import{h as x}from"./useApi-CROJJdhE-C8LJOf_8.js";var S={class:`page-shell`},C={class:`page-heading`},w={key:0,class:`page-actions`},T={class:`metric-strip surface`},E={class:`metric`},D={class:`metric-icon green`},O={class:`metric`},k={class:`metric-icon coral`},A={class:`metric`},j={class:`metric-icon amber`},M={class:`metric`},N={class:`completion`},P={class:`work-grid`},F={class:`surface action-panel`},I=f(r({__name:`DashboardView`,setup(r){let f=x(),I=g(),L=h(!0),R=h({scenarios:0,published_sops:0,runs:0,completed_runs:0});return c(async()=>{try{R.value=await p.get(`/dashboard/summary`)}finally{L.value=!1}}),(r,c)=>{let p=m(`a-button`),h=m(`a-skeleton`);return o(),u(`div`,S,[i(`div`,C,[c[7]||=i(`div`,null,[i(`h1`,null,`今天从经验开始`),i(`p`,null,`查看知识沉淀和一线执行的最新状态。`)],-1),s(I).can(`scenario.edit`)?(o(),u(`div`,w,[n(p,{type:`primary`,onClick:c[0]||=e=>s(f).push(`/scenarios`)},{default:d(()=>[n(s(_)),c[6]||=a(`创建场景`,-1)]),_:1})])):e(``,!0)]),n(h,{loading:L.value,active:``},{default:d(()=>[i(`section`,T,[i(`div`,E,[i(`span`,D,[n(s(y))]),i(`div`,null,[i(`b`,null,t(R.value.scenarios),1),c[8]||=i(`small`,null,`可用场景`,-1)])]),i(`div`,O,[i(`span`,k,[n(s(l))]),i(`div`,null,[i(`b`,null,t(R.value.published_sops),1),c[9]||=i(`small`,null,`已发布 SOP`,-1)])]),i(`div`,A,[i(`span`,j,[n(s(b))]),i(`div`,null,[i(`b`,null,t(R.value.runs),1),c[10]||=i(`small`,null,`累计执行`,-1)])]),i(`div`,M,[i(`span`,N,t(R.value.runs?Math.round(R.value.completed_runs/R.value.runs*100):0)+`%`,1),i(`div`,null,[i(`b`,null,t(R.value.completed_runs),1),c[11]||=i(`small`,null,`完成执行`,-1)])])])]),_:1},8,[`loading`]),i(`section`,P,[i(`div`,F,[c[17]||=i(`div`,{class:`panel-kicker`},`常用入口`,-1),s(I).can(`scenario.edit`)?(o(),u(`button`,{key:0,onClick:c[1]||=e=>s(f).push(`/scenarios`)},[c[12]||=i(`span`,null,[a(`配置新的业务场景`),i(`small`,null,`定义字段、目标和触发条件`)],-1),n(s(v))])):e(``,!0),s(I).can(`sop.review`)?(o(),u(`button`,{key:1,onClick:c[2]||=e=>s(f).push(`/reviews`)},[c[13]||=i(`span`,null,[a(`处理待审核 SOP`),i(`small`,null,`核对流程、口径和风险出口`)],-1),n(s(v))])):e(``,!0),s(I).can(`sop.execute`)?(o(),u(`button`,{key:2,onClick:c[3]||=e=>s(f).push(`/execute`)},[c[14]||=i(`span`,null,[a(`开始执行已发布 SOP`),i(`small`,null,`根据客户回答逐步推进`)],-1),n(s(v))])):e(``,!0),s(I).can(`knowledge.edit`)?(o(),u(`button`,{key:3,onClick:c[4]||=e=>s(f).push(`/knowledge`)},[c[15]||=i(`span`,null,[a(`维护审核知识卡`),i(`small`,null,`统一口径与风险提示`)],-1),n(s(v))])):e(``,!0),s(I).can(`runs.view_all`)||s(I).can(`runs.view_own`)?(o(),u(`button`,{key:4,onClick:c[5]||=e=>s(f).push(`/runs`)},[c[16]||=i(`span`,null,[a(`复盘执行记录`),i(`small`,null,`查看节点轨迹、回答与反馈`)],-1),n(s(v))])):e(``,!0)]),c[18]||=i(`div`,{class:`surface doctrine-panel`},[i(`div`,{class:`panel-kicker`},`平台原则`),i(`blockquote`,null,`一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。`),i(`div`,{class:`flow-note`},[i(`span`,null,`创建`),i(`i`),i(`span`,null,`发布`),i(`i`),i(`span`,null,`执行`),i(`i`),i(`span`,null,`复盘`)])],-1)])])}}}),[[`__scopeId`,`data-v-81958fcc`]]);export{I as default};

View File

@@ -0,0 +1 @@
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,i as l,j as u,lt as d,r as f,t as p,tt as m,vt as h}from"./client-Bk7n1O1W.js";import{t as g}from"./auth-D6Tj4g1X.js";import{t as _}from"./PlusOutlined-CosM7zFe.js";import{n as v,t as y}from"./AppstoreOutlined-BD2QJkU0.js";import{t as b}from"./PlayCircleOutlined-DjeS67m_.js";import{h as x}from"./useApi-CROJJdhE-C8LJOf_8.js";var S={class:`page-shell`},C={class:`page-heading`},w={key:0,class:`page-actions`},T={class:`metric-strip surface`},E={class:`metric`},D={class:`metric-icon green`},O={class:`metric`},k={class:`metric-icon coral`},A={class:`metric`},j={class:`metric-icon amber`},M={class:`metric`},N={class:`completion`},P={class:`work-grid`},F={class:`surface action-panel`},I=f(r({__name:`DashboardView`,setup(r){let f=x(),I=g(),L=h(!0),R=h({scenarios:0,published_sops:0,runs:0,completed_runs:0});return c(async()=>{try{R.value=await p.get(`/dashboard/summary`)}finally{L.value=!1}}),(r,c)=>{let p=m(`a-button`),h=m(`a-skeleton`);return o(),u(`div`,S,[i(`div`,C,[c[6]||=i(`div`,null,[i(`h1`,null,`今天从经验开始`),i(`p`,null,`查看知识沉淀和一线执行的最新状态。`)],-1),s(I).can(`scenario.edit`)?(o(),u(`div`,w,[n(p,{type:`primary`,onClick:c[0]||=e=>s(f).push(`/scenarios`)},{default:d(()=>[n(s(_)),c[5]||=a(`创建场景`,-1)]),_:1})])):e(``,!0)]),n(h,{loading:L.value,active:``},{default:d(()=>[i(`section`,T,[i(`div`,E,[i(`span`,D,[n(s(y))]),i(`div`,null,[i(`b`,null,t(R.value.scenarios),1),c[7]||=i(`small`,null,`可用场景`,-1)])]),i(`div`,O,[i(`span`,k,[n(s(l))]),i(`div`,null,[i(`b`,null,t(R.value.published_sops),1),c[8]||=i(`small`,null,`可用 SOP`,-1)])]),i(`div`,A,[i(`span`,j,[n(s(b))]),i(`div`,null,[i(`b`,null,t(R.value.runs),1),c[9]||=i(`small`,null,`累计执行`,-1)])]),i(`div`,M,[i(`span`,N,t(R.value.runs?Math.round(R.value.completed_runs/R.value.runs*100):0)+`%`,1),i(`div`,null,[i(`b`,null,t(R.value.completed_runs),1),c[10]||=i(`small`,null,`完成执行`,-1)])])])]),_:1},8,[`loading`]),i(`section`,P,[i(`div`,F,[c[15]||=i(`div`,{class:`panel-kicker`},`常用入口`,-1),s(I).can(`scenario.edit`)?(o(),u(`button`,{key:0,onClick:c[1]||=e=>s(f).push(`/scenarios`)},[c[11]||=i(`span`,null,[a(`配置新的业务场景`),i(`small`,null,`定义字段、目标和触发条件`)],-1),n(s(v))])):e(``,!0),s(I).can(`sop.execute`)?(o(),u(`button`,{key:1,onClick:c[2]||=e=>s(f).push(`/execute`)},[c[12]||=i(`span`,null,[a(`开始执行 SOP`),i(`small`,null,`根据客户回答逐步推进`)],-1),n(s(v))])):e(``,!0),s(I).can(`knowledge.edit`)?(o(),u(`button`,{key:2,onClick:c[3]||=e=>s(f).push(`/scenarios`)},[c[13]||=i(`span`,null,[a(`配置场景知识卡`),i(`small`,null,`在所属场景内维护输入关联与话术`)],-1),n(s(v))])):e(``,!0),s(I).can(`runs.view_all`)||s(I).can(`runs.view_own`)?(o(),u(`button`,{key:3,onClick:c[4]||=e=>s(f).push(`/runs`)},[c[14]||=i(`span`,null,[a(`复盘执行记录`),i(`small`,null,`查看节点轨迹、回答与反馈`)],-1),n(s(v))])):e(``,!0)]),c[16]||=i(`div`,{class:`surface doctrine-panel`},[i(`div`,{class:`panel-kicker`},`平台原则`),i(`blockquote`,null,`一句好话术不应只被收藏,它应该知道何时出现、下一步去哪,以及是否真的有效。`),i(`div`,{class:`flow-note`},[i(`span`,null,`创建`),i(`i`),i(`span`,null,`配置`),i(`i`),i(`span`,null,`执行`),i(`i`),i(`span`,null,`复盘`)])],-1)])])}}}),[[`__scopeId`,`data-v-46574c4c`]]);export{I as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.execution-shell[data-v-937f7610]{max-width:1220px}.sop-catalog[data-v-937f7610]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-937f7610]{text-align:left;cursor:pointer;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;min-height:150px;padding:22px;display:grid}.sop-entry[data-v-937f7610]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-937f7610]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-937f7610]{color:var(--green);font-weight:650}.sop-entry h2[data-v-937f7610]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-937f7610]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-937f7610]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-937f7610]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-937f7610]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-937f7610]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-937f7610]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-937f7610]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-937f7610]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-937f7610]{color:#9eada7;font-size:12px}.privacy-note[data-v-937f7610]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-937f7610]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-937f7610]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-937f7610]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-937f7610]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-937f7610]{padding:44px 0 26px}.node-content small[data-v-937f7610]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-937f7610]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-937f7610]{color:#29342f;border-left:3px solid var(--green);background:#f2f6f4;margin:0;padding:18px 20px;font-size:17px;line-height:1.8}.answer-form[data-v-937f7610]{max-width:680px}.choice-group[data-v-937f7610]{flex-wrap:wrap;display:flex}.run-actions[data-v-937f7610]{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:16px;margin:24px -34px -30px;padding:18px 34px;font-size:12px;display:flex}.run-actions>div[data-v-937f7610]{align-items:center;gap:9px;display:flex}.completed-state[data-v-937f7610]{text-align:center;padding:40px 0}.completed-state>span[data-v-937f7610]{color:var(--green);font-size:50px}.completed-state h3[data-v-937f7610]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-937f7610]{color:var(--muted);margin:0 0 24px}.quick-feedback[data-v-937f7610]{text-align:left;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:#f5f8f6;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;max-width:620px;margin:0 auto 18px;padding:16px;display:grid}.quick-feedback>span[data-v-937f7610]{font-size:12px;font-weight:650}.quick-feedback[data-v-937f7610] .ant-rate{font-size:18px}.quick-feedback[data-v-937f7610] .ant-input{grid-column:1/3}.feedback-thanks[data-v-937f7610]{max-width:520px;color:var(--green);background:#edf6f2;margin:0 auto 18px;padding:12px;font-size:12px}.knowledge-snapshot[data-v-937f7610]{border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin:0 0 24px}.knowledge-head[data-v-937f7610]{justify-content:space-between;align-items:center;padding:12px 0;display:flex}.knowledge-head span[data-v-937f7610]{color:var(--muted);font-size:11px}.standard-copy[data-v-937f7610],.knowledge-note[data-v-937f7610]{border-left:3px solid var(--green);background:#f2f7f5;padding:14px 16px}.standard-copy small[data-v-937f7610],.knowledge-note small[data-v-937f7610]{font-size:10px;font-weight:750}.standard-copy p[data-v-937f7610],.knowledge-note p[data-v-937f7610]{margin:6px 0 0;line-height:1.7}.knowledge-note[data-v-937f7610]{background:#fbf3f0;border-left-color:#c4573f;margin-top:8px}.knowledge-note.risk[data-v-937f7610]{background:#fbf6eb;border-left-color:#bd7b24}@media (width<=800px){.sop-catalog[data-v-937f7610],.run-workspace[data-v-937f7610]{grid-template-columns:1fr}.run-context[data-v-937f7610]{position:static}.conversation[data-v-937f7610]{padding:22px}.run-actions[data-v-937f7610]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-937f7610]{display:none}.quick-feedback[data-v-937f7610]{grid-template-columns:1fr}.quick-feedback[data-v-937f7610] .ant-input{grid-column:auto}}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 00-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z`}}]},name:`check`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`CheckOutlined`,a.inheritAttrs=!1;var o={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`}},{tag:`path`,attrs:{d:`M686.7 638.6L544.1 535.5V288c0-4.4-3.6-8-8-8H488c-4.4 0-8 3.6-8 8v275.4c0 2.6 1.2 5 3.3 6.5l165.4 120.6c3.6 2.6 8.6 1.8 11.2-1.7l28.6-39c2.6-3.7 1.8-8.7-1.8-11.2z`}}]},name:`clock-circle`,theme:`outlined`};function s(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){c(e,t,n[t])})}return e}function c(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var l=function(n,r){var i=s({},n,r.attrs);return e(t,s({},i,{icon:o}),null)};l.displayName=`ClockCircleOutlined`,l.inheritAttrs=!1;var u={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M942.2 486.2C847.4 286.5 704.1 186 512 186c-192.2 0-335.4 100.5-430.2 300.3a60.3 60.3 0 000 51.5C176.6 737.5 319.9 838 512 838c192.2 0 335.4-100.5 430.2-300.3 7.7-16.2 7.7-35 0-51.5zM512 766c-161.3 0-279.4-81.8-362.7-254C232.6 339.8 350.7 258 512 258c161.3 0 279.4 81.8 362.7 254C791.5 684.2 673.4 766 512 766zm-4-430c-97.2 0-176 78.8-176 176s78.8 176 176 176 176-78.8 176-176-78.8-176-176-176zm0 288c-61.9 0-112-50.1-112-112s50.1-112 112-112 112 50.1 112 112-50.1 112-112 112z`}}]},name:`eye`,theme:`outlined`};function d(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){f(e,t,n[t])})}return e}function f(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var p=function(n,r){var i=d({},n,r.attrs);return e(t,d({},i,{icon:u}),null)};p.displayName=`EyeOutlined`,p.inheritAttrs=!1;export{l as n,a as r,p as t};

Some files were not shown because too many files have changed in this diff Show More