Files
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

499 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}
}