feat: implement scenario-driven sales SOP platform

This commit is contained in:
Eric 1549169735@qq.com
2026-08-06 21:37:29 +08:00
parent 254469ab89
commit de5345607a
84 changed files with 7132 additions and 2 deletions

View File

@@ -0,0 +1,18 @@
package audit
import (
"encoding/json"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
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
}
return db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error
}

View File

@@ -0,0 +1,80 @@
package auth
import (
"errors"
"net/http"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
type loginRequest struct {
Username string `json:"username" binding:"required,min=2,max=64"`
Password string `json:"password" binding:"required,min=6,max=128"`
}
func (h *Handler) Login(c *gin.Context) {
var input loginRequest
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请输入有效的用户名和密码")
return
}
tokens, err := h.service.Login(input.Username, input.Password)
if errors.Is(err, ErrInvalidCredentials) {
response.Error(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "用户名或密码错误")
return
}
if err != nil {
response.Error(c, http.StatusInternalServerError, "LOGIN_FAILED", "登录失败")
return
}
response.OK(c, tokens)
}
func (h *Handler) Refresh(c *gin.Context) {
var input struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "refresh_token 不能为空")
return
}
tokens, err := h.service.Refresh(input.RefreshToken)
if err != nil {
response.Error(c, http.StatusUnauthorized, "INVALID_REFRESH_TOKEN", "登录状态已失效")
return
}
response.OK(c, tokens)
}
func (h *Handler) Me(c *gin.Context) {
principal, exists := PrincipalFromContext(c)
if !exists {
response.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "请先登录")
return
}
response.OK(c, principal)
}
const principalContextKey = "principal"
func SetPrincipal(c *gin.Context, principal Principal) {
c.Set(principalContextKey, principal)
}
func PrincipalFromContext(c *gin.Context) (Principal, bool) {
value, exists := c.Get(principalContextKey)
if !exists {
return Principal{}, false
}
principal, ok := value.(Principal)
return principal, ok
}

View File

@@ -0,0 +1,185 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/datatypes"
"gorm.io/gorm"
)
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"`
}
type Claims struct {
TenantID uint64 `json:"tenant_id"`
RoleCode string `json:"role_code"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
jwt.RegisteredClaims
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
User Principal `json:"user"`
}
type Service struct {
db *gorm.DB
cfg config.AuthConfig
}
func NewService(db *gorm.DB, cfg config.AuthConfig) *Service {
return &Service{db: db, cfg: cfg}
}
func (s *Service) Login(username, password string) (TokenPair, error) {
var user model.User
if err := s.db.Where("username = ? AND status = ?", username, "active").First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return TokenPair{}, ErrInvalidCredentials
}
return TokenPair{}, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return TokenPair{}, ErrInvalidCredentials
}
principal, err := s.principalForUser(user)
if err != nil {
return TokenPair{}, err
}
return s.issueTokenPair(principal)
}
func (s *Service) Refresh(rawToken string) (TokenPair, error) {
hash := tokenHash(rawToken)
var stored model.RefreshToken
err := s.db.Where("token_hash = ? AND revoked_at IS NULL AND expires_at > ?", hash, time.Now()).First(&stored).Error
if err != nil {
return TokenPair{}, ErrInvalidCredentials
}
var user model.User
if err := s.db.First(&user, stored.UserID).Error; err != nil {
return TokenPair{}, err
}
principal, err := s.principalForUser(user)
if err != nil {
return TokenPair{}, err
}
now := time.Now()
if err := s.db.Model(&stored).Update("revoked_at", &now).Error; err != nil {
return TokenPair{}, err
}
return s.issueTokenPair(principal)
}
func (s *Service) ParseAccessToken(rawToken string) (Principal, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(rawToken, claims, func(token *jwt.Token) (interface{}, error) {
if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg())
}
return []byte(s.cfg.JWTSecret), nil
})
if err != nil || !token.Valid {
return Principal{}, ErrInvalidCredentials
}
userID, err := parseUint(claims.Subject)
if err != nil {
return Principal{}, ErrInvalidCredentials
}
return Principal{UserID: userID, TenantID: claims.TenantID, RoleCode: claims.RoleCode, Username: claims.Username, DisplayName: claims.DisplayName}, nil
}
func (s *Service) issueTokenPair(principal Principal) (TokenPair, error) {
now := time.Now()
expiresAt := now.Add(s.cfg.AccessTokenTTL)
claims := Claims{
TenantID: principal.TenantID, RoleCode: principal.RoleCode, Username: principal.Username, DisplayName: principal.DisplayName,
RegisteredClaims: jwt.RegisteredClaims{Subject: fmt.Sprintf("%d", principal.UserID), IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expiresAt)},
}
access, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.cfg.JWTSecret))
if err != nil {
return TokenPair{}, err
}
refresh := uuid.NewString() + uuid.NewString()
record := model.RefreshToken{TenantID: principal.TenantID, UserID: principal.UserID, TokenHash: tokenHash(refresh), ExpiresAt: now.Add(s.cfg.RefreshTokenTTL)}
if err := s.db.Create(&record).Error; err != nil {
return TokenPair{}, err
}
return TokenPair{AccessToken: access, RefreshToken: refresh, ExpiresAt: expiresAt, User: principal}, nil
}
func (s *Service) principalForUser(user model.User) (Principal, error) {
type row struct {
TenantID uint64
RoleCode string
}
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
if err != nil {
return Principal{}, err
}
return Principal{UserID: user.ID, TenantID: membership.TenantID, RoleCode: membership.RoleCode, Username: user.Username, DisplayName: user.DisplayName}, nil
}
func Seed(db *gorm.DB, cfg config.SeedConfig) error {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
var tenant model.Tenant
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
}
var user model.User
err := tx.Where("username = ?", cfg.AdminUsername).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
hash, hashErr := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcrypt.DefaultCost)
if hashErr != nil {
return hashErr
}
user = model.User{Username: cfg.AdminUsername, PasswordHash: string(hash), DisplayName: cfg.AdminDisplayName, Status: "active"}
if err := tx.Create(&user).Error; err != nil {
return err
}
} else if err != nil {
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
})
}
func tokenHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func parseUint(value string) (uint64, error) {
var result uint64
_, err := fmt.Sscanf(value, "%d", &result)
return result, err
}

View File

@@ -0,0 +1,134 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/ilyakaznacheev/cleanenv"
)
type Config struct {
App AppConfig `yaml:"app"`
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"`
Seed SeedConfig `yaml:"seed"`
}
type AppConfig struct {
Name string `yaml:"name" env:"APP_NAME" env-default:"iqudo-top1"`
Env string `yaml:"env" env:"APP_ENV" env-default:"development"`
}
type ServerConfig struct {
Host string `yaml:"host" env:"APP_SERVER_HOST" env-default:"127.0.0.1"`
Port int `yaml:"port" env:"APP_SERVER_PORT" env-default:"8080"`
ReadTimeout time.Duration `yaml:"read_timeout" env:"APP_SERVER_READ_TIMEOUT" env-default:"10s"`
WriteTimeout time.Duration `yaml:"write_timeout" env:"APP_SERVER_WRITE_TIMEOUT" env-default:"20s"`
ShutdownTimeout time.Duration `yaml:"shutdown_timeout" env:"APP_SERVER_SHUTDOWN_TIMEOUT" env-default:"10s"`
}
func (c ServerConfig) Address() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
type DatabaseConfig struct {
Host string `yaml:"host" env:"APP_DATABASE_HOST" env-default:"127.0.0.1"`
Port int `yaml:"port" env:"APP_DATABASE_PORT" env-default:"3306"`
Name string `yaml:"name" env:"APP_DATABASE_NAME"`
User string `yaml:"user" env:"APP_DATABASE_USER"`
Password string `yaml:"password" env:"APP_DATABASE_PASSWORD"`
MaxIdleConnections int `yaml:"max_idle_connections" env:"APP_DATABASE_MAX_IDLE_CONNECTIONS" env-default:"10"`
MaxOpenConnections int `yaml:"max_open_connections" env:"APP_DATABASE_MAX_OPEN_CONNECTIONS" env-default:"40"`
ConnectionMaxLifetime time.Duration `yaml:"connection_max_lifetime" env:"APP_DATABASE_CONNECTION_MAX_LIFETIME" env-default:"30m"`
}
func (c DatabaseConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true", c.User, c.Password, c.Host, c.Port, c.Name)
}
type AuthConfig struct {
JWTSecret string `yaml:"jwt_secret" env:"APP_AUTH_JWT_SECRET"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl" env:"APP_AUTH_ACCESS_TOKEN_TTL" env-default:"2h"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl" env:"APP_AUTH_REFRESH_TOKEN_TTL" env-default:"168h"`
}
type SeedConfig struct {
AdminUsername string `yaml:"admin_username" env:"APP_SEED_ADMIN_USERNAME"`
AdminPassword string `yaml:"admin_password" env:"APP_SEED_ADMIN_PASSWORD"`
AdminDisplayName string `yaml:"admin_display_name" env:"APP_SEED_ADMIN_DISPLAY_NAME"`
}
type LoadOptions struct {
Environment string
ConfigDir string
}
func Load(options LoadOptions) (Config, error) {
configDir := options.ConfigDir
if configDir == "" {
configDir = "configs"
}
environment := normalizeEnvironment(options.Environment)
if environment == "" {
environment = normalizeEnvironment(os.Getenv("APP_ENV"))
}
var cfg Config
basePath := filepath.Join(configDir, "config.yml")
if err := cleanenv.ReadConfig(basePath, &cfg); err != nil {
return Config{}, fmt.Errorf("load base config %s: %w", basePath, err)
}
if environment == "test" || environment == "prod" {
profilePath := filepath.Join(configDir, "config."+environment+".yml")
if err := cleanenv.ReadConfig(profilePath, &cfg); err != nil {
return Config{}, fmt.Errorf("load profile config %s: %w", profilePath, err)
}
}
if err := cleanenv.ReadEnv(&cfg); err != nil {
return Config{}, fmt.Errorf("load environment variables: %w", err)
}
if environment != "" {
cfg.App.Env = environment
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func normalizeEnvironment(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "production", "prod":
return "prod"
case "testing", "test":
return "test"
case "development", "dev", "local":
return "development"
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func (c Config) Validate() error {
if c.Database.Name == "" || c.Database.User == "" {
return errors.New("database name and user are required")
}
if len(c.Auth.JWTSecret) < 16 {
return errors.New("auth.jwt_secret must contain at least 16 characters")
}
if c.Server.Port < 1 || c.Server.Port > 65535 {
return errors.New("server.port must be between 1 and 65535")
}
if c.App.Env == "prod" && c.Database.Password == "" {
return errors.New("database password is required in production")
}
return nil
}

View File

@@ -0,0 +1,40 @@
package dashboard
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/gorm"
)
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)
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"}},
}
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
}
response.OK(c, counts)
}

View File

@@ -0,0 +1,86 @@
package database
import (
"context"
"errors"
"fmt"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
"go.uber.org/zap"
"gorm.io/driver/mysql"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func Open(cfg config.DatabaseConfig, log *zap.Logger) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
Logger: newGORMLogger(log.Named("gorm"), gormlogger.Warn),
})
if err != nil {
return nil, fmt.Errorf("open mysql: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("get sql database: %w", err)
}
sqlDB.SetMaxIdleConns(cfg.MaxIdleConnections)
sqlDB.SetMaxOpenConns(cfg.MaxOpenConnections)
sqlDB.SetConnMaxLifetime(cfg.ConnectionMaxLifetime)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping mysql: %w", err)
}
return db, nil
}
type zapGORMLogger struct {
log *zap.Logger
level gormlogger.LogLevel
}
func newGORMLogger(log *zap.Logger, level gormlogger.LogLevel) gormlogger.Interface {
return &zapGORMLogger{log: log, level: level}
}
func (l *zapGORMLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
clone := *l
clone.level = level
return &clone
}
func (l *zapGORMLogger) Info(_ context.Context, msg string, data ...interface{}) {
if l.level >= gormlogger.Info {
l.log.Sugar().Infof(msg, data...)
}
}
func (l *zapGORMLogger) Warn(_ context.Context, msg string, data ...interface{}) {
if l.level >= gormlogger.Warn {
l.log.Sugar().Warnf(msg, data...)
}
}
func (l *zapGORMLogger) Error(_ context.Context, msg string, data ...interface{}) {
if l.level >= gormlogger.Error {
l.log.Sugar().Errorf(msg, data...)
}
}
func (l *zapGORMLogger) Trace(_ context.Context, begin time.Time, fc func() (string, int64), err error) {
if l.level == gormlogger.Silent {
return
}
sql, rows := fc()
fields := []zap.Field{zap.Duration("duration", time.Since(begin)), zap.Int64("rows", rows), zap.String("sql", sql)}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormlogger.Error {
l.log.Error("query failed", append(fields, zap.Error(err))...)
return
}
if l.level >= gormlogger.Info {
l.log.Debug("query", fields...)
}
}

View File

@@ -0,0 +1,114 @@
package httpserver
import (
"io/fs"
"mime"
"net/http"
"path"
"strings"
"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/middleware"
runhandler "git.iwork-ai.com/xdc/iqudo-top1/internal/run"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scenario"
"git.iwork-ai.com/xdc/iqudo-top1/internal/sop"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
)
func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger, environment string) http.Handler {
if environment == "prod" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(middleware.RequestLogger(log.Named("http")), middleware.Recovery(log.Named("recovery")))
authHandler := auth.NewHandler(authService)
scenarioHandler := scenario.NewHandler(db)
sopHandler := sop.NewHandler(db)
runHandler := runhandler.NewHandler(db)
knowledgeHandler := knowledge.NewHandler(db)
dashboardHandler := dashboard.NewHandler(db)
api := router.Group("/api/v1")
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
api.POST("/auth/login", authHandler.Login)
api.POST("/auth/refresh", authHandler.Refresh)
protected := api.Group("")
protected.Use(middleware.Authenticate(authService))
protected.GET("/auth/me", authHandler.Me)
protected.GET("/dashboard/summary", 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("/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("/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("/knowledge-cards", knowledgeHandler.List)
protected.POST("/knowledge-cards", knowledgeHandler.Create)
protected.DELETE("/knowledge-cards/:id", knowledgeHandler.Delete)
router.NoRoute(spaHandler(frontend))
return router
}
func spaHandler(frontend fs.FS) gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "接口不存在"})
return
}
name := strings.TrimPrefix(path.Clean(c.Request.URL.Path), "/")
if name == "" || name == "." {
name = "index.html"
}
data, err := fs.ReadFile(frontend, name)
if err != nil {
name = "index.html"
data, err = fs.ReadFile(frontend, name)
}
if err != nil {
c.Status(http.StatusNotFound)
return
}
contentType := mime.TypeByExtension(path.Ext(name))
if contentType == "" {
contentType = "application/octet-stream"
}
if name == "index.html" {
c.Header("Cache-Control", "no-cache")
} else {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
c.Data(http.StatusOK, contentType, data)
}
}

View File

@@ -0,0 +1,79 @@
package knowledge
import (
"encoding/json"
"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"
"gorm.io/datatypes"
"gorm.io/gorm"
)
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"`
}
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
type row struct {
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
if 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
}
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
}
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
}
response.OK(c, gin.H{"id": id})
}

View File

@@ -0,0 +1,28 @@
package logger
import (
"strings"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func New(environment, service string) (*zap.Logger, error) {
var cfg zap.Config
if strings.EqualFold(environment, "prod") || strings.EqualFold(environment, "production") {
cfg = zap.NewProductionConfig()
cfg.EncoderConfig.TimeKey = "time"
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
} else {
cfg = zap.NewDevelopmentConfig()
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
cfg.OutputPaths = []string{"stdout"}
cfg.ErrorOutputPaths = []string{"stderr"}
log, err := cfg.Build()
if err != nil {
return nil, err
}
return log.With(zap.String("service", service), zap.String("env", environment)), nil
}

View File

@@ -0,0 +1,69 @@
package middleware
import (
"net/http"
"runtime/debug"
"strings"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
func RequestLogger(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
started := time.Now()
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = uuid.NewString()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
fields := []zap.Field{
zap.String("request_id", requestID), zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()), zap.Int("response_bytes", c.Writer.Size()), zap.Duration("duration", time.Since(started)), zap.String("client_ip", c.ClientIP()),
}
if principal, ok := auth.PrincipalFromContext(c); ok {
fields = append(fields, zap.Uint64("tenant_id", principal.TenantID), zap.Uint64("user_id", principal.UserID))
}
if len(c.Errors) > 0 {
fields = append(fields, zap.String("errors", c.Errors.String()))
}
log.Info("http request", fields...)
}
}
func Recovery(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recovered := recover(); recovered != nil {
log.Error("panic recovered", zap.Any("panic", recovered), zap.ByteString("stack", debug.Stack()))
response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务暂时不可用")
}
}()
c.Next()
}
}
func Authenticate(service *auth.Service) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
response.Error(c, http.StatusUnauthorized, "UNAUTHORIZED", "请先登录")
return
}
principal, err := service.ParseAccessToken(parts[1])
if err != nil {
response.Error(c, http.StatusUnauthorized, "INVALID_TOKEN", "登录状态已失效")
return
}
auth.SetPrincipal(c, principal)
c.Next()
}
}

View File

@@ -0,0 +1,35 @@
package migration
import (
"errors"
"fmt"
"io/fs"
"github.com/golang-migrate/migrate/v4"
migratemysql "github.com/golang-migrate/migrate/v4/database/mysql"
"github.com/golang-migrate/migrate/v4/source/iofs"
"gorm.io/gorm"
)
func Run(db *gorm.DB, files fs.FS) error {
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("get sql database: %w", err)
}
driver, err := migratemysql.WithInstance(sqlDB, &migratemysql.Config{})
if err != nil {
return fmt.Errorf("create migration database driver: %w", err)
}
source, err := iofs.New(files, "migrations")
if err != nil {
return fmt.Errorf("create migration source: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "mysql", driver)
if err != nil {
return fmt.Errorf("create migrator: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("run migrations: %w", err)
}
return nil
}

View File

@@ -0,0 +1,186 @@
package model
import (
"time"
"gorm.io/datatypes"
)
type Base struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Tenant struct {
Base
Name string `json:"name" gorm:"size:128;not null"`
Slug string `json:"slug" gorm:"size:64;not null;uniqueIndex"`
Status string `json:"status" gorm:"size:24;not null"`
}
type User struct {
Base
Username string `json:"username" gorm:"size:64;not null;uniqueIndex"`
PasswordHash string `json:"-" gorm:"size:255;not null"`
DisplayName string `json:"display_name" gorm:"size:128;not null"`
Status string `json:"status" gorm:"size:24;not null"`
}
type Role struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
Name string `json:"name" gorm:"size:64;not null"`
Code string `json:"code" gorm:"size:64;not null"`
Permissions datatypes.JSON `json:"permissions" gorm:"type:json;not null"`
}
type TenantMember struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
UserID uint64 `json:"user_id" gorm:"not null;index"`
RoleID uint64 `json:"role_id" gorm:"not null;index"`
Status string `json:"status" gorm:"size:24;not null"`
}
type Scenario struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
Name string `json:"name" gorm:"size:128;not null"`
Industry string `json:"industry" gorm:"size:64;not null"`
RoleName string `json:"role_name" gorm:"size:64;not null"`
Goal string `json:"goal" gorm:"type:text;not null"`
TriggerText string `json:"trigger_text" gorm:"type:text;not null"`
Visibility string `json:"visibility" gorm:"size:24;not null"`
Status string `json:"status" gorm:"size:24;not null"`
CreatedBy uint64 `json:"created_by" gorm:"not null"`
}
type ScenarioField struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
FieldKey string `json:"field_key" gorm:"size:64;not null"`
FieldName string `json:"field_name" gorm:"size:128;not null"`
FieldType string `json:"field_type" gorm:"size:32;not null"`
Required bool `json:"required" gorm:"not null"`
Options datatypes.JSON `json:"options" gorm:"type:json;not null"`
Validation datatypes.JSON `json:"validation" gorm:"type:json;not null"`
SortOrder int `json:"sort_order" gorm:"not null"`
}
type SOP struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
Name string `json:"name" gorm:"size:128;not null"`
Description string `json:"description" gorm:"type:text;not null"`
Status string `json:"status" gorm:"size:24;not null"`
CreatedBy uint64 `json:"created_by" gorm:"not null"`
}
type SOPVersion struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
Version int `json:"version" gorm:"not null"`
Status string `json:"status" gorm:"size:24;not null"`
StartNodeKey string `json:"start_node_key" gorm:"size:64;not null"`
PublishedAt *time.Time `json:"published_at"`
CreatedBy uint64 `json:"created_by" gorm:"not null"`
ReviewedBy *uint64 `json:"reviewed_by"`
}
type SOPNode struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
NodeKey string `json:"node_key" gorm:"size:64;not null"`
Type string `json:"type" gorm:"size:32;not null"`
Title string `json:"title" gorm:"size:128;not null"`
Content string `json:"content" gorm:"type:text;not null"`
Config datatypes.JSON `json:"config" gorm:"type:json;not null"`
PositionX int `json:"position_x" gorm:"not null"`
PositionY int `json:"position_y" gorm:"not null"`
}
type SOPEdge struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
SourceNodeKey string `json:"source_node_key" gorm:"size:64;not null"`
TargetNodeKey string `json:"target_node_key" gorm:"size:64;not null"`
Condition datatypes.JSON `json:"condition" gorm:"type:json;not null"`
Priority int `json:"priority" gorm:"not null"`
}
type KnowledgeCard struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
ScenarioID uint64 `json:"scenario_id" gorm:"not null;index"`
Title string `json:"title" gorm:"size:128;not null"`
Status string `json:"status" gorm:"size:24;not null"`
CreatedBy uint64 `json:"created_by" gorm:"not null"`
}
type KnowledgeCardVersion struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
KnowledgeCardID uint64 `json:"knowledge_card_id" gorm:"not null;index"`
Version int `json:"version" gorm:"not null"`
Content datatypes.JSON `json:"content" gorm:"type:json;not null"`
Status string `json:"status" gorm:"size:24;not null"`
}
type SOPRun struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
SOPID uint64 `json:"sop_id" gorm:"not null;index"`
SOPVersionID uint64 `json:"sop_version_id" gorm:"not null;index"`
OperatorID uint64 `json:"operator_id" gorm:"not null;index"`
CurrentNodeKey string `json:"current_node_key" gorm:"size:64;not null"`
Status string `json:"status" gorm:"size:24;not null"`
Answers datatypes.JSON `json:"answers" gorm:"type:json;not null"`
Result string `json:"result" gorm:"size:64;not null"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
}
type SOPRunEvent struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
RunID uint64 `json:"run_id" gorm:"not null;index"`
NodeKey string `json:"node_key" gorm:"size:64;not null"`
Action string `json:"action" gorm:"size:64;not null"`
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
}
type SOPFeedback struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
RunID uint64 `json:"run_id" gorm:"not null;index"`
UserID uint64 `json:"user_id" gorm:"not null;index"`
Score int `json:"score" gorm:"not null"`
Comment string `json:"comment" gorm:"type:text;not null"`
}
func (SOPFeedback) TableName() string { return "sop_feedback" }
type AuditLog struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
UserID uint64 `json:"user_id" gorm:"not null;index"`
Action string `json:"action" gorm:"size:64;not null"`
Resource string `json:"resource" gorm:"size:64;not null"`
ResourceID uint64 `json:"resource_id" gorm:"not null"`
Payload datatypes.JSON `json:"payload" gorm:"type:json;not null"`
}
type RefreshToken struct {
Base
TenantID uint64 `json:"tenant_id" gorm:"not null;index"`
UserID uint64 `json:"user_id" gorm:"not null;index"`
TokenHash string `json:"-" gorm:"size:64;not null;uniqueIndex"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null"`
RevokedAt *time.Time `json:"revoked_at"`
}

View File

@@ -0,0 +1,21 @@
package response
import "github.com/gin-gonic/gin"
type Envelope struct {
Code string `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func OK(c *gin.Context, data interface{}) {
c.JSON(200, Envelope{Code: "OK", Message: "success", Data: data})
}
func Created(c *gin.Context, data interface{}) {
c.JSON(201, Envelope{Code: "CREATED", Message: "created", Data: data})
}
func Error(c *gin.Context, status int, code, message string) {
c.AbortWithStatusJSON(status, Envelope{Code: code, Message: message})
}

View File

@@ -0,0 +1,129 @@
package run
import (
"encoding/json"
"fmt"
"reflect"
"strings"
)
func matchCondition(raw json.RawMessage, answers map[string]interface{}) (bool, error) {
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
return true, nil
}
var rule map[string]interface{}
if err := json.Unmarshal(raw, &rule); err != nil {
return false, err
}
if all, ok := rule["all"].([]interface{}); ok {
for _, item := range all {
object, ok := item.(map[string]interface{})
if !ok {
return false, fmt.Errorf("invalid all condition")
}
matched, err := matchRule(object, answers)
if err != nil || !matched {
return false, err
}
}
return true, nil
}
if any, ok := rule["any"].([]interface{}); ok {
for _, item := range any {
object, ok := item.(map[string]interface{})
if !ok {
continue
}
matched, err := matchRule(object, answers)
if err != nil {
return false, err
}
if matched {
return true, nil
}
}
return false, nil
}
return matchRule(rule, answers)
}
func matchRule(rule map[string]interface{}, answers map[string]interface{}) (bool, error) {
field, _ := rule["field"].(string)
operator, _ := rule["operator"].(string)
if field == "" || operator == "" {
return false, fmt.Errorf("condition field and operator are required")
}
actual, exists := answers[field]
expected := rule["value"]
switch operator {
case "exists":
return exists && actual != nil && fmt.Sprint(actual) != "", nil
case "not_exists":
return !exists || actual == nil || fmt.Sprint(actual) == "", nil
case "equals":
return reflect.DeepEqual(normalizeValue(actual), normalizeValue(expected)), nil
case "not_equals":
return !reflect.DeepEqual(normalizeValue(actual), normalizeValue(expected)), nil
case "contains":
return strings.Contains(strings.ToLower(fmt.Sprint(actual)), strings.ToLower(fmt.Sprint(expected))), nil
case "greater_than", "less_than":
left, leftOK := toFloat(actual)
right, rightOK := toFloat(expected)
if !leftOK || !rightOK {
return false, nil
}
if operator == "greater_than" {
return left > right, nil
}
return left < right, nil
case "in":
values, ok := expected.([]interface{})
if !ok {
return false, nil
}
for _, value := range values {
if reflect.DeepEqual(normalizeValue(actual), normalizeValue(value)) {
return true, nil
}
}
return false, nil
default:
return false, fmt.Errorf("unsupported operator: %s", operator)
}
}
func normalizeValue(value interface{}) interface{} {
switch typed := value.(type) {
case json.Number:
if number, err := typed.Float64(); err == nil {
return number
}
case int:
return float64(typed)
case int64:
return float64(typed)
case uint64:
return float64(typed)
}
return value
}
func toFloat(value interface{}) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint64:
return float64(typed), true
case json.Number:
result, err := typed.Float64()
return result, err == nil
default:
return 0, false
}
}

View File

@@ -0,0 +1,33 @@
package run
import (
"encoding/json"
"testing"
)
func TestMatchCondition(t *testing.T) {
answers := map[string]interface{}{"weight": float64(6), "urgent": true, "symptom": "持续呕吐"}
tests := []struct {
name string
rule string
want bool
}{
{name: "default", rule: `{}`, want: true},
{name: "equals", rule: `{"field":"urgent","operator":"equals","value":true}`, want: true},
{name: "greater", rule: `{"field":"weight","operator":"greater_than","value":5}`, want: true},
{name: "contains", rule: `{"field":"symptom","operator":"contains","value":"呕吐"}`, want: true},
{name: "all", rule: `{"all":[{"field":"urgent","operator":"equals","value":true},{"field":"weight","operator":"greater_than","value":5}]}`, want: true},
{name: "any false", rule: `{"any":[{"field":"urgent","operator":"equals","value":false},{"field":"weight","operator":"less_than","value":3}]}`, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := matchCondition(json.RawMessage(test.rule), answers)
if err != nil {
t.Fatalf("matchCondition returned error: %v", err)
}
if got != test.want {
t.Fatalf("matchCondition() = %v, want %v", got, test.want)
}
})
}
}

View File

@@ -0,0 +1,247 @@
package run
import (
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"time"
"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"
)
type Handler struct {
db *gorm.DB
}
func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db}
}
func (h *Handler) PublishedSOPs(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
type item struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ScenarioID uint64 `json:"scenario_id"`
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
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可执行 SOP 失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Start(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
var input struct {
SOPID uint64 `json:"sop_id" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 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", "没有可执行的已发布版本")
return
}
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, SOPVersionID: version.ID, OperatorID: p.UserID, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON([]byte(`{}`)), Result: "", StartedAt: time.Now()}
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&run).Error; err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动 SOP 失败")
return
}
_ = audit.Record(h.db, p, "start", "sop_run", run.ID, gin.H{"sop_id": input.SOPID})
h.respondRun(c, run)
}
func (h *Handler) Get(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var item model.SOPRun
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
h.respondRun(c, item)
}
func (h *Handler) List(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
type row struct {
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 {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Answer(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var input struct {
Answers map[string]interface{} `json:"answers"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "回答格式不正确")
return
}
var updated model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses().Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil {
return err
}
if updated.Status != "running" {
return errors.New("run is not active")
}
answers := map[string]interface{}{}
if len(updated.Answers) > 0 {
_ = json.Unmarshal(updated.Answers, &answers)
}
for key, value := range input.Answers {
answers[key] = value
}
var edges []model.SOPEdge
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
return err
}
sort.SliceStable(edges, func(i, j int) bool { return edges[i].Priority < edges[j].Priority })
nextKey := ""
for _, edge := range edges {
matched, matchErr := matchCondition(json.RawMessage(edge.Condition), answers)
if matchErr != nil {
return matchErr
}
if matched {
nextKey = edge.TargetNodeKey
break
}
}
if nextKey == "" {
return errors.New("没有满足条件的下一节点")
}
answerBytes, _ := json.Marshal(answers)
payload, _ := json.Marshal(input)
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
var next model.SOPNode
if err := tx.Where("sop_version_id = ? AND node_key = ?", updated.SOPVersionID, nextKey).First(&next).Error; err != nil {
return err
}
updates := map[string]interface{}{"current_node_key": nextKey, "answers": datatypes.JSON(answerBytes)}
if next.Type == "finish" || next.Type == "escalate" {
now := time.Now()
updates["status"] = "completed"
updates["completed_at"] = &now
updates["result"] = next.Type
updated.Status = "completed"
updated.CompletedAt = &now
updated.Result = next.Type
}
if err := tx.Model(&updated).Updates(updates).Error; err != nil {
return err
}
updated.CurrentNodeKey = nextKey
updated.Answers = datatypes.JSON(answerBytes)
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error())
return
}
h.respondRun(c, updated)
}
func (h *Handler) Finish(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var input struct {
Result string `json:"result" binding:"required,max=64"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
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})
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
_ = audit.Record(h.db, p, "finish", "sop_run", id, input)
h.Get(c)
}
func (h *Handler) Feedback(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var input struct {
Score int `json:"score" binding:"required,min=1,max=5"`
Comment string `json:"comment" binding:"max=2000"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "反馈内容不正确")
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
}
response.Created(c, item)
}
func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
var node model.SOPNode
if err := h.db.Where("sop_version_id = ? AND node_key = ?", item.SOPVersionID, item.CurrentNodeKey).First(&node).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
var fields []model.ScenarioField
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})
}
func runID(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,241 @@
package scenario
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"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"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type Handler struct {
db *gorm.DB
}
func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db}
}
type scenarioInput struct {
Name string `json:"name" binding:"required,max=128"`
Industry string `json:"industry" binding:"required,max=64"`
RoleName string `json:"role_name" binding:"required,max=64"`
Goal string `json:"goal" binding:"required,max=2000"`
TriggerText string `json:"trigger_text" binding:"required,max=2000"`
Visibility string `json:"visibility" binding:"omitempty,oneof=private team tenant"`
}
type fieldInput struct {
FieldKey string `json:"field_key" binding:"required,max=64"`
FieldName string `json:"field_name" binding:"required,max=128"`
FieldType string `json:"field_type" binding:"required,oneof=text textarea number boolean select multiselect date"`
Required bool `json:"required"`
Options json.RawMessage `json:"options"`
Validation json.RawMessage `json:"validation"`
SortOrder int `json:"sort_order"`
}
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")
if keyword := c.Query("keyword"); keyword != "" {
query = query.Where("name LIKE ? OR 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 {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败")
return
}
if err := query.Order("updated_at DESC").Find(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景失败")
return
}
response.OK(c, gin.H{"items": items, "total": total})
}
func (h *Handler) Create(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
var input scenarioInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "场景信息不完整")
return
}
visibility := input.Visibility
if visibility == "" {
visibility = "tenant"
}
item := model.Scenario{TenantID: p.TenantID, Name: input.Name, Industry: input.Industry, RoleName: input.RoleName, Goal: input.Goal, TriggerText: input.TriggerText, Visibility: visibility, Status: "draft", CreatedBy: p.UserID}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建场景失败")
return
}
_ = audit.Record(h.db, p, "create", "scenario", item.ID, input)
response.Created(c, item)
}
func (h *Handler) Get(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := idParam(c, "id")
if !ok {
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
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)
response.OK(c, gin.H{"scenario": item, "fields": fields, "sops": sops})
}
func (h *Handler) Update(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := idParam(c, "id")
if !ok {
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}
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", "场景不存在")
return
}
_ = audit.Record(h.db, p, "update", "scenario", id, input)
h.Get(c)
}
func (h *Handler) Archive(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := idParam(c, "id")
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 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
return
}
_ = audit.Record(h.db, p, "archive", "scenario", id, nil)
response.OK(c, gin.H{"id": id})
}
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 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
}
return
}
var input fieldInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
return
}
if !fieldKeyPattern.MatchString(input.FieldKey) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段标识必须以字母开头,且只能包含字母、数字和下划线")
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}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusConflict, "CREATE_FAILED", "字段标识已存在或配置不正确")
return
}
_ = audit.Record(h.db, p, "create", "scenario_field", item.ID, input)
response.Created(c, item)
}
func (h *Handler) UpdateField(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := idParam(c, "fieldId")
if !ok {
return
}
var input fieldInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段配置不正确")
return
}
if !fieldKeyPattern.MatchString(input.FieldKey) {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "字段标识必须以字母开头,且只能包含字母、数字和下划线")
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}
result := h.db.Model(&model.ScenarioField{}).Where("id = ? AND tenant_id = ?", id, p.TenantID).Updates(updates)
if result.Error != nil || result.RowsAffected == 0 {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "字段不存在")
return
}
_ = audit.Record(h.db, p, "update", "scenario_field", id, input)
var item model.ScenarioField
h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&item)
response.OK(c, item)
}
func (h *Handler) DeleteField(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := idParam(c, "fieldId")
if !ok {
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", "字段不存在")
return
}
_ = audit.Record(h.db, p, "delete", "scenario_field", id, nil)
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))
}
return datatypes.JSON(value)
}
func idParam(c *gin.Context, key string) (uint64, bool) {
id, err := strconv.ParseUint(c.Param(key), 10, 64)
if err != nil || id == 0 {
response.Error(c, http.StatusBadRequest, "INVALID_ID", "资源 ID 不正确")
return 0, false
}
return id, true
}
func notFound(c *gin.Context, err error, message string) {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", message)
return
}
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询失败")
}

View File

@@ -0,0 +1,398 @@
package sop
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"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"
)
type Handler struct {
db *gorm.DB
}
func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db}
}
type createInput struct {
Name string `json:"name" binding:"required,max=128"`
Description string `json:"description" binding:"max=2000"`
}
type graphInput struct {
StartNodeKey string `json:"start_node_key" binding:"required,max=64"`
Nodes []nodeInput `json:"nodes" binding:"required,min=1"`
Edges []edgeInput `json:"edges"`
}
type nodeInput struct {
NodeKey string `json:"node_key" binding:"required,max=64"`
Type string `json:"type" binding:"required,max=32"`
Title string `json:"title" binding:"required,max=128"`
Content string `json:"content" binding:"max=5000"`
Config json.RawMessage `json:"config"`
PositionX int `json:"position_x"`
PositionY int `json:"position_y"`
}
type edgeInput struct {
SourceNodeKey string `json:"source_node_key" binding:"required,max=64"`
TargetNodeKey string `json:"target_node_key" binding:"required,max=64"`
Condition json.RawMessage `json:"condition"`
Priority int `json:"priority"`
}
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")
scenarioID := c.Query("scenario_id")
if scenarioID == "" {
scenarioID = c.Param("id")
}
if scenarioID != "" {
query = query.Where("scenario_id = ?", scenarioID)
}
if err := query.Order("updated_at DESC").Find(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
func (h *Handler) Create(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
scenarioID, ok := parseID(c, "id")
if !ok {
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 input createInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "SOP 信息不完整")
return
}
var item model.SOP
var version model.SOPVersion
err := h.db.Transaction(func(tx *gorm.DB) error {
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "draft", CreatedBy: p.UserID}
if err := tx.Create(&item).Error; err != nil {
return err
}
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "draft", StartNodeKey: "start", CreatedBy: p.UserID}
if err := tx.Create(&version).Error; err != nil {
return err
}
nodes := []model.SOPNode{
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "start", Type: "start", Title: "开始", Content: "", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 0},
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "opening", Type: "message", Title: "开场", Content: "您好,我先了解一下具体情况。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 120},
{TenantID: p.TenantID, SOPVersionID: version.ID, NodeKey: "finish", Type: "finish", Title: "结束", Content: "本次沟通已完成。", Config: datatypes.JSON([]byte(`{}`)), PositionX: 0, PositionY: 240},
}
if err := tx.Create(&nodes).Error; err != nil {
return err
}
edges := []model.SOPEdge{
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
}
return tx.Create(&edges).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败")
return
}
_ = audit.Record(h.db, p, "create", "sop", item.ID, input)
response.Created(c, gin.H{"sop": item, "version": version})
}
func (h *Handler) Get(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
} else {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 失败")
}
return
}
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges})
}
func (h *Handler) SaveGraph(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
var input graphInput
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 = ?", id, p.TenantID, "draft").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可编辑的草稿版本")
return
}
nodes, edges := toModels(p.TenantID, version.ID, input)
problems := ValidateGraph(input.StartNodeKey, nodes, edges)
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.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
return err
}
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPNode{}).Error; err != nil {
return err
}
if err := tx.Create(&nodes).Error; err != nil {
return err
}
if len(edges) > 0 {
if err := tx.Create(&edges).Error; err != nil {
return err
}
}
return tx.Model(&version).Update("start_node_key", input.StartNodeKey).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败")
return
}
_ = audit.Record(h.db, p, "save_graph", "sop", id, gin.H{"version_id": version.ID})
h.Get(c)
}
func (h *Handler) Validate(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := parseID(c, "id")
if !ok {
return
}
_, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
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
}
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 := ValidateGraph(version.StartNodeKey, nodes, edges)
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
}
return tx.Model(&item).Update("status", "reviewing").Error
})
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
}
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
if err != nil || (version.Status != "draft" && version.Status != "reviewing") {
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
return
}
problems := ValidateGraph(version.StartNodeKey, nodes, edges)
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 := 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
}
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
}
var existing int64
h.db.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").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) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
var item model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
return item, model.SOPVersion{}, nil, nil, err
}
var version model.SOPVersion
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
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 toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
nodes := make([]model.SOPNode, 0, len(input.Nodes))
for _, item := range input.Nodes {
config := item.Config
if len(config) == 0 || !json.Valid(config) {
config = json.RawMessage(`{}`)
}
nodes = append(nodes, model.SOPNode{TenantID: tenantID, SOPVersionID: versionID, NodeKey: item.NodeKey, Type: item.Type, Title: item.Title, Content: item.Content, Config: datatypes.JSON(config), PositionX: item.PositionX, PositionY: item.PositionY})
}
edges := make([]model.SOPEdge, 0, len(input.Edges))
for _, item := range input.Edges {
condition := item.Condition
if len(condition) == 0 || !json.Valid(condition) {
condition = json.RawMessage(`{}`)
}
edges = append(edges, model.SOPEdge{TenantID: tenantID, SOPVersionID: versionID, SourceNodeKey: item.SourceNodeKey, TargetNodeKey: item.TargetNodeKey, Condition: datatypes.JSON(condition), Priority: item.Priority})
}
return nodes, edges
}
func parseID(c *gin.Context, name string) (uint64, bool) {
id, err := strconv.ParseUint(c.Param(name), 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,90 @@
package sop
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
var allowedNodeTypes = map[string]bool{"start": true, "message": true, "question": true, "form": true, "choice": true, "condition": true, "knowledge": true, "escalate": true, "finish": true}
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
var problems []string
if startNodeKey == "" {
problems = append(problems, "未设置开始节点")
}
nodeMap := make(map[string]model.SOPNode, len(nodes))
startCount := 0
finishCount := 0
for _, node := range nodes {
if node.NodeKey == "" {
problems = append(problems, "存在没有标识的节点")
continue
}
if _, exists := nodeMap[node.NodeKey]; exists {
problems = append(problems, fmt.Sprintf("节点标识重复:%s", node.NodeKey))
}
nodeMap[node.NodeKey] = node
if !allowedNodeTypes[node.Type] {
problems = append(problems, fmt.Sprintf("节点 %s 类型不支持", node.Title))
}
if node.Type == "start" {
startCount++
}
if node.Type == "finish" || node.Type == "escalate" {
finishCount++
}
}
if startCount != 1 {
problems = append(problems, "流程必须且只能包含一个开始节点")
}
if finishCount == 0 {
problems = append(problems, "流程至少需要一个结束或转人工节点")
}
if _, exists := nodeMap[startNodeKey]; !exists && startNodeKey != "" {
problems = append(problems, "开始节点不存在")
}
adjacency := make(map[string][]string)
outgoing := make(map[string]int)
for _, edge := range edges {
if _, exists := nodeMap[edge.SourceNodeKey]; !exists {
problems = append(problems, fmt.Sprintf("连线起点不存在:%s", edge.SourceNodeKey))
}
if _, exists := nodeMap[edge.TargetNodeKey]; !exists {
problems = append(problems, fmt.Sprintf("连线终点不存在:%s", edge.TargetNodeKey))
}
if len(edge.Condition) > 0 && !json.Valid(edge.Condition) {
problems = append(problems, fmt.Sprintf("连线 %s -> %s 的条件不是有效 JSON", edge.SourceNodeKey, edge.TargetNodeKey))
}
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
outgoing[edge.SourceNodeKey]++
}
for _, node := range nodes {
if node.Type != "finish" && node.Type != "escalate" && outgoing[node.NodeKey] == 0 {
problems = append(problems, fmt.Sprintf("节点“%s”没有下一步", node.Title))
}
}
visited := map[string]bool{}
var walk func(string)
walk = func(key string) {
if visited[key] {
return
}
visited[key] = true
for _, next := range adjacency[key] {
walk(next)
}
}
if startNodeKey != "" {
walk(startNodeKey)
}
for key, node := range nodeMap {
if !visited[key] {
problems = append(problems, fmt.Sprintf("节点“%s”无法从开始节点到达", node.Title))
}
}
return problems
}