feat: complete pet SOP governance workflow
This commit is contained in:
51
internal/access/scenario.go
Normal file
51
internal/access/scenario.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ScopeScenarios(query *gorm.DB, principal auth.Principal, alias string) *gorm.DB {
|
||||
if auth.HasPermission(principal, "*") {
|
||||
return query.Where(alias+".tenant_id = ?", principal.TenantID)
|
||||
}
|
||||
teamVisible := auth.HasPermission(principal, "scenario.team_view")
|
||||
return query.Where(
|
||||
alias+".tenant_id = ? AND ("+alias+".visibility = ? OR "+alias+".created_by = ? OR ("+alias+".visibility = ? AND ?))",
|
||||
principal.TenantID, "tenant", principal.UserID, "team", teamVisible,
|
||||
)
|
||||
}
|
||||
|
||||
func CanViewScenario(db *gorm.DB, principal auth.Principal, scenarioID uint64) bool {
|
||||
var count int64
|
||||
query := ScopeScenarios(db.Model(&model.Scenario{}), principal, "scenarios")
|
||||
if err := query.Where("scenarios.id = ? AND scenarios.status <> ?", scenarioID, "archived").Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count == 1
|
||||
}
|
||||
|
||||
func CanEditScenario(db *gorm.DB, principal auth.Principal, scenarioID uint64) bool {
|
||||
if !auth.HasPermission(principal, "scenario.edit") && !auth.HasPermission(principal, "*") {
|
||||
return false
|
||||
}
|
||||
return CanViewScenario(db, principal, scenarioID)
|
||||
}
|
||||
|
||||
func CanViewSOP(db *gorm.DB, principal auth.Principal, sopID uint64) bool {
|
||||
var count int64
|
||||
query := db.Table("sops s").Joins("JOIN scenarios sc ON sc.id = s.scenario_id")
|
||||
query = ScopeScenarios(query, principal, "sc")
|
||||
if err := query.Where("s.id = ? AND s.tenant_id = ? AND s.status <> ?", sopID, principal.TenantID, "archived").Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count == 1
|
||||
}
|
||||
|
||||
func CanEditSOP(db *gorm.DB, principal auth.Principal, sopID uint64) bool {
|
||||
if !auth.HasPermission(principal, "sop.edit") && !auth.HasPermission(principal, "*") {
|
||||
return false
|
||||
}
|
||||
return CanViewSOP(db, principal, sopID)
|
||||
}
|
||||
16
internal/access/scenario_test.go
Normal file
16
internal/access/scenario_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||
)
|
||||
|
||||
func TestScenarioVisibilityPermission(t *testing.T) {
|
||||
if !auth.HasPermission(auth.Principal{Permissions: []string{"scenario.team_view"}}, "scenario.team_view") {
|
||||
t.Fatal("team visibility permission should be recognized")
|
||||
}
|
||||
if auth.HasPermission(auth.Principal{Permissions: []string{"scenario.view"}}, "scenario.team_view") {
|
||||
t.Fatal("tenant view permission must not imply team visibility")
|
||||
}
|
||||
}
|
||||
19
internal/auth/permissions.go
Normal file
19
internal/auth/permissions.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
func HasPermission(principal Principal, permission string) bool {
|
||||
for _, allowed := range principal.Permissions {
|
||||
if allowed == "*" || allowed == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func HasAnyPermission(principal Principal, permissions ...string) bool {
|
||||
for _, permission := range permissions {
|
||||
if HasPermission(principal, permission) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
24
internal/auth/permissions_test.go
Normal file
24
internal/auth/permissions_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasPermission(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permissions []string
|
||||
permission string
|
||||
want bool
|
||||
}{
|
||||
{name: "exact", permissions: []string{"sop.view"}, permission: "sop.view", want: true},
|
||||
{name: "wildcard", permissions: []string{"*"}, permission: "sop.publish", want: true},
|
||||
{name: "denied", permissions: []string{"sop.view"}, permission: "sop.publish", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
principal := Principal{Permissions: test.permissions}
|
||||
if got := HasPermission(principal, test.permission); got != test.want {
|
||||
t.Fatalf("HasPermission() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -19,11 +20,12 @@ import (
|
||||
var ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
|
||||
type Principal struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
RoleCode string `json:"role_code"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
RoleCode string `json:"role_code"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
@@ -79,7 +81,7 @@ func (s *Service) Refresh(rawToken string) (TokenPair, error) {
|
||||
if err := s.db.First(&user, stored.UserID).Error; err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
principal, err := s.principalForUser(user)
|
||||
principal, err := s.principalForTenant(user, stored.TenantID)
|
||||
if err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
@@ -108,6 +110,18 @@ func (s *Service) ParseAccessToken(rawToken string) (Principal, error) {
|
||||
return Principal{UserID: userID, TenantID: claims.TenantID, RoleCode: claims.RoleCode, Username: claims.Username, DisplayName: claims.DisplayName}, nil
|
||||
}
|
||||
|
||||
func (s *Service) RefreshPrincipal(principal Principal) (Principal, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("id = ? AND status = ?", principal.UserID, "active").First(&user).Error; err != nil {
|
||||
return Principal{}, ErrInvalidCredentials
|
||||
}
|
||||
current, err := s.principalForTenant(user, principal.TenantID)
|
||||
if err != nil {
|
||||
return Principal{}, ErrInvalidCredentials
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (s *Service) issueTokenPair(principal Principal) (TokenPair, error) {
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(s.cfg.AccessTokenTTL)
|
||||
@@ -128,16 +142,29 @@ func (s *Service) issueTokenPair(principal Principal) (TokenPair, error) {
|
||||
}
|
||||
|
||||
func (s *Service) principalForUser(user model.User) (Principal, error) {
|
||||
return s.principalForTenant(user, 0)
|
||||
}
|
||||
|
||||
func (s *Service) principalForTenant(user model.User, tenantID uint64) (Principal, error) {
|
||||
type row struct {
|
||||
TenantID uint64
|
||||
RoleCode string
|
||||
TenantID uint64
|
||||
RoleCode string
|
||||
Permissions datatypes.JSON
|
||||
}
|
||||
var membership row
|
||||
err := s.db.Table("tenant_members tm").Select("tm.tenant_id, r.code AS role_code").Joins("JOIN roles r ON r.id = tm.role_id").Where("tm.user_id = ? AND tm.status = ?", user.ID, "active").First(&membership).Error
|
||||
query := s.db.Table("tenant_members tm").Select("tm.tenant_id, r.code AS role_code, r.permissions").Joins("JOIN roles r ON r.id = tm.role_id").Where("tm.user_id = ? AND tm.status = ?", user.ID, "active")
|
||||
if tenantID != 0 {
|
||||
query = query.Where("tm.tenant_id = ?", tenantID)
|
||||
}
|
||||
err := query.Order("tm.id").First(&membership).Error
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
return Principal{UserID: user.ID, TenantID: membership.TenantID, RoleCode: membership.RoleCode, Username: user.Username, DisplayName: user.DisplayName}, nil
|
||||
var permissions []string
|
||||
if err := json.Unmarshal(membership.Permissions, &permissions); err != nil {
|
||||
return Principal{}, fmt.Errorf("decode role permissions: %w", err)
|
||||
}
|
||||
return Principal{UserID: user.ID, TenantID: membership.TenantID, RoleCode: membership.RoleCode, Permissions: permissions, Username: user.Username, DisplayName: user.DisplayName}, nil
|
||||
}
|
||||
|
||||
func Seed(db *gorm.DB, cfg config.SeedConfig) error {
|
||||
@@ -149,10 +176,23 @@ func Seed(db *gorm.DB, cfg config.SeedConfig) error {
|
||||
if err := tx.Where("slug = ?", "default").FirstOrCreate(&tenant, model.Tenant{Name: "默认企业", Slug: "default", Status: "active"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
permissions := datatypes.JSON([]byte(`["*"]`))
|
||||
var role model.Role
|
||||
if err := tx.Where("tenant_id = ? AND code = ?", tenant.ID, "admin").FirstOrCreate(&role, model.Role{TenantID: tenant.ID, Name: "管理员", Code: "admin", Permissions: permissions}).Error; err != nil {
|
||||
return err
|
||||
roleDefinitions := []struct {
|
||||
Name, Code string
|
||||
Permissions []string
|
||||
}{
|
||||
{Name: "管理员", Code: "admin", Permissions: []string{"*"}},
|
||||
{Name: "SOP 编辑者", Code: "editor", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "scenario.edit", "sop.view", "sop.edit", "sop.submit_review", "knowledge.view", "knowledge.edit", "runs.view_all"}},
|
||||
{Name: "审核者", Code: "reviewer", Permissions: []string{"dashboard.view", "scenario.view", "scenario.team_view", "sop.view", "sop.review", "sop.publish", "knowledge.view", "runs.view_all"}},
|
||||
{Name: "一线执行者", 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))
|
||||
for _, definition := range roleDefinitions {
|
||||
permissions, _ := json.Marshal(definition.Permissions)
|
||||
var role model.Role
|
||||
if err := tx.Where("tenant_id = ? AND code = ?", tenant.ID, definition.Code).Assign(model.Role{Name: definition.Name, Permissions: datatypes.JSON(permissions)}).FirstOrCreate(&role, model.Role{TenantID: tenant.ID, Code: definition.Code}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
roles[definition.Code] = role
|
||||
}
|
||||
var user model.User
|
||||
err := tx.Where("username = ?", cfg.AdminUsername).First(&user).Error
|
||||
@@ -169,7 +209,7 @@ func Seed(db *gorm.DB, cfg config.SeedConfig) error {
|
||||
return err
|
||||
}
|
||||
var member model.TenantMember
|
||||
return tx.Where("tenant_id = ? AND user_id = ?", tenant.ID, user.ID).FirstOrCreate(&member, model.TenantMember{TenantID: tenant.ID, UserID: user.ID, RoleID: role.ID, Status: "active"}).Error
|
||||
return tx.Where("tenant_id = ? AND user_id = ?", tenant.ID, user.ID).FirstOrCreate(&member, model.TenantMember{TenantID: tenant.ID, UserID: user.ID, RoleID: roles["admin"].ID, Status: "active"}).Error
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package dashboard
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
@@ -15,26 +16,46 @@ type Handler struct{ db *gorm.DB }
|
||||
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
|
||||
|
||||
func (h *Handler) Summary(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
principal, _ := auth.PrincipalFromContext(c)
|
||||
counts := map[string]int64{}
|
||||
queries := []struct {
|
||||
key string
|
||||
model interface{}
|
||||
where string
|
||||
args []interface{}
|
||||
}{
|
||||
{key: "scenarios", model: &model.Scenario{}, where: "tenant_id = ? AND status <> ?", args: []interface{}{p.TenantID, "archived"}},
|
||||
{key: "published_sops", model: &model.SOP{}, where: "tenant_id = ? AND status = ?", args: []interface{}{p.TenantID, "published"}},
|
||||
{key: "runs", model: &model.SOPRun{}, where: "tenant_id = ?", args: []interface{}{p.TenantID}},
|
||||
{key: "completed_runs", model: &model.SOPRun{}, where: "tenant_id = ? AND status = ?", args: []interface{}{p.TenantID, "completed"}},
|
||||
|
||||
scenarioQuery := access.ScopeScenarios(h.db.Model(&model.Scenario{}), principal, "scenarios").Where("scenarios.status <> ?", "archived")
|
||||
var scenarios int64
|
||||
if err := scenarioQuery.Count(&scenarios).Error; err != nil {
|
||||
queryFailed(c)
|
||||
return
|
||||
}
|
||||
for _, query := range queries {
|
||||
var count int64
|
||||
if err := h.db.Model(query.model).Where(query.where, query.args...).Count(&count).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询统计数据失败")
|
||||
return
|
||||
}
|
||||
counts[query.key] = count
|
||||
counts["scenarios"] = scenarios
|
||||
|
||||
sopQuery := h.db.Table("sops s").Joins("JOIN scenarios sc ON sc.id = s.scenario_id")
|
||||
sopQuery = access.ScopeScenarios(sopQuery, principal, "sc").Where("s.status = ?", "published")
|
||||
var publishedSOPs int64
|
||||
if err := sopQuery.Count(&publishedSOPs).Error; err != nil {
|
||||
queryFailed(c)
|
||||
return
|
||||
}
|
||||
counts["published_sops"] = publishedSOPs
|
||||
|
||||
runQuery := h.db.Model(&model.SOPRun{}).Where("tenant_id = ?", principal.TenantID)
|
||||
if !auth.HasPermission(principal, "runs.view_all") && !auth.HasPermission(principal, "*") {
|
||||
runQuery = runQuery.Where("operator_id = ?", principal.UserID)
|
||||
}
|
||||
var runs int64
|
||||
if err := runQuery.Count(&runs).Error; err != nil {
|
||||
queryFailed(c)
|
||||
return
|
||||
}
|
||||
counts["runs"] = runs
|
||||
var completedRuns int64
|
||||
if err := runQuery.Where("status = ?", "completed").Count(&completedRuns).Error; err != nil {
|
||||
queryFailed(c)
|
||||
return
|
||||
}
|
||||
counts["completed_runs"] = completedRuns
|
||||
|
||||
response.OK(c, counts)
|
||||
}
|
||||
|
||||
func queryFailed(c *gin.Context) {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询统计数据失败")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/dashboard"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/knowledge"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/member"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/middleware"
|
||||
runhandler "git.iwork-ai.com/xdc/iqudo-top1/internal/run"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/scenario"
|
||||
@@ -31,6 +32,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
||||
sopHandler := sop.NewHandler(db)
|
||||
runHandler := runhandler.NewHandler(db)
|
||||
knowledgeHandler := knowledge.NewHandler(db)
|
||||
memberHandler := member.NewHandler(db)
|
||||
dashboardHandler := dashboard.NewHandler(db)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
@@ -43,39 +45,45 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Authenticate(authService))
|
||||
protected.GET("/auth/me", authHandler.Me)
|
||||
protected.GET("/dashboard/summary", dashboardHandler.Summary)
|
||||
protected.GET("/members", middleware.RequirePermission("member.manage"), memberHandler.List)
|
||||
protected.POST("/members", middleware.RequirePermission("member.manage"), memberHandler.Create)
|
||||
protected.PUT("/members/:id", middleware.RequirePermission("member.manage"), memberHandler.Update)
|
||||
protected.GET("/dashboard/summary", middleware.RequirePermission("dashboard.view"), dashboardHandler.Summary)
|
||||
|
||||
protected.GET("/scenarios", scenarioHandler.List)
|
||||
protected.POST("/scenarios", scenarioHandler.Create)
|
||||
protected.GET("/scenarios/:id", scenarioHandler.Get)
|
||||
protected.PUT("/scenarios/:id", scenarioHandler.Update)
|
||||
protected.DELETE("/scenarios/:id", scenarioHandler.Archive)
|
||||
protected.POST("/scenarios/:id/fields", scenarioHandler.CreateField)
|
||||
protected.PUT("/scenario-fields/:fieldId", scenarioHandler.UpdateField)
|
||||
protected.DELETE("/scenario-fields/:fieldId", scenarioHandler.DeleteField)
|
||||
protected.GET("/scenarios", middleware.RequirePermission("scenario.view"), scenarioHandler.List)
|
||||
protected.POST("/scenarios", middleware.RequirePermission("scenario.edit"), scenarioHandler.Create)
|
||||
protected.GET("/scenarios/:id", middleware.RequirePermission("scenario.view"), scenarioHandler.Get)
|
||||
protected.PUT("/scenarios/:id", middleware.RequirePermission("scenario.edit"), scenarioHandler.Update)
|
||||
protected.DELETE("/scenarios/:id", middleware.RequirePermission("scenario.edit"), scenarioHandler.Archive)
|
||||
protected.POST("/scenarios/:id/fields", middleware.RequirePermission("scenario.edit"), scenarioHandler.CreateField)
|
||||
protected.PUT("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.UpdateField)
|
||||
protected.DELETE("/scenario-fields/:fieldId", middleware.RequirePermission("scenario.edit"), scenarioHandler.DeleteField)
|
||||
|
||||
protected.GET("/sops", sopHandler.List)
|
||||
protected.GET("/scenarios/:id/sops", sopHandler.List)
|
||||
protected.POST("/scenarios/:id/sops", sopHandler.Create)
|
||||
protected.GET("/sops/:id", sopHandler.Get)
|
||||
protected.PUT("/sops/:id/draft", sopHandler.SaveGraph)
|
||||
protected.POST("/sops/:id/validate", sopHandler.Validate)
|
||||
protected.POST("/sops/:id/submit-review", sopHandler.SubmitReview)
|
||||
protected.POST("/sops/:id/publish", sopHandler.Publish)
|
||||
protected.POST("/sops/:id/offline", sopHandler.Offline)
|
||||
protected.POST("/sops/:id/versions", sopHandler.CreateVersion)
|
||||
protected.GET("/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
|
||||
protected.GET("/scenarios/:id/sops", middleware.RequirePermission("sop.view"), sopHandler.List)
|
||||
protected.POST("/scenarios/:id/sops", middleware.RequirePermission("sop.edit"), sopHandler.Create)
|
||||
protected.GET("/sops/:id", middleware.RequirePermission("sop.view"), sopHandler.Get)
|
||||
protected.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/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.GET("/reviews", middleware.RequirePermission("sop.review"), sopHandler.Reviews)
|
||||
|
||||
protected.GET("/published-sops", runHandler.PublishedSOPs)
|
||||
protected.GET("/runs", runHandler.List)
|
||||
protected.POST("/runs", runHandler.Start)
|
||||
protected.GET("/runs/:id", runHandler.Get)
|
||||
protected.POST("/runs/:id/answer", runHandler.Answer)
|
||||
protected.POST("/runs/:id/finish", runHandler.Finish)
|
||||
protected.POST("/runs/:id/feedback", runHandler.Feedback)
|
||||
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
|
||||
protected.GET("/runs", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.List)
|
||||
protected.POST("/runs", middleware.RequirePermission("sop.execute"), runHandler.Start)
|
||||
protected.GET("/runs/:id", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Get)
|
||||
protected.GET("/runs/:id/detail", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Detail)
|
||||
protected.POST("/runs/:id/answer", middleware.RequirePermission("sop.execute"), runHandler.Answer)
|
||||
protected.POST("/runs/:id/finish", middleware.RequirePermission("sop.execute"), runHandler.Finish)
|
||||
protected.POST("/runs/:id/feedback", middleware.RequirePermission("runs.feedback"), runHandler.Feedback)
|
||||
|
||||
protected.GET("/knowledge-cards", knowledgeHandler.List)
|
||||
protected.POST("/knowledge-cards", knowledgeHandler.Create)
|
||||
protected.DELETE("/knowledge-cards/:id", knowledgeHandler.Delete)
|
||||
protected.GET("/knowledge-cards", middleware.RequirePermission("knowledge.view"), knowledgeHandler.List)
|
||||
protected.POST("/knowledge-cards", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Create)
|
||||
protected.DELETE("/knowledge-cards/:id", middleware.RequirePermission("knowledge.edit"), knowledgeHandler.Delete)
|
||||
|
||||
router.NoRoute(spaHandler(frontend))
|
||||
return router
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"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"
|
||||
@@ -30,8 +31,10 @@ func (h *Handler) List(c *gin.Context) {
|
||||
model.KnowledgeCard
|
||||
Content datatypes.JSON `json:"content"`
|
||||
}
|
||||
var items []row
|
||||
err := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content").Joins("LEFT JOIN knowledge_card_versions kcv ON kcv.knowledge_card_id = kc.id AND kcv.status = ?", "published").Where("kc.tenant_id = ?", p.TenantID).Order("kc.updated_at DESC").Scan(&items).Error
|
||||
items := make([]row, 0)
|
||||
query := h.db.Table("knowledge_cards kc").Select("kc.*, kcv.content").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 <> ?", p.TenantID, "archived").Order("kc.updated_at DESC").Scan(&items).Error
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询知识卡失败")
|
||||
return
|
||||
@@ -46,6 +49,10 @@ func (h *Handler) Create(c *gin.Context) {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "知识卡内容不完整")
|
||||
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 {
|
||||
@@ -70,10 +77,29 @@ func (h *Handler) Delete(c *gin.Context) {
|
||||
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})
|
||||
}
|
||||
|
||||
127
internal/member/handler.go
Normal file
127
internal/member/handler.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package member
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct{ db *gorm.DB }
|
||||
|
||||
func NewHandler(db *gorm.DB) *Handler { return &Handler{db: db} }
|
||||
|
||||
type MemberItem struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RoleCode string `json:"role_code"`
|
||||
RoleName string `json:"role_name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
principal, _ := auth.PrincipalFromContext(c)
|
||||
items := make([]MemberItem, 0)
|
||||
err := h.db.Table("tenant_members tm").Select("tm.id, tm.user_id, u.username, u.display_name, r.code AS role_code, r.name AS role_name, tm.status").Joins("JOIN users u ON u.id = tm.user_id").Joins("JOIN roles r ON r.id = tm.role_id").Where("tm.tenant_id = ?", principal.TenantID).Order("tm.id").Scan(&items).Error
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询企业成员失败")
|
||||
return
|
||||
}
|
||||
roles := make([]model.Role, 0)
|
||||
if err := h.db.Where("tenant_id = ?", principal.TenantID).Order("id").Find(&roles).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询角色失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "roles": roles, "total": len(items)})
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
principal, _ := auth.PrincipalFromContext(c)
|
||||
var input struct {
|
||||
Username string `json:"username" binding:"required,min=2,max=64"`
|
||||
Password string `json:"password" binding:"required,min=6,max=128"`
|
||||
DisplayName string `json:"display_name" binding:"required,max=128"`
|
||||
RoleCode string `json:"role_code" binding:"required,max=64"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "成员信息不完整")
|
||||
return
|
||||
}
|
||||
var role model.Role
|
||||
if err := h.db.Where("tenant_id = ? AND code = ?", principal.TenantID, input.RoleCode).First(&role).Error; err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ROLE", "角色不存在")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建成员失败")
|
||||
return
|
||||
}
|
||||
var user model.User
|
||||
var membership model.TenantMember
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
user = model.User{Username: input.Username, PasswordHash: string(hash), DisplayName: input.DisplayName, Status: "active"}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
membership = model.TenantMember{TenantID: principal.TenantID, UserID: user.ID, RoleID: role.ID, Status: "active"}
|
||||
return tx.Create(&membership).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusConflict, "MEMBER_EXISTS", "用户名已存在")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, principal, "create", "tenant_member", membership.ID, gin.H{"username": input.Username, "role_code": input.RoleCode})
|
||||
response.Created(c, gin.H{"id": membership.ID, "user_id": user.ID})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
principal, _ := 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 input struct {
|
||||
RoleCode string `json:"role_code" binding:"required,max=64"`
|
||||
Status string `json:"status" binding:"required,oneof=active disabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "成员配置不正确")
|
||||
return
|
||||
}
|
||||
var membership model.TenantMember
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, principal.TenantID).First(&membership).Error; err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "成员不存在")
|
||||
return
|
||||
}
|
||||
if membership.UserID == principal.UserID {
|
||||
response.Error(c, http.StatusConflict, "SELF_UPDATE_FORBIDDEN", "不能修改自己的角色或状态")
|
||||
return
|
||||
}
|
||||
var role model.Role
|
||||
if err := h.db.Where("tenant_id = ? AND code = ?", principal.TenantID, input.RoleCode).First(&role).Error; err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ROLE", "角色不存在")
|
||||
return
|
||||
}
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&membership).Updates(map[string]interface{}{"role_id": role.ID, "status": input.Status}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.User{}).Where("id = ?", membership.UserID).Update("status", input.Status).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新成员失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, principal, "update", "tenant_member", id, input)
|
||||
response.OK(c, gin.H{"id": id, "role_code": input.RoleCode, "status": input.Status})
|
||||
}
|
||||
@@ -63,7 +63,31 @@ func Authenticate(service *auth.Service) gin.HandlerFunc {
|
||||
response.Error(c, http.StatusUnauthorized, "INVALID_TOKEN", "登录状态已失效")
|
||||
return
|
||||
}
|
||||
principal, err = service.RefreshPrincipal(principal)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusUnauthorized, "INVALID_MEMBERSHIP", "账号或企业成员身份已失效")
|
||||
return
|
||||
}
|
||||
auth.SetPrincipal(c, principal)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAnyPermission(permissions ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
principal, ok := auth.PrincipalFromContext(c)
|
||||
if !ok {
|
||||
response.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "请先登录")
|
||||
return
|
||||
}
|
||||
if !auth.HasAnyPermission(principal, permissions...) {
|
||||
response.Error(c, http.StatusForbidden, "FORBIDDEN", "没有执行该操作的权限")
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequirePermission(permission string) gin.HandlerFunc {
|
||||
return RequireAnyPermission(permission)
|
||||
}
|
||||
|
||||
66
internal/run/detail.go
Normal file
66
internal/run/detail.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type DetailHeader struct {
|
||||
model.SOPRun
|
||||
SOPName string `json:"sop_name"`
|
||||
ScenarioName string `json:"scenario_name"`
|
||||
Version int `json:"version"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
}
|
||||
|
||||
type DetailEvent struct {
|
||||
model.SOPRunEvent
|
||||
NodeTitle string `json:"node_title"`
|
||||
NodeType string `json:"node_type"`
|
||||
NodeContent string `json:"node_content"`
|
||||
}
|
||||
|
||||
type DetailFeedback struct {
|
||||
model.SOPFeedback
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
|
||||
func (h *Handler) Detail(c *gin.Context) {
|
||||
principal, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := runID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var header DetailHeader
|
||||
err := h.db.Table("sop_runs r").Select(
|
||||
"r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name",
|
||||
).Joins("JOIN sops s ON s.id = r.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").Joins("JOIN users u ON u.id = r.operator_id").Where("r.id = ? AND r.tenant_id = ?", id, principal.TenantID).Scan(&header).Error
|
||||
if err != nil || header.ID == 0 || !canViewRun(principal, header.SOPRun) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
return
|
||||
}
|
||||
events := make([]DetailEvent, 0)
|
||||
if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content").Joins("LEFT JOIN sop_nodes n ON n.sop_version_id = ? AND n.node_key = e.node_key", header.SOPVersionID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败")
|
||||
return
|
||||
}
|
||||
feedback := make([]DetailFeedback, 0)
|
||||
if err := h.db.Table("sop_feedback f").Select("f.*, u.display_name AS user_name").Joins("JOIN users u ON u.id = f.user_id").Where("f.run_id = ? AND f.tenant_id = ?", id, principal.TenantID).Order("f.created_at").Scan(&feedback).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行反馈失败")
|
||||
return
|
||||
}
|
||||
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 = ?", header.SOPID, principal.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
|
||||
return
|
||||
}
|
||||
if header.Answers == nil {
|
||||
header.Answers = datatypes.JSON([]byte(`{}`))
|
||||
}
|
||||
response.OK(c, gin.H{"run": header, "events": events, "feedback": feedback, "fields": fields})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"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"
|
||||
@@ -36,8 +37,10 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
|
||||
ScenarioName string `json:"scenario_name"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
var items []item
|
||||
err := 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").Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
|
||||
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 = access.ScopeScenarios(query, p, "sc")
|
||||
err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可执行 SOP 失败")
|
||||
return
|
||||
@@ -54,6 +57,10 @@ func (h *Handler) Start(c *gin.Context) {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP")
|
||||
return
|
||||
}
|
||||
if !access.CanViewSOP(h.db, p, input.SOPID) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布 SOP")
|
||||
return
|
||||
}
|
||||
var version model.SOPVersion
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布版本")
|
||||
@@ -85,6 +92,10 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
return
|
||||
}
|
||||
if !canViewRun(p, item) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
return
|
||||
}
|
||||
h.respondRun(c, item)
|
||||
}
|
||||
|
||||
@@ -94,12 +105,34 @@ func (h *Handler) List(c *gin.Context) {
|
||||
model.SOPRun
|
||||
SOPName string `json:"sop_name"`
|
||||
}
|
||||
var items []row
|
||||
if err := h.db.Table("sop_runs r").Select("r.*, s.name AS sop_name").Joins("JOIN sops s ON s.id = r.sop_id").Where("r.tenant_id = ?", p.TenantID).Order("r.created_at DESC").Limit(100).Scan(&items).Error; err != nil {
|
||||
page, pageSize := pagination(c)
|
||||
query := scopeRuns(h.db.Table("sop_runs r").Joins("JOIN sops s ON s.id = r.sop_id"), p, "r")
|
||||
if status := c.Query("status"); status != "" {
|
||||
query = query.Where("r.status = ?", status)
|
||||
}
|
||||
if result := c.Query("result"); result != "" {
|
||||
query = query.Where("r.result = ?", result)
|
||||
}
|
||||
if sopID := c.Query("sop_id"); sopID != "" {
|
||||
query = query.Where("r.sop_id = ?", sopID)
|
||||
}
|
||||
if startedFrom := c.Query("started_from"); startedFrom != "" {
|
||||
query = query.Where("r.started_at >= ?", startedFrom)
|
||||
}
|
||||
if startedTo := c.Query("started_to"); startedTo != "" {
|
||||
query = query.Where("r.started_at <= ?", startedTo)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||
items := make([]row, 0)
|
||||
if err := query.Select("r.*, s.name AS sop_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) Answer(c *gin.Context) {
|
||||
@@ -124,6 +157,9 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
if updated.Status != "running" {
|
||||
return errors.New("run is not active")
|
||||
}
|
||||
if !canOperateRun(p, updated) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if input.NodeKey != "" && input.NodeKey != updated.CurrentNodeKey {
|
||||
return errors.New("当前步骤已经变化,请刷新后重试")
|
||||
}
|
||||
@@ -131,7 +167,7 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPVersionID, updated.CurrentNodeKey, p.TenantID).First(¤tNode).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var fields []model.ScenarioField
|
||||
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 = ?", updated.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -210,8 +246,17 @@ func (h *Handler) Finish(c *gin.Context) {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
|
||||
return
|
||||
}
|
||||
var run model.SOPRun
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil || !canOperateRun(p, run) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
return
|
||||
}
|
||||
if run.Status != "running" {
|
||||
response.Error(c, http.StatusConflict, "RUN_COMPLETED", "执行记录已经结束")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
result := h.db.Model(&model.SOPRun{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now})
|
||||
result := h.db.Model(&model.SOPRun{}).Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "running").Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now})
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
return
|
||||
@@ -234,11 +279,21 @@ func (h *Handler) Feedback(c *gin.Context) {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "反馈内容不正确")
|
||||
return
|
||||
}
|
||||
var run model.SOPRun
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil || !canViewRun(p, run) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
return
|
||||
}
|
||||
if run.Status != "completed" {
|
||||
response.Error(c, http.StatusConflict, "RUN_NOT_COMPLETED", "执行完成后才能提交反馈")
|
||||
return
|
||||
}
|
||||
item := model.SOPFeedback{TenantID: p.TenantID, RunID: id, UserID: p.UserID, Score: input.Score, Comment: input.Comment}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
response.Error(c, http.StatusConflict, "FEEDBACK_EXISTS", "该执行记录已经提交反馈")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "feedback", "sop_run", id, gin.H{"score": input.Score})
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
@@ -248,7 +303,7 @@ func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
|
||||
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
||||
return
|
||||
}
|
||||
var fields []model.ScenarioField
|
||||
fields := make([]model.ScenarioField, 0)
|
||||
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)
|
||||
response.OK(c, gin.H{"run": item, "node": node, "fields": fields})
|
||||
}
|
||||
@@ -261,3 +316,37 @@ func runID(c *gin.Context) (uint64, bool) {
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func scopeRuns(query *gorm.DB, principal auth.Principal, alias string) *gorm.DB {
|
||||
query = query.Where(alias+".tenant_id = ?", principal.TenantID)
|
||||
if auth.HasPermission(principal, "runs.view_all") || auth.HasPermission(principal, "*") {
|
||||
return query
|
||||
}
|
||||
return query.Where(alias+".operator_id = ?", principal.UserID)
|
||||
}
|
||||
|
||||
func canViewRun(principal auth.Principal, run model.SOPRun) bool {
|
||||
if run.TenantID != principal.TenantID {
|
||||
return false
|
||||
}
|
||||
return run.OperatorID == principal.UserID || auth.HasPermission(principal, "runs.view_all") || auth.HasPermission(principal, "*")
|
||||
}
|
||||
|
||||
func canOperateRun(principal auth.Principal, run model.SOPRun) bool {
|
||||
return run.TenantID == principal.TenantID && (run.OperatorID == principal.UserID || auth.HasPermission(principal, "*"))
|
||||
}
|
||||
|
||||
func pagination(c *gin.Context) (int, int) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"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"
|
||||
@@ -54,16 +55,17 @@ type edgeInput struct {
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
var items []model.SOP
|
||||
query := h.db.Where("tenant_id = ? AND status <> ?", p.TenantID, "archived")
|
||||
items := make([]model.SOP, 0)
|
||||
query := h.db.Table("sops s").Select("s.*").Joins("JOIN scenarios sc ON sc.id = s.scenario_id")
|
||||
query = access.ScopeScenarios(query, p, "sc").Where("s.tenant_id = ? AND s.status <> ?", p.TenantID, "archived")
|
||||
scenarioID := c.Query("scenario_id")
|
||||
if scenarioID == "" {
|
||||
scenarioID = c.Param("id")
|
||||
}
|
||||
if scenarioID != "" {
|
||||
query = query.Where("scenario_id = ?", scenarioID)
|
||||
query = query.Where("s.scenario_id = ?", scenarioID)
|
||||
}
|
||||
if err := query.Order("updated_at DESC").Find(&items).Error; err != nil {
|
||||
if err := query.Order("s.updated_at DESC").Scan(&items).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 失败")
|
||||
return
|
||||
}
|
||||
@@ -76,6 +78,10 @@ func (h *Handler) Create(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !access.CanEditScenario(h.db, p, scenarioID) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
||||
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", "场景不存在")
|
||||
@@ -125,6 +131,10 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
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)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -143,6 +153,10 @@ func (h *Handler) SaveGraph(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !access.CanEditSOP(h.db, p, id) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可编辑")
|
||||
return
|
||||
}
|
||||
var input graphInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
|
||||
@@ -190,6 +204,10 @@ func (h *Handler) Validate(c *gin.Context) {
|
||||
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)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
@@ -209,6 +227,10 @@ func (h *Handler) SubmitReview(c *gin.Context) {
|
||||
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", "没有可提交审核的草稿版本")
|
||||
@@ -227,7 +249,14 @@ func (h *Handler) SubmitReview(c *gin.Context) {
|
||||
if err := tx.Model(&version).Update("status", "reviewing").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&item).Update("status", "reviewing").Error
|
||||
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", "提交审核失败")
|
||||
@@ -243,11 +272,20 @@ func (h *Handler) Publish(c *gin.Context) {
|
||||
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)
|
||||
if err != nil || (version.Status != "draft" && version.Status != "reviewing") {
|
||||
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", "校验流程失败")
|
||||
@@ -284,6 +322,10 @@ func (h *Handler) Offline(c *gin.Context) {
|
||||
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 不存在")
|
||||
@@ -319,10 +361,14 @@ func (h *Handler) CreateVersion(c *gin.Context) {
|
||||
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 = ?", id, p.TenantID, "draft").Count(&existing)
|
||||
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", "已经存在草稿版本")
|
||||
response.Error(c, http.StatusConflict, "DRAFT_EXISTS", "已经存在草稿或审核中的版本")
|
||||
return
|
||||
}
|
||||
_, source, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||
@@ -369,8 +415,8 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ?", sopID, tenantID).Order("version DESC").First(&version).Error; err != nil {
|
||||
return item, version, nil, nil, err
|
||||
}
|
||||
var nodes []model.SOPNode
|
||||
var edges []model.SOPEdge
|
||||
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
|
||||
}
|
||||
@@ -381,7 +427,7 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
|
||||
}
|
||||
|
||||
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
|
||||
var fields []model.ScenarioField
|
||||
fields := make([]model.ScenarioField, 0)
|
||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
90
internal/sop/review.go
Normal file
90
internal/sop/review.go
Normal file
@@ -0,0 +1,90 @@
|
||||
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"})
|
||||
}
|
||||
Reference in New Issue
Block a user