package workbench import ( "context" "errors" "fmt" "net/http" "reflect" "slices" "strconv" "strings" "time" "github.com/Tencent/WeKnora/internal/types" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Handler struct{ service *Service } func NewHandler(s *Service) *Handler { return &Handler{s} } func respondError(c *gin.Context, e error) { _ = c.Error(e) code := http.StatusInternalServerError message := "服务处理失败,请稍后重试" switch { case errors.Is(e, gorm.ErrRecordNotFound): code = 404 message = "资源不存在或无权访问" case errors.Is(e, ErrForbidden): code = 403 message = e.Error() case errors.Is(e, ErrConflict): code = 409 message = e.Error() case errors.Is(e, ErrInvalid): code = 400 message = e.Error() } c.JSON(code, gin.H{"success": false, "message": message}) } func result(c *gin.Context, v any) { c.JSON(200, gin.H{"success": true, "data": v}) } func (h *Handler) Modules(c *gin.Context) { result(c, []gin.H{{"id": "training-maps", "name": "训练营导图"}, {"id": "content-tasks", "name": "内容任务"}, {"id": "flows", "name": "画布编排"}}) } func (h *Handler) List(c *gin.Context) { k := c.Param("kind") if !validKind(k) { respondError(c, ErrInvalid) return } q, e := scope(c.Request.Context(), h.service.db.WithContext(c.Request.Context()).Model(&Resource{})) if e != nil { respondError(c, e) return } q = q.Where("kind = ?", k) if search := strings.TrimSpace(c.Query("q")); search != "" { q = q.Where("name LIKE ?", "%"+search+"%") } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) if page < 1 { page = 1 } if size < 1 || size > 100 { size = 50 } var total int64 if e = q.Count(&total).Error; e != nil { respondError(c, e) return } rows := []Resource{} if e = q.Order("updated_at DESC, id ASC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; e != nil { respondError(c, e) return } if c.Query("view") == "summary" { summaries := make([]resourceListSummary, 0, len(rows)) for _, row := range rows { summaries = append(summaries, summarizeResource(row)) } response := gin.H{"success": true, "data": summaries, "total": total} // The initial detail is opt-in and still goes through the same tenant/owner // scope query. It removes one client round trip without putting full // resource bodies into ordinary summary responses. if c.Query("include") == "initial_detail" { selectedID := strings.TrimSpace(c.Query("selected_id")) if selectedID != "" { detailScope, scopeErr := scope(c.Request.Context(), h.service.db.WithContext(c.Request.Context())) if scopeErr != nil { respondError(c, scopeErr) return } detailScope = detailScope.Where("kind = ?", k) if search := strings.TrimSpace(c.Query("q")); search != "" { detailScope = detailScope.Where("name LIKE ?", "%"+search+"%") } selectedScope := detailScope.Session(&gorm.Session{}) fallbackScope := detailScope.Session(&gorm.Session{}) var selected Resource if err := selectedScope.Where("id = ?", selectedID).First(&selected).Error; err == nil { response["initial_detail"] = selected } else if errors.Is(err, gorm.ErrRecordNotFound) { // A stale/deleted selection falls back to the first authorized // resource in the same ordering used by the summary list. fallback := fallbackScope.Order("updated_at DESC, id ASC").Limit(1).First(&selected).Error if fallback == nil { response["initial_detail"] = selected } else if errors.Is(fallback, gorm.ErrRecordNotFound) { response["initial_detail"] = nil } else { respondError(c, fallback) return } } else { respondError(c, err) return } } } c.JSON(200, response) return } c.JSON(200, gin.H{"success": true, "data": rows, "total": total}) } func (h *Handler) Get(c *gin.Context) { r, e := h.service.get(c.Request.Context(), c.Param("kind"), c.Param("id")) if e != nil { respondError(c, e) return } result(c, r) } type resourceRequest struct { Name string `json:"name"` Data JSON `json:"data"` Version int `json:"version"` Configure bool `json:"configure,omitempty"` Summary bool `json:"summary,omitempty"` } func (h *Handler) Create(c *gin.Context) { ctx := c.Request.Context() tenant, user, _, e := identity(ctx) if e != nil { respondError(c, e) return } var request resourceRequest c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 15<<20) if c.ShouldBindJSON(&request) != nil { respondError(c, ErrInvalid) return } if request.Summary { respondError(c, fmt.Errorf("%w: 请先加载完整详情后再保存", ErrInvalid)) return } r := &Resource{ID: uuid.NewString(), TenantID: tenant, OwnerID: user, Kind: c.Param("kind"), Name: strings.TrimSpace(request.Name), Status: "draft", Version: 1, Data: request.Data, CreatedAt: time.Now(), UpdatedAt: time.Now()} if r.Data == nil { r.Data = JSON{} } if e = validateLegacyMetadataUpdate(nil, r.Data); e != nil { respondError(c, e) return } var u types.User if e = h.service.db.WithContext(ctx).Select("id", "username").Where("id = ?", user).First(&u).Error; e != nil { respondError(c, e) return } r.OwnerName = u.Username // Generated state cannot be supplied while creating an unreviewed draft. delete(r.Data, "reading") if r.Kind == "training-maps" { r.Data["days"] = []Day{} } if r.Kind == "content-tasks" { r.Data["segments"] = []Segment{} r.Data["review"] = "" r.Data["composed"] = "" delete(r.Data, "quality") delete(r.Data, "flow_snapshot") delete(r.Data, "agent_snapshots") delete(r.Data, "continuity_anchors") delete(r.Data, "category_options") } if e = h.service.validate(ctx, r); e != nil { respondError(c, e) return } if e = h.service.create(ctx, r); e != nil { respondError(c, e) return } _ = h.service.event(ctx, r.ID, "create", "创建"+r.Name, "info") c.JSON(201, gin.H{"success": true, "data": r}) } func (h *Handler) Update(c *gin.Context) { ctx := c.Request.Context() r, e := h.service.get(ctx, c.Param("kind"), c.Param("id")) if e != nil { respondError(c, e) return } var request resourceRequest c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 15<<20) if c.ShouldBindJSON(&request) != nil { respondError(c, ErrInvalid) return } if request.Version != r.Version { respondError(c, ErrConflict) return } if request.Summary { respondError(c, fmt.Errorf("%w: 请先加载完整详情后再保存", ErrInvalid)) return } beforeData := compact(r.Data) if e = applyManualUpdate(r, request); e != nil { respondError(c, e) return } if request.Configure || beforeData != compact(r.Data) { ctx = context.WithValue(ctx, configurationSaveKey{}, true) if e = h.service.validate(ctx, r); e != nil { respondError(c, e) return } if r.Status == "needs_configuration" { r.Status = "draft" } } else if strings.TrimSpace(r.Name) == "" || len([]rune(r.Name)) > 200 { respondError(c, fmt.Errorf("%w: 名称必填且最多 200 字", ErrInvalid)) return } if e = h.service.save(ctx, r, request.Version); e != nil { respondError(c, e) return } _ = h.service.event(ctx, r.ID, "update", "修改"+r.Name, "info") result(c, r) } func (h *Handler) Delete(c *gin.Context) { ctx := c.Request.Context() var r *Resource e := h.service.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var err error r, err = lockResourceForDeletion(ctx, tx, c.Param("kind"), c.Param("id")) if err != nil { return err } if err = requireIdle(tx, r.TenantID, r.ID); err != nil { return err } if err = archiveResource(ctx, tx, r); err != nil { return err } // Content tasks cannot be referenced by other workbench resources. if r.Kind == "content-tasks" { return deleteResource(ctx, tx, r) } var related []Resource if err := tx.Where("tenant_id = ? AND kind = ?", r.TenantID, "content-tasks").Order("id ASC").Find(&related).Error; err != nil { return err } for _, other := range related { d, _ := decode[TaskData](other.Data) if d.MapID != r.ID && d.FlowID != r.ID { continue } if c.Query("detach") != "true" { return fmt.Errorf("%w: 此资源已绑定内容任务,请选择一键解绑后删除", ErrConflict) } _, _, admin, _ := identity(ctx) if other.OwnerID != r.OwnerID && !admin { return fmt.Errorf("%w: 其他用户的内容任务仍在使用此资源,请先由任务所有者更换画布或解除绑定", ErrConflict) } // Re-read after locking: another edit may have changed the binding. if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", other.ID, r.TenantID).First(&other).Error; err != nil { return err } d, err = decode[TaskData](other.Data) if err != nil { return ErrInvalid } if d.MapID != r.ID && d.FlowID != r.ID { continue } if err := requireIdle(tx, r.TenantID, other.ID); err != nil { return err } if err := detachResourceForDeletion(ctx, tx, r, &other); err != nil { return err } } return deleteResource(ctx, tx, r) }) if e != nil { respondError(c, e) return } result(c, gin.H{"deleted": true}) } func (h *Handler) Action(c *gin.Context) { var request ActionRequest c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) if c.ShouldBindJSON(&request) != nil { respondError(c, ErrInvalid) return } j, e := h.service.submit(c.Request.Context(), c.Param("kind"), c.Param("id"), c.Param("action"), request) if e != nil { respondError(c, e) return } c.JSON(202, gin.H{"success": true, "data": j}) } func deleteResource(ctx context.Context, tx *gorm.DB, r *Resource) error { if err := invalidateDeletedContentFeedback(tx, r); err != nil { return err } q := tx.Where("id = ? AND tenant_id = ? AND version = ?", r.ID, r.TenantID, r.Version).Delete(&Resource{}) if q.Error != nil { return q.Error } if q.RowsAffected != 1 { return ErrConflict } return (&Service{db: tx}).event(ctx, r.ID, "delete", "删除"+r.Name, "info") } // PUT changes configuration; generated/accepted content must go through actions. func applyManualUpdate(r *Resource, request resourceRequest) error { if request.Data == nil { return ErrInvalid } if err := validateLegacyMetadataUpdate(r.Data, request.Data); err != nil { return err } if compact(r.Data) == compact(request.Data) { r.Name = strings.TrimSpace(request.Name) return nil } switch r.Kind { case "training-maps": before, err := decode[MapData](r.Data) if err != nil { return ErrInvalid } after, err := decode[MapData](request.Data) if err != nil || !reflect.DeepEqual(before.Days, after.Days) || !reflect.DeepEqual(before.Reading, after.Reading) { return fmt.Errorf("%w: 导图内容及确认状态请通过生成或确认操作修改", ErrInvalid) } if !reflect.DeepEqual(before, after) { for i := range after.Days { after.Days[i].Confirmed = false } r.Status = "draft" if len(after.Days) > 0 { r.Status = "needs_review" } } r.Data = resourceData(r, after) case "content-tasks": before, err := decode[TaskData](r.Data) if err != nil { return ErrInvalid } after, err := decode[TaskData](request.Data) if before.QualitySettings != after.QualitySettings { return fmt.Errorf("%w: 质检规则与风格请通过质检设置保存", ErrInvalid) } if !reflect.DeepEqual(before.CategoryOptions, after.CategoryOptions) { return ErrInvalid } if !reflect.DeepEqual(before.AgentCategoryOptions, after.AgentCategoryOptions) { return ErrInvalid } if !reflect.DeepEqual(before.ContinuityAnchors, after.ContinuityAnchors) { return ErrInvalid } if !reflect.DeepEqual(before.Reading, after.Reading) { return ErrInvalid } if err != nil || len(before.Segments) != len(after.Segments) || before.Review != after.Review || before.Composed != after.Composed || !reflect.DeepEqual(before.Quality, after.Quality) || !reflect.DeepEqual(before.FlowSnapshot, after.FlowSnapshot) || !reflect.DeepEqual(before.AgentSnapshots, after.AgentSnapshots) { return fmt.Errorf("%w: 内容段和质检结果请通过任务操作修改", ErrInvalid) } firstChanged := -1 for i := range before.Segments { original, proposed := before.Segments[i], after.Segments[i] if (original.AgentID != proposed.AgentID || original.CategoryID != proposed.CategoryID) && firstChanged < 0 { firstChanged = i } options := before.CategoryOptions if before.AgentCategoryOptions != nil { options = before.AgentCategoryOptions[proposed.AgentID] } if proposed.CategoryID != "" && !slices.ContainsFunc(options, func(option SegmentCategory) bool { return option.ID == proposed.CategoryID }) { return fmt.Errorf("%w: 所选分类不属于本段 Agent 的资料范围", ErrInvalid) } proposed.AgentID = original.AgentID proposed.CategoryID = original.CategoryID if !reflect.DeepEqual(original, proposed) { return fmt.Errorf("%w: 内容段只允许更换负责 Agent 和知识分类;生成及确认请使用任务操作", ErrInvalid) } } beforeConfig, afterConfig := before, after beforeConfig.Segments, afterConfig.Segments = nil, nil if !reflect.DeepEqual(beforeConfig, afterConfig) { durationChanged := before.DurationMinutes != after.DurationMinutes if durationChanged { after.Review, after.Composed = "", "" } after.Quality = nil after.FlowSnapshot = nil after.AgentSnapshots = nil for i := range after.Segments { if durationChanged { after.Segments[i].Review = "" } if after.Segments[i].Status != "skipped" && after.Segments[i].Content != "" { after.Segments[i].Status = "needs_review" } } r.Status = "draft" if len(after.Segments) > 0 { r.Status = "needs_review" } } else if firstChanged >= 0 { invalidateTask(&after, r, firstChanged) } r.Data = resourceData(r, after) default: r.Data = resourceData(r, request.Data) } r.Name = strings.TrimSpace(request.Name) return nil } func (h *Handler) Jobs(c *gin.Context) { ctx := c.Request.Context() r, err := h.service.get(ctx, c.Param("kind"), c.Param("id")) if err != nil { respondError(c, err) return } q, err := scope(ctx, h.service.db.WithContext(ctx).Model(&Job{})) if err != nil { respondError(c, err) return } q = q.Where("resource_id = ? AND kind = ?", r.ID, r.Kind) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) if page < 1 { page = 1 } if size < 1 || size > 100 { size = 50 } var total int64 if err = q.Count(&total).Error; err != nil { respondError(c, err) return } rows := []Job{} if err = q.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { respondError(c, err) return } c.JSON(200, gin.H{"success": true, "data": rows, "total": total}) } func (h *Handler) Job(c *gin.Context) { q, e := scope(c.Request.Context(), h.service.db.WithContext(c.Request.Context())) if e != nil { respondError(c, e) return } var j Job if e = q.Where("id = ?", c.Param("id")).First(&j).Error; e != nil { respondError(c, e) return } result(c, j) } func (h *Handler) Notifications(c *gin.Context) { t, u, _, e := identity(c.Request.Context()) if e != nil { respondError(c, e) return } rows := []Event{} if e = h.service.db.WithContext(c.Request.Context()).Where("tenant_id = ? AND owner_id = ?", t, u).Order("created_at DESC").Limit(100).Find(&rows).Error; e != nil { respondError(c, e) return } result(c, rows) } func (h *Handler) ReadAll(c *gin.Context) { t, u, _, e := identity(c.Request.Context()) if e != nil { respondError(c, e) return } q := h.service.db.WithContext(c.Request.Context()).Model(&Event{}).Where("tenant_id = ? AND owner_id = ? AND read = ?", t, u, false).Update("read", true) if q.Error != nil { respondError(c, q.Error) return } result(c, gin.H{"updated": q.RowsAffected}) }