feat: complete pet SOP governance workflow
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"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"
|
||||
@@ -47,20 +48,20 @@ var fieldKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
var items []model.Scenario
|
||||
query := h.db.Where("tenant_id = ? AND status <> ?", p.TenantID, "archived")
|
||||
items := make([]model.Scenario, 0)
|
||||
query := access.ScopeScenarios(h.db.Model(&model.Scenario{}), p, "scenarios").Where("scenarios.status <> ?", "archived")
|
||||
if keyword := c.Query("keyword"); keyword != "" {
|
||||
query = query.Where("name LIKE ? OR industry LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
query = query.Where("scenarios.name LIKE ? OR scenarios.industry LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
if status := c.Query("status"); status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Model(&model.Scenario{}).Count(&total).Error; err != nil {
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败")
|
||||
return
|
||||
}
|
||||
if err := query.Order("updated_at DESC").Find(&items).Error; err != nil {
|
||||
if err := query.Order("scenarios.updated_at DESC").Find(&items).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败")
|
||||
return
|
||||
}
|
||||
@@ -93,15 +94,21 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !access.CanViewScenario(h.db, p, id) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||
return
|
||||
}
|
||||
var item model.Scenario
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item).Error; err != nil {
|
||||
notFound(c, err, "场景不存在")
|
||||
return
|
||||
}
|
||||
var fields []model.ScenarioField
|
||||
var sops []model.SOP
|
||||
fields := make([]model.ScenarioField, 0)
|
||||
sops := make([]model.SOP, 0)
|
||||
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("sort_order, id").Find(&fields)
|
||||
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops)
|
||||
if auth.HasPermission(p, "sop.view") {
|
||||
h.db.Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Order("updated_at DESC").Find(&sops)
|
||||
}
|
||||
response.OK(c, gin.H{"scenario": item, "fields": fields, "sops": sops})
|
||||
}
|
||||
|
||||
@@ -111,12 +118,20 @@ func (h *Handler) Update(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !access.CanEditScenario(h.db, p, id) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
||||
return
|
||||
}
|
||||
var input scenarioInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "场景信息不完整")
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{"name": input.Name, "industry": input.Industry, "role_name": input.RoleName, "goal": input.Goal, "trigger_text": input.TriggerText, "visibility": input.Visibility}
|
||||
visibility := input.Visibility
|
||||
if visibility == "" {
|
||||
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}
|
||||
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, p.TenantID, "archived").Updates(updates)
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||
@@ -132,8 +147,26 @@ func (h *Handler) Archive(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result := h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived")
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
if !access.CanEditScenario(h.db, p, id) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可归档")
|
||||
return
|
||||
}
|
||||
var activeSOPs int64
|
||||
if err := h.db.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"published", "reviewing"}).Count(&activeSOPs).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "检查场景状态失败")
|
||||
return
|
||||
}
|
||||
if activeSOPs > 0 {
|
||||
response.Error(c, http.StatusConflict, "SCENARIO_IN_USE", "请先下线已发布 SOP 或处理审核任务")
|
||||
return
|
||||
}
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Update("status", "archived").Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||
return
|
||||
}
|
||||
@@ -144,7 +177,7 @@ func (h *Handler) Archive(c *gin.Context) {
|
||||
func (h *Handler) CreateField(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
scenarioID, ok := idParam(c, "id")
|
||||
if !ok || !h.scenarioExists(p.TenantID, scenarioID) {
|
||||
if !ok || !access.CanEditScenario(h.db, p, scenarioID) {
|
||||
if ok {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||
}
|
||||
@@ -174,6 +207,15 @@ func (h *Handler) UpdateField(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var existing model.ScenarioField
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&existing).Error; err != nil || !access.CanEditScenario(h.db, p, existing.ScenarioID) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在或不可编辑")
|
||||
return
|
||||
}
|
||||
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
|
||||
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能修改;请新增字段并创建 SOP 新版本")
|
||||
return
|
||||
}
|
||||
var input fieldInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
|
||||
@@ -201,6 +243,15 @@ func (h *Handler) DeleteField(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var existing model.ScenarioField
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&existing).Error; err != nil || !access.CanEditScenario(h.db, p, existing.ScenarioID) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在或不可删除")
|
||||
return
|
||||
}
|
||||
if h.fieldReferencedByReleasedSOP(existing.ScenarioID, existing.FieldKey, p.TenantID) {
|
||||
response.Error(c, http.StatusConflict, "FIELD_IN_USE", "字段已被发布版本引用,不能删除")
|
||||
return
|
||||
}
|
||||
result := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).Delete(&model.ScenarioField{})
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
|
||||
@@ -210,12 +261,6 @@ func (h *Handler) DeleteField(c *gin.Context) {
|
||||
response.OK(c, gin.H{"id": id})
|
||||
}
|
||||
|
||||
func (h *Handler) scenarioExists(tenantID, id uint64) bool {
|
||||
var count int64
|
||||
h.db.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ? AND status <> ?", id, tenantID, "archived").Count(&count)
|
||||
return count == 1
|
||||
}
|
||||
|
||||
func normalizedJSON(value json.RawMessage, fallback string) datatypes.JSON {
|
||||
if len(value) == 0 || !json.Valid(value) {
|
||||
return datatypes.JSON([]byte(fallback))
|
||||
|
||||
63
internal/scenario/references.go
Normal file
63
internal/scenario/references.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
)
|
||||
|
||||
func (h *Handler) fieldReferencedByReleasedSOP(scenarioID uint64, fieldKey string, tenantID uint64) bool {
|
||||
statuses := []string{"published", "offline", "superseded"}
|
||||
var nodes []model.SOPNode
|
||||
err := h.db.Table("sop_nodes n").Select("n.*").Joins("JOIN sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where("s.scenario_id = ? AND n.tenant_id = ? AND sv.status IN ?", scenarioID, tenantID, statuses).Scan(&nodes).Error
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if jsonReferencesField(node.Config, fieldKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
var edges []model.SOPEdge
|
||||
err = h.db.Table("sop_edges e").Select("e.*").Joins("JOIN sop_versions sv ON sv.id = e.sop_version_id").Joins("JOIN sops s ON s.id = sv.sop_id").Where("s.scenario_id = ? AND e.tenant_id = ? AND sv.status IN ?", scenarioID, tenantID, statuses).Scan(&edges).Error
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, edge := range edges {
|
||||
if jsonReferencesField(edge.Condition, fieldKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func jsonReferencesField(raw []byte, fieldKey string) bool {
|
||||
var value interface{}
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return true
|
||||
}
|
||||
return valueReferencesField(value, fieldKey)
|
||||
}
|
||||
|
||||
func valueReferencesField(value interface{}, fieldKey string) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for key, child := range typed {
|
||||
if key == "field_key" || key == "field" {
|
||||
if child == fieldKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if valueReferencesField(child, fieldKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, child := range typed {
|
||||
if child == fieldKey || valueReferencesField(child, fieldKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
24
internal/scenario/references_test.go
Normal file
24
internal/scenario/references_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package scenario
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestJSONReferencesField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{name: "question", raw: `{"field_key":"pet_name"}`, key: "pet_name", want: true},
|
||||
{name: "form", raw: `{"field_keys":["pet_name","pet_type"]}`, key: "pet_type", want: true},
|
||||
{name: "nested condition", raw: `{"any":[{"field":"emergency","operator":"equals","value":true}]}`, key: "emergency", want: true},
|
||||
{name: "not referenced", raw: `{"field_key":"pet_name"}`, key: "pet_type", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := jsonReferencesField([]byte(test.raw), test.key); got != test.want {
|
||||
t.Fatalf("jsonReferencesField() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user