Files

499 lines
16 KiB
Go
Raw Permalink Normal View History

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