package workbench import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/Tencent/WeKnora/internal/accesspolicy" "github.com/Tencent/WeKnora/internal/application/repository" apperrors "github.com/Tencent/WeKnora/internal/errors" "github.com/Tencent/WeKnora/internal/types" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) func actionName(action string) string { if action == "review-settings" { return "保存质检规则与风格" } if name := modelActionNames[action]; name != "" { return name } if name := map[string]string{"generate": "生成内容", "generate-day": "生成每日连线框架", "decompose": "拆解内容段", "import-image": "识别导图图片", "revise": "按建议修改", "review": "跨稿质检", "compose": "组合成稿", "accept": "采用内容段", "skip": "跳过内容段", "confirm": "确认训练营方案", "confirm-day": "确认每日框架", "create": "创建", "update": "修改", "delete": "删除", "cancel": "取消任务", "pause": "暂停任务", "resume": "继续任务"}[action]; name != "" { return name } return action } var modelActionNames = map[string]string{ "knowledge_classification_recommend": "AI 推荐知识分类", "digital_human_distillation": "蒸馏数字人", "digital_human_trial": "数字人版本试写", "document_summary": "生成资料摘要", "document_auto_tag": "自动分类知识资料", "question_generation": "生成资料问题", "knowledge_qa": "知识问答", "agent_round": "Agent 推理与回答", "agent_context_compaction": "整理对话上下文", "follow_up.suggestions": "生成后续问题建议", "web_fetch_summary": "生成网页摘要", "query_rewrite": "优化检索问题", "data_analysis_plan": "生成数据分析计划", "entity_extraction": "提取知识实体", "wiki_page_modify": "修改 Wiki 页面", "wiki_chunk_citation": "整理 Wiki 来源引用", "wiki_candidate_slug": "整理 Wiki 页面名称", "wiki_summary": "生成 Wiki 摘要", "wiki_knowledge_extract": "提取 Wiki 知识", "wiki_taxonomy_plan": "规划 Wiki 分类", "wiki_deduplication": "合并 Wiki 重复内容", "wiki_index_intro": "生成 Wiki 目录介绍", "wiki_generation": "生成 Wiki 内容", } func operationsScope(ctx context.Context, db *gorm.DB, adminAll bool) (*gorm.DB, error) { t, u, admin, e := identity(ctx) if e != nil { return nil, e } q := db.Where("tenant_id = ?", t) if !admin || !adminAll { q = q.Where("owner_id = ?", u) } return q, nil } func (h *Handler) Balance(c *gin.Context) { t, u, _, e := identity(c.Request.Context()) if e != nil { respondError(c, e) return } row := Balance{TenantID: t, UserID: u} e = h.service.db.WithContext(c.Request.Context()).Where("tenant_id=? AND user_id=?", t, u).Take(&row).Error if e != nil && !errors.Is(e, gorm.ErrRecordNotFound) { respondError(c, e) return } result(c, gin.H{"balance": row.Balance, "available": max(int64(0), row.Balance-row.Reserved), "reserved": row.Reserved}) } func (h *Handler) Accounts(c *gin.Context) { t, _, admin, e := identity(c.Request.Context()) if e != nil || !admin { respondError(c, ErrForbidden) return } rows := []struct { UserID string `json:"user_id"` UserName string `json:"user_name"` Balance int64 `json:"balance"` Reserved int64 `json:"reserved"` }{} e = repository.TenantUsersQuery(h.service.db.WithContext(c.Request.Context()), t). Select("m.user_id, u.username AS user_name, COALESCE(b.balance,0) AS balance, COALESCE(b.reserved,0) AS reserved"). Joins("LEFT JOIN workbench_balances b ON b.tenant_id=m.tenant_id AND b.user_id=m.user_id"). Order("u.username,u.id").Scan(&rows).Error if e != nil { respondError(c, e) return } result(c, rows) } func (s *Service) grant(ctx context.Context, user string, amount int64, reason, key string) (*Transaction, error) { t, actor, admin, e := identity(ctx) if e != nil || !admin { return nil, ErrForbidden } if user == "" || amount <= 0 || amount > 1_000_000_000_000 || len(key) < 8 || len(key) > 120 || len([]rune(reason)) > 1000 { return nil, ErrInvalid } var result Transaction e = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var member types.TenantMember if e := repository.TenantUsersQuery(tx, t).Select("m.*").Where("m.user_id=?", user).Take(&member).Error; e != nil { return e } if e := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&Balance{TenantID: t, UserID: user}).Error; e != nil { return e } var account Balance if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND user_id=?", t, user).Take(&account).Error; e != nil { return e } key = "grant:" + key e := tx.Where("tenant_id=? AND idempotency_key=?", t, key).Take(&result).Error if e == nil { if result.UserID != user || result.Delta != amount || result.Reason != reason { return ErrConflict } return nil } if !errors.Is(e, gorm.ErrRecordNotFound) { return e } if account.Balance > 9_000_000_000_000_000-amount { return ErrInvalid } result = Transaction{ID: uuid.NewString(), TenantID: t, UserID: user, Delta: amount, Kind: "credit", Reason: reason, ActorID: actor, IdempotencyKey: key, Metadata: JSON{}, CreatedAt: time.Now()} if e = tx.Create(&result).Error; e != nil { return e } return tx.Model(&account).Updates(map[string]any{"balance": account.Balance + amount, "updated_at": time.Now()}).Error }) return &result, e } func (h *Handler) GrantBalance(c *gin.Context) { var body struct { UserID string `json:"user_id"` Amount int64 `json:"amount"` Reason string `json:"reason"` Key string `json:"idempotency_key"` } if e := c.ShouldBindJSON(&body); e != nil { respondError(c, ErrInvalid) return } row, e := h.service.grant(c.Request.Context(), body.UserID, body.Amount, strings.TrimSpace(body.Reason), body.Key) if e != nil { respondError(c, e) return } result(c, row) } func operationsPage(c *gin.Context) (int, int) { p, _ := strconv.Atoi(c.Query("page")) n, _ := strconv.Atoi(c.Query("page_size")) if p < 1 { p = 1 } if n < 1 { n = 50 } return min(p, 100000), min(n, 200) } func (h *Handler) OperationRecords(c *gin.Context) { ctx := c.Request.Context() t, u, admin, e := identity(ctx) if e != nil { respondError(c, e) return } kind := c.Param("recordKind") page, size := operationsPage(c) db := h.service.db.WithContext(ctx) var rows any var q *gorm.DB switch kind { case "transactions": if !admin { respondError(c, ErrForbidden) return } rows = &[]Transaction{} q = db.Model(&Transaction{}).Where("tenant_id=?", t) if user := c.Query("user_id"); user != "" { q = q.Where("user_id=?", user) } case "usage": if !admin { respondError(c, ErrForbidden) return } rows = &[]Usage{} q = db.Model(&Usage{}).Where("tenant_id=?", t) if user := c.Query("user_id"); user != "" { q = q.Where("owner_id=?", user) } case "audit", "system": if !admin { respondError(c, ErrForbidden) return } rows = &[]OperationLog{} q = db.Model(&OperationLog{}).Where("tenant_id=? AND kind=?", t, kind) if kind == "system" { q = q.Where("level NOT IN ?", []string{"warn", "warning"}) } if search := strings.TrimSpace(c.Query("q")); search != "" { q = q.Where("message LIKE ? OR action LIKE ? OR owner_name LIKE ? OR trace_id LIKE ?", "%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%") } case "consumption": if !accesspolicy.Allowed(ctx, "knowledge.read") { respondError(c, ErrForbidden) return } rows = &[]Consumption{} q = db.Model(&Consumption{}).Where("tenant_id=?", t) if !admin || c.Query("all_users") != "true" { q = q.Where("owner_id=?", u) } if kb := c.Query("kb_id"); kb != "" { q = q.Where("kb_id=?", kb) } if state := c.Query("status"); state != "" { q = q.Where("status=?", state) } case "memories": if !accesspolicy.Allowed(ctx, "agents.read") { respondError(c, ErrForbidden) return } rows = &[]PendingMemory{} q = db.Model(&PendingMemory{}).Where("tenant_id=? AND owner_id=?", t, u) if state := c.Query("status"); state != "" { q = q.Where("status=?", state) } default: respondError(c, ErrInvalid) return } var total int64 if e = q.Count(&total).Error; e == nil { e = q.Order("created_at DESC,id DESC").Offset((page - 1) * size).Limit(size).Find(rows).Error } if e != nil { respondError(c, e) return } if memories, ok := rows.(*[]PendingMemory); ok { h.service.decorateLegacyMemories(ctx, *memories) } c.JSON(http.StatusOK, gin.H{"success": true, "data": rows, "total": total}) } func (h *Handler) UpdateConsumption(c *gin.Context) { ctx := c.Request.Context() t, u, _, e := identity(ctx) if e != nil { respondError(c, e) return } if !accesspolicy.Allowed(ctx, "knowledge.consumption") { respondError(c, ErrForbidden) return } var body struct { IDs []string `json:"ids"` Action string `json:"action"` } if c.ShouldBindJSON(&body) != nil || len(body.IDs) < 1 || len(body.IDs) > 1000 || (body.Action != "open" && body.Action != "ban") { respondError(c, ErrInvalid) return } e = h.service.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var rows []Consumption if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND id IN ?", t, u, body.IDs).Find(&rows).Error; e != nil { return e } if len(rows) != len(body.IDs) { return ErrForbidden } for _, r := range rows { if r.Status == "reserved" && r.LeaseUntil != nil && r.LeaseUntil.After(time.Now()) { return fmt.Errorf("%w: 资料正在生成中使用,请等待任务结束", ErrConflict) } } return tx.Model(&Consumption{}).Where("tenant_id=? AND owner_id=? AND id IN ?", t, u, body.IDs).Updates(map[string]any{"status": "open", "banned": body.Action == "ban", "job_id": "", "lease_until": nil, "updated_at": time.Now()}).Error }) if e != nil { respondError(c, e) return } result(c, gin.H{"updated": len(body.IDs)}) } func (h *Handler) ReviewMemory(c *gin.Context) { ctx := c.Request.Context() t, u, _, e := identity(ctx) if e != nil { respondError(c, e) return } if !accesspolicy.Allowed(ctx, "agents.memory") { respondError(c, ErrForbidden) return } var body memoryReviewRequest if c.ShouldBindJSON(&body) != nil || (body.Status != "accepted" && body.Status != "rejected") { respondError(c, ErrInvalid) return } var row PendingMemory e = h.service.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if e := tx.Where("id=? AND tenant_id=? AND owner_id=?", c.Param("id"), t, u).Take(&row).Error; e != nil { return e } if body.ReconfirmLegacy { return h.service.reconfirmLegacyMemory(ctx, tx, &row, body) } if body.Status == "accepted" { if e := lockContentFeedbackSource(tx, &row); e != nil { return e } } if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND tenant_id=? AND owner_id=?", c.Param("id"), t, u).Take(&row).Error; e != nil { return e } if row.Status != "pending" { return ErrConflict } if body.Content != "" { row.Content = strings.TrimSpace(body.Content) } if row.Content == "" || len([]rune(row.Content)) > 12000 { return ErrInvalid } if body.Status == "accepted" { if len([]rune(row.Content)) > types.MemoryContentMaxRunes { return fmt.Errorf("%w: 长期记忆最多 %d 字,请精简后再确认;当前候选仍保留", ErrInvalid, types.MemoryContentMaxRunes) } if h.service.rememberInTransaction == nil { return fmt.Errorf("%w: 记忆服务未初始化", ErrInvalid) } item := types.MemoryItem{Kind: types.MemoryKindFact, Content: row.Content, Origin: types.MemoryOriginExplicit, Importance: 3} if strings.HasPrefix(row.SourceRef, contentMemorySourcePrefix) { item.Kind = types.MemoryKindPreference item.SourceMessageID = row.ID } stored, err := h.service.rememberInTransaction(ctx, tx, item) if err != nil { return err } if stored == nil || stored.Status != types.MemoryStatusActive { return fmt.Errorf("%w: 记忆尚未写入,请重试", ErrInvalid) } // Approval history must show the sanitized content actually retained. row.Content = stored.Content } row.Status, row.ReviewedBy, row.UpdatedAt = body.Status, u, time.Now() return tx.Save(&row).Error }) if e != nil { respondError(c, e) return } result(c, row) } func (s *Service) recordOperation(ctx context.Context, row OperationLog) error { t, ok := types.TenantIDFromContext(ctx) if !ok || t == 0 { return ErrForbidden } u, _ := types.UserIDFromContext(ctx) row.ID, row.TenantID, row.OwnerID, row.CreatedAt = uuid.NewString(), t, u, time.Now() if u != "" && !types.IsSyntheticUserID(u) { row.OwnerName = u var actor struct{ Username string } if e := s.db.WithContext(ctx).Table("users").Select("username").Where("id=?", u).Take(&actor).Error; e == nil && actor.Username != "" { row.OwnerName = actor.Username } } else { row.OwnerName = "系统任务" } if row.Metadata == nil { row.Metadata = JSON{} } return s.db.WithContext(ctx).Create(&row).Error } // The outer ErrorHandler may not have rendered a pending error yet. func operationHTTPStatus(c *gin.Context) int { if c.Writer.Written() || len(c.Errors) == 0 { return c.Writer.Status() } var appError *apperrors.AppError if errors.As(c.Errors.Last().Err, &appError) && appError.HTTPCode >= 400 && appError.HTTPCode <= 599 { return appError.HTTPCode } return http.StatusInternalServerError } // Request logs store route names and safe diagnostics, never request bodies or keys. func (h *Handler) OperationAudit() gin.HandlerFunc { return func(c *gin.Context) { started := time.Now() c.Next() ctx := c.Request.Context() _, _, _, e := identity(ctx) if e != nil { return } path := c.FullPath() if path == "" { return } status := operationHTTPStatus(c) write := c.Request.Method != "GET" && c.Request.Method != "HEAD" && c.Request.Method != "OPTIONS" if !write && status < 400 { return } if strings.Contains(path, "/notifications/") || strings.Contains(path, "/auth/") { return } action := map[string]string{"POST": "创建或执行", "PUT": "修改", "PATCH": "修改", "DELETE": "删除", "GET": "访问"}[c.Request.Method] module := "系统" for _, entry := range [][2]string{{"training-maps", "训练营导图"}, {"content-tasks", "内容任务"}, {"digital-humans", "数字人"}, {"knowledge", "知识库"}, {"agents", "Agent"}, {"skills", "技能"}, {"memory", "记忆"}, {"access", "访问控制"}, {"models", "模型"}, {"flows", "画布"}, {"operations", "业务管理"}} { if strings.Contains(c.Request.URL.Path, entry[0]) { module = entry[1] break } } if a := c.Param("action"); a != "" { action = actionName(a) } message := module + ":" + action level := "info" if status >= 400 { level = "warn" if status >= 500 { level = "error" } message += fmt.Sprintf("失败(HTTP %d)", status) } trace := c.Writer.Header().Get("X-Request-ID") if trace == "" { trace = uuid.NewString() } finish, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) defer cancel() row := OperationLog{Kind: "audit", Level: level, Action: action, Message: message, ResourceID: c.Param("id"), TraceID: trace, Status: status, Metadata: JSON{"method": c.Request.Method, "route": path, "duration_ms": time.Since(started).Milliseconds()}} if status >= 400 { row.Metadata["error_class"] = fmt.Sprintf("http_%d", status) if last := c.Errors.Last(); last != nil { for key, value := range jobErrorMetadata(last.Err) { row.Metadata[key] = value } row.Message += ";" + row.Metadata["error_message"].(string) } } if e = h.service.recordOperation(finish, row); e != nil { logJobFailure(finish, nil, "audit", e) } if status >= 400 { row.Kind = "system" if status == 403 { row.Message = "权限不足:" + module + " / " + action if label := c.GetString("required_permission_label"); label != "" { row.Message = "权限不足:" + label } } if e = h.service.recordOperation(finish, row); e != nil { logJobFailure(finish, nil, "system_log", e) } } } }