Files
xinmi/internal/workbench/handler.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

520 lines
16 KiB
Go

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})
}