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
678 lines
26 KiB
Go
678 lines
26 KiB
Go
package accesscontrol
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
|
"github.com/Tencent/WeKnora/internal/knowledgeversion"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type KnowledgeVersionJob struct {
|
|
ID string `json:"id" gorm:"primaryKey"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
OwnerID string `json:"owner_id"`
|
|
ForkID string `json:"fork_id"`
|
|
SubmissionID string `json:"submission_id,omitempty"`
|
|
Purpose string `json:"purpose"`
|
|
SourceID string `json:"source_id"`
|
|
TargetID string `json:"target_id"`
|
|
ExpectedHash string `json:"-"`
|
|
Status string `json:"status"`
|
|
Processed int `json:"processed"`
|
|
Total int `json:"total"`
|
|
Attempts int `json:"attempts"`
|
|
Error string `json:"error,omitempty"`
|
|
LeaseToken string `json:"-"`
|
|
LeaseUntil *time.Time `json:"-"`
|
|
AbandonedIDs types.StringArray `json:"-" gorm:"type:jsonb"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func (KnowledgeVersionJob) TableName() string { return "workbench_knowledge_version_jobs" }
|
|
|
|
// Aliases affect new personal branches only. Already bound KB IDs stay pinned.
|
|
type KnowledgeVersionAlias struct {
|
|
TenantID uint64 `gorm:"primaryKey"`
|
|
SourceID string `gorm:"primaryKey"`
|
|
CurrentID string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
func (KnowledgeVersionAlias) TableName() string { return "workbench_knowledge_version_aliases" }
|
|
|
|
func resolveKnowledgeVersion(tx *gorm.DB, tenant uint64, id string) (string, error) {
|
|
var alias KnowledgeVersionAlias
|
|
err := tx.Where("tenant_id=? AND source_id=?", tenant, id).First(&alias).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return id, nil
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return alias.CurrentID, nil
|
|
}
|
|
|
|
// SetKnowledgeVersionServices accepts the native service without expanding its
|
|
// broad public interface. The optional capability is implemented in a separate
|
|
// native service file so mocks do not claim to copy external objects.
|
|
func (s *Service) SetKnowledgeVersionServices(service any) error {
|
|
adapter, ok := service.(KnowledgeVersionAdapter)
|
|
if !ok {
|
|
return fmt.Errorf("native knowledge service cannot clone immutable versions")
|
|
}
|
|
s.versionKnowledge = adapter
|
|
return nil
|
|
}
|
|
|
|
type knowledgeVersionDocument struct {
|
|
Title string `json:"title"`
|
|
Type string `json:"type"`
|
|
Folder string `json:"folder"`
|
|
FileHash string `json:"file_hash"`
|
|
Chunks int `json:"chunks"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
}
|
|
type knowledgeVersionView struct {
|
|
GraphFingerprint string `json:"graph_fingerprint,omitempty"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Type string `json:"type"`
|
|
Configuration map[string]any `json:"configuration"`
|
|
Documents []knowledgeVersionDocument `json:"documents"`
|
|
Tags []types.KnowledgeTag `json:"categories"`
|
|
}
|
|
|
|
func knowledgeVersionSnapshot(ctx context.Context, tx *gorm.DB, id string) (*knowledgeVersionView, error) {
|
|
t, _ := identity(ctx)
|
|
var kb types.KnowledgeBase
|
|
if err := tx.Where("tenant_id=? AND id=?", t, id).First(&kb).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if kb.VersionState != "" && !knowledgeversion.IsWorker(ctx) {
|
|
return nil, fmt.Errorf("%w: 知识库版本仍在准备中", errConflict)
|
|
}
|
|
// Explicit fields prevent legacy storage credentials from entering API or
|
|
// immutable review payloads. File paths are hashed, never returned.
|
|
vlm := kb.VLMConfig
|
|
vlm.APIKey, vlm.BaseURL = "", ""
|
|
out := &knowledgeVersionView{Name: kb.Name, Description: kb.Description, Type: kb.Type, Documents: []knowledgeVersionDocument{}, Configuration: map[string]any{
|
|
"chunking": kb.ChunkingConfig, "image_processing": kb.ImageProcessingConfig,
|
|
"embedding_model_id": kb.EmbeddingModelID, "summary_model_id": kb.SummaryModelID,
|
|
"vector_store_id": kb.VectorStoreID, "storage_backend_id": kb.StorageBackendID,
|
|
"faq": kb.FAQConfig, "indexing": kb.IndexingStrategy,
|
|
"vlm": vlm, "asr": kb.ASRConfig, "extraction": kb.ExtractConfig,
|
|
"questions": kb.QuestionGenerationConfig, "automatic_categories": kb.AutoTagConfig,
|
|
}}
|
|
if err := tx.Where("tenant_id=? AND knowledge_base_id=?", t, id).Order("id").Find(&out.Tags).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var docs []types.Knowledge
|
|
if err := tx.Where("tenant_id=? AND knowledge_base_id=?", t, id).Order("id").Find(&docs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, doc := range docs {
|
|
if doc.ParseStatus != types.ParseStatusCompleted || doc.PendingSubtasksCount > 0 {
|
|
return nil, fmt.Errorf("%w: 知识 %s 尚未完成索引", errConflict, doc.Title)
|
|
}
|
|
h := sha256.New()
|
|
if err := json.NewEncoder(h).Encode(doc); err != nil {
|
|
return nil, err
|
|
}
|
|
var relations []types.KnowledgeTagRelation
|
|
if err := tx.Where("knowledge_id=?", doc.ID).Order("tag_id").Find(&relations).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.NewEncoder(h).Encode(relations); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_id=?", t, doc.ID).Order("id").Rows()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
count := 0
|
|
for rows.Next() {
|
|
var chunk types.Chunk
|
|
if err := tx.ScanRows(rows, &chunk); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
if chunk.IndexStatus == "processing" || chunk.IndexStatus == "failed" {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("%w: 知识 %s 的片段索引未就绪", errConflict, doc.Title)
|
|
}
|
|
if err := json.NewEncoder(h).Encode(chunk); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
if err := json.NewEncoder(h).Encode([]string{chunk.SourceContent, chunk.ContextHeader}); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
count++
|
|
}
|
|
rowErr := rows.Err()
|
|
rows.Close()
|
|
if rowErr != nil {
|
|
return nil, rowErr
|
|
}
|
|
out.Documents = append(out.Documents, knowledgeVersionDocument{Title: doc.Title, Type: doc.Type, Folder: doc.FolderPath, FileHash: doc.FileHash, Chunks: count, Fingerprint: hex.EncodeToString(h.Sum(nil))})
|
|
}
|
|
var graphErr error
|
|
out.GraphFingerprint, graphErr = knowledgeversion.GraphFingerprint(tx, t, id)
|
|
if graphErr != nil {
|
|
return nil, graphErr
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func newKnowledgeVersionBase(tx *gorm.DB, tenant uint64, owner, source, target string) error {
|
|
var kb types.KnowledgeBase
|
|
if err := tx.Where("tenant_id=? AND id=?", tenant, source).First(&kb).Error; err != nil {
|
|
return err
|
|
}
|
|
if kb.Type == types.KnowledgeBaseTypeWiki || kb.IsWikiEnabled() || kb.IsGraphEnabled() {
|
|
return fmt.Errorf("%w: Wiki 或图谱知识库暂不支持独立个人版本,请使用文档或问答知识库", errInvalid)
|
|
}
|
|
var unsupported int64
|
|
if err := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_base_id=? AND chunk_type NOT IN ?", tenant, source,
|
|
[]types.ChunkType{types.ChunkTypeText, types.ChunkTypeParentText, types.ChunkTypeSummary, types.ChunkTypeImageCaption, types.ChunkTypeImageOCR, types.ChunkTypeFAQ}).Count(&unsupported).Error; err != nil {
|
|
return err
|
|
}
|
|
if unsupported > 0 {
|
|
return fmt.Errorf("%w: 此知识库包含暂不支持独立复制的表格、图谱或 Wiki 片段", errInvalid)
|
|
}
|
|
kb.ID, kb.CreatorID, kb.VersionState = target, owner, "copying"
|
|
kb.IsTemporary, kb.IsPinned, kb.PinnedAt = true, false, nil
|
|
kb.CreatedAt, kb.UpdatedAt = time.Time{}, time.Time{}
|
|
kb.DeletedAt = gorm.DeletedAt{}
|
|
if err := tx.Create(&kb).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Create(&Publication{TenantID: tenant, Kind: "knowledge", ResourceID: target, Global: false}).Error
|
|
}
|
|
|
|
func (s *Service) reserveKnowledgeFork(tx *gorm.DB, ctx context.Context, source ResourceView, out *ResourceFork) error {
|
|
if s.versionKnowledge == nil {
|
|
return fmt.Errorf("%w: 知识库复制服务未启用", errInvalid)
|
|
}
|
|
t, u := identity(ctx)
|
|
body, hash, err := s.resourceSnapshot(tx, ctx, "knowledge", source.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*out = ResourceFork{ID: uuid.NewString(), TenantID: t, OwnerID: u, Kind: "knowledge", SourceID: source.ID, PersonalID: uuid.NewString(), Name: source.Name, BaseFingerprint: hash, BaseSnapshot: body, NoExport: true, Status: "copying"}
|
|
if err := newKnowledgeVersionBase(tx, t, u, source.ID, out.PersonalID); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(out).Error; err != nil {
|
|
return err
|
|
}
|
|
job := KnowledgeVersionJob{ID: uuid.NewString(), TenantID: t, OwnerID: u, ForkID: out.ID, Purpose: "fork", SourceID: source.ID, TargetID: out.PersonalID, ExpectedHash: hash, Status: "queued", AbandonedIDs: types.StringArray{}}
|
|
if err := tx.Create(&job).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.knowledge_copy_queued", out.ID, map[string]any{"job_id": job.ID, "source_id": source.ID})
|
|
}
|
|
|
|
func (s *Service) reserveKnowledgeSubmission(tx *gorm.DB, ctx context.Context, f ResourceFork, note string, out *ResourceSubmission) error {
|
|
if s.versionKnowledge == nil || f.Status != "ready" {
|
|
return fmt.Errorf("%w: 请等待个人知识库复制完成", errConflict)
|
|
}
|
|
t, u := identity(ctx)
|
|
source, err := sourceView(tx, t, "knowledge", f.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !source.Global {
|
|
return errVersionDenied
|
|
}
|
|
personal, err := sourceView(tx, t, "knowledge", f.PersonalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if personal.OwnerID != u {
|
|
return errVersionDenied
|
|
}
|
|
var count int64
|
|
if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND status IN ?", t, f.ID, []string{"pending", "snapshotting"}).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return fmt.Errorf("%w: 已有正在准备或待审核的版本", errConflict)
|
|
}
|
|
body, hash, err := s.resourceSnapshot(tx, ctx, "knowledge", f.PersonalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*out = ResourceSubmission{ID: uuid.NewString(), TenantID: t, OwnerID: u, ForkID: f.ID, Kind: "knowledge", SourceID: f.SourceID, PersonalID: f.PersonalID, Name: personal.Name, Status: "snapshotting", BaseFingerprint: f.BaseFingerprint, PersonalFingerprint: hash, BaseSnapshot: f.BaseSnapshot, ProposedSnapshot: body, Note: strings.TrimSpace(note), SnapshotID: uuid.NewString()}
|
|
if err := newKnowledgeVersionBase(tx, t, u, f.PersonalID, out.SnapshotID); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(out).Error; err != nil {
|
|
return err
|
|
}
|
|
job := KnowledgeVersionJob{ID: uuid.NewString(), TenantID: t, OwnerID: u, ForkID: f.ID, SubmissionID: out.ID, Purpose: "submission", SourceID: f.PersonalID, TargetID: out.SnapshotID, ExpectedHash: hash, Status: "queued", AbandonedIDs: types.StringArray{}}
|
|
if err := tx.Create(&job).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.knowledge_snapshot_queued", out.ID, map[string]any{"job_id": job.ID})
|
|
}
|
|
|
|
// Publishing promotes an already complete, independently owned physical KB.
|
|
// The former global KB remains an archived revision for existing pinned IDs;
|
|
// no files or external indexes are overwritten in a database transaction.
|
|
func (s *Service) reviewKnowledgeSubmission(tx *gorm.DB, ctx context.Context, r *ResourceSubmission, approve bool, note string) error {
|
|
t, u := identity(ctx)
|
|
if err := versionPermission(ctx, "knowledge", true); err != nil {
|
|
return err
|
|
}
|
|
if approve {
|
|
var source types.KnowledgeBase
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND id=? AND is_temporary=false", t, r.SourceID).First(&source).Error; err != nil {
|
|
return err
|
|
}
|
|
v, err := sourceView(tx, t, "knowledge", r.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !v.Global {
|
|
return errVersionDenied
|
|
}
|
|
_, hash, err := s.resourceSnapshot(tx, ctx, "knowledge", r.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hash != r.BaseFingerprint {
|
|
return fmt.Errorf("%w: 全局知识库已有变化,请同步审核基准后重新提交", errConflict)
|
|
}
|
|
var stage types.KnowledgeBase
|
|
if err := tx.Where("tenant_id=? AND id=? AND version_state='snapshot'", t, r.SnapshotID).First(&stage).Error; err != nil {
|
|
return err
|
|
}
|
|
stage.CreatorID, stage.VersionState, stage.IsTemporary = source.CreatorID, "", false
|
|
if err := tx.Save(&stage).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&types.KnowledgeBase{}).Where("tenant_id=? AND id=?", t, source.ID).Update("is_temporary", true).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&Publication{}).Where("tenant_id=? AND kind='knowledge' AND resource_id=?", t, stage.ID).Update("global", true).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&KnowledgeVersionAlias{}).Where("tenant_id=? AND current_id=?", t, source.ID).Update("current_id", stage.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tenant_id"}, {Name: "source_id"}}, DoUpdates: clause.AssignmentColumns([]string{"current_id", "updated_at"})}).Create(&KnowledgeVersionAlias{TenantID: t, SourceID: source.ID, CurrentID: stage.ID, UpdatedAt: time.Now()}).Error; err != nil {
|
|
return err
|
|
}
|
|
var hidden []ResourceHidden
|
|
if err := tx.Where("tenant_id=? AND kind='knowledge' AND resource_id=?", t, source.ID).Find(&hidden).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, row := range hidden {
|
|
row.ResourceID = stage.ID
|
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Existing personal branches follow the current review target while
|
|
// retaining their old baseline, making conflicting approval explicit.
|
|
if err := tx.Model(&ResourceFork{}).Where("tenant_id=? AND kind='knowledge' AND source_id=?", t, source.ID).Update("source_id", stage.ID).Error; err != nil {
|
|
return err
|
|
}
|
|
body, newHash, err := s.resourceSnapshot(tx, ctx, "knowledge", stage.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&ResourceFork{}).Where("tenant_id=? AND id=?", t, r.ForkID).Updates(map[string]any{"base_snapshot": body, "base_fingerprint": newHash}).Error; err != nil {
|
|
return err
|
|
}
|
|
r.PublishedID, r.Status = stage.ID, "approved"
|
|
} else {
|
|
r.Status = "rejected"
|
|
}
|
|
now := time.Now()
|
|
r.ReviewedAt = &now
|
|
r.ReviewerID = u
|
|
r.ReviewNote = strings.TrimSpace(note)
|
|
if err := tx.Save(r).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.knowledge_version_"+r.Status, r.ID, map[string]any{"source_id": r.SourceID, "published_id": r.PublishedID})
|
|
}
|
|
|
|
func (s *Service) RetryKnowledgeVersionJob(c *gin.Context) {
|
|
versionResult(c, s.retryKnowledgeVersionJob(c.Request.Context(), c.Param("id")), true)
|
|
}
|
|
|
|
func (s *Service) retryKnowledgeVersionJob(ctx context.Context, jobID string) error {
|
|
t, u := identity(ctx)
|
|
if err := versionPermission(ctx, "knowledge", true); err != nil {
|
|
return err
|
|
}
|
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var j KnowledgeVersionJob
|
|
q := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND id=?", t, jobID)
|
|
if !administrator(ctx) {
|
|
q = q.Where("owner_id=?", u)
|
|
}
|
|
if err := q.First(&j).Error; err != nil {
|
|
return err
|
|
}
|
|
if j.Status != "failed" {
|
|
return errConflict
|
|
}
|
|
if j.Purpose == "fork" {
|
|
id, err := resolveKnowledgeVersion(tx, t, j.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
j.SourceID = id
|
|
}
|
|
body, hash, err := s.resourceSnapshot(tx, ctx, "knowledge", j.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hash != j.ExpectedHash && j.Purpose != "fork" {
|
|
return fmt.Errorf("%w: 原资料已变更,请重新提交版本", errConflict)
|
|
}
|
|
if j.Purpose == "submission" {
|
|
var n int64
|
|
if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND id<>? AND status IN ?", t, j.ForkID, j.SubmissionID, []string{"pending", "snapshotting"}).Count(&n).Error; err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return errConflict
|
|
}
|
|
r := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND id=? AND status='snapshot_failed'", t, j.SubmissionID).Update("status", "snapshotting")
|
|
if r.Error != nil {
|
|
return r.Error
|
|
}
|
|
if r.RowsAffected != 1 {
|
|
return errConflict
|
|
}
|
|
} else {
|
|
if err := tx.Model(&ResourceFork{}).Where("tenant_id=? AND id=?", t, j.ForkID).Updates(map[string]any{"status": "copying", "source_id": j.SourceID, "base_snapshot": body, "base_fingerprint": hash}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Model(&j).Updates(map[string]any{"status": "queued", "error": "", "lease_until": nil, "source_id": j.SourceID, "expected_hash": hash}).Error
|
|
})
|
|
}
|
|
|
|
// Resource versions are retired. Keep queued records and historical data intact
|
|
// without starting copies or cleanup from an older installation.
|
|
func (s *Service) StartKnowledgeVersionWorker(context.Context) {}
|
|
|
|
// Persisted copy jobs must not retain permissions revoked after enqueueing.
|
|
func (s *Service) authorizeKnowledgeVersionJob(ctx context.Context, j KnowledgeVersionJob) (context.Context, error) {
|
|
var user types.User
|
|
if err := s.db.WithContext(ctx).Select("id", "tenant_id", "is_active", "is_system_admin", "can_access_all_tenants").Where("id=? AND is_active=?", j.OwnerID, true).First(&user).Error; err != nil {
|
|
return ctx, fmt.Errorf("%w: 复制任务所属账号已不可用", errVersionDenied)
|
|
}
|
|
var member types.TenantMember
|
|
if err := s.db.WithContext(ctx).Where("tenant_id=? AND user_id=? AND status=?", j.TenantID, j.OwnerID, types.TenantMemberStatusActive).First(&member).Error; err != nil {
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) || !user.CanAccessAllTenants || j.TenantID == user.TenantID {
|
|
return ctx, fmt.Errorf("%w: 复制任务所属空间权限已失效", errVersionDenied)
|
|
}
|
|
// Match request authentication without creating a persistent membership.
|
|
member.Role = types.TenantRoleAdmin
|
|
}
|
|
ctx = context.WithValue(ctx, types.TenantIDContextKey, j.TenantID)
|
|
ctx = context.WithValue(ctx, types.UserIDContextKey, j.OwnerID)
|
|
ctx = context.WithValue(ctx, types.TenantRoleContextKey, member.Role)
|
|
ctx = context.WithValue(ctx, types.SystemAdminContextKey, user.IsSystemAdmin)
|
|
snapshot, err := s.Snapshot(ctx)
|
|
if err != nil {
|
|
return ctx, err
|
|
}
|
|
ctx = accesspolicy.WithSnapshot(ctx, snapshot)
|
|
if err := versionPermission(ctx, "knowledge", true); err != nil {
|
|
return ctx, err
|
|
}
|
|
if err := versionPermission(ctx, "knowledge", false); err != nil {
|
|
return ctx, err
|
|
}
|
|
source, err := sourceView(s.db.WithContext(ctx), j.TenantID, "knowledge", j.SourceID)
|
|
if err != nil {
|
|
return ctx, err
|
|
}
|
|
if accesspolicy.Hidden(ctx, "knowledge", j.SourceID) {
|
|
return ctx, errVersionDenied
|
|
}
|
|
switch j.Purpose {
|
|
case "fork":
|
|
if !source.Global {
|
|
return ctx, fmt.Errorf("%w: 原知识库已取消全局发布", errVersionDenied)
|
|
}
|
|
case "submission":
|
|
var fork ResourceFork
|
|
if err := s.db.WithContext(ctx).Where("id=? AND tenant_id=? AND owner_id=? AND personal_id=?", j.ForkID, j.TenantID, j.OwnerID, j.SourceID).First(&fork).Error; err != nil {
|
|
return ctx, errVersionDenied
|
|
}
|
|
original, err := sourceView(s.db.WithContext(ctx), j.TenantID, "knowledge", fork.SourceID)
|
|
if err != nil || !original.Global || source.OwnerID != j.OwnerID {
|
|
return ctx, errVersionDenied
|
|
}
|
|
default:
|
|
return ctx, errVersionDenied
|
|
}
|
|
return ctx, nil
|
|
}
|
|
|
|
// RunKnowledgeVersionJob claims one persisted job. Lease takeover always uses
|
|
// a new target, so a delayed worker can only write its abandoned private KB.
|
|
func (s *Service) RunKnowledgeVersionJob(ctx context.Context) error {
|
|
if s.versionKnowledge == nil {
|
|
return errInvalid
|
|
}
|
|
var j KnowledgeVersionJob
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
now := time.Now()
|
|
claim := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("status='queued' OR (status='running' AND lease_until < ?)", now).Order("created_at").Limit(1).Find(&j)
|
|
if claim.Error != nil {
|
|
return claim.Error
|
|
}
|
|
if claim.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
if j.Attempts > 0 {
|
|
target := uuid.NewString()
|
|
if err := newKnowledgeVersionBase(tx, j.TenantID, j.OwnerID, j.SourceID, target); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errInvalid) {
|
|
return s.failKnowledgeVersionJob(tx, &j, err)
|
|
}
|
|
return err
|
|
}
|
|
j.AbandonedIDs = append(j.AbandonedIDs, j.TargetID)
|
|
j.TargetID = target
|
|
if j.Purpose == "fork" {
|
|
if err := tx.Model(&ResourceFork{}).Where("id=? AND tenant_id=?", j.ForkID, j.TenantID).Update("personal_id", j.TargetID).Error; err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := tx.Model(&ResourceSubmission{}).Where("id=? AND tenant_id=?", j.SubmissionID, j.TenantID).Update("snapshot_id", j.TargetID).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
until := now.Add(time.Minute)
|
|
j.LeaseUntil = &until
|
|
j.LeaseToken = uuid.NewString()
|
|
j.Status = "running"
|
|
j.Attempts++
|
|
j.Processed = 0
|
|
j.Error = ""
|
|
return tx.Save(&j).Error
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if j.Status == "failed" {
|
|
return nil
|
|
}
|
|
worker, cancel := context.WithTimeout(knowledgeversion.WithWorker(ctx), 2*time.Hour)
|
|
defer cancel()
|
|
worker, err = s.authorizeKnowledgeVersionJob(worker, j)
|
|
if err != nil {
|
|
return s.finishKnowledgeVersionJob(ctx, j, err)
|
|
}
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
go func() {
|
|
tick := time.NewTicker(15 * time.Second)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-worker.Done():
|
|
return
|
|
case <-tick.C:
|
|
r := s.db.WithContext(worker).Model(&KnowledgeVersionJob{}).Where("id=? AND lease_token=? AND status='running'", j.ID, j.LeaseToken).Update("lease_until", time.Now().Add(time.Minute))
|
|
if r.Error != nil || r.RowsAffected != 1 {
|
|
cancel()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
_, hash, err := s.resourceSnapshot(s.db.WithContext(worker), worker, "knowledge", j.SourceID)
|
|
if err == nil && hash != j.ExpectedHash {
|
|
err = fmt.Errorf("原资料已变更,请重新提交版本")
|
|
}
|
|
if err == nil {
|
|
err = s.versionKnowledge.CloneKnowledgeVersion(worker, j.SourceID, j.TargetID, func(n, total int) error {
|
|
if _, err := s.authorizeKnowledgeVersionJob(worker, j); err != nil {
|
|
return err
|
|
}
|
|
r := s.db.WithContext(worker).Model(&KnowledgeVersionJob{}).Where("id=? AND lease_token=? AND status='running'", j.ID, j.LeaseToken).Updates(map[string]any{"processed": n, "total": total})
|
|
if r.Error != nil {
|
|
return r.Error
|
|
}
|
|
if r.RowsAffected != 1 {
|
|
return fmt.Errorf("copy lease lost")
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
if err == nil {
|
|
_, hash, err = s.resourceSnapshot(s.db.WithContext(worker), worker, "knowledge", j.SourceID)
|
|
if err == nil && hash != j.ExpectedHash {
|
|
err = fmt.Errorf("复制期间原资料发生变化,请重新提交版本")
|
|
}
|
|
}
|
|
if err == nil {
|
|
err = verifyKnowledgeVersionCopy(s.db.WithContext(worker), j)
|
|
}
|
|
if err == nil {
|
|
_, err = s.authorizeKnowledgeVersionJob(worker, j)
|
|
}
|
|
finish, cancelFinish := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
|
defer cancelFinish()
|
|
return s.finishKnowledgeVersionJob(finish, j, err)
|
|
}
|
|
|
|
func verifyKnowledgeVersionCopy(tx *gorm.DB, j KnowledgeVersionJob) error {
|
|
var source, target []types.Knowledge
|
|
if err := tx.Where("tenant_id=? AND knowledge_base_id=?", j.TenantID, j.SourceID).Find(&source).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Where("tenant_id=? AND knowledge_base_id=?", j.TenantID, j.TargetID).Find(&target).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(source) != len(target) {
|
|
return fmt.Errorf("复制后的知识数量不一致")
|
|
}
|
|
paths := map[string]bool{}
|
|
for _, d := range source {
|
|
if d.FilePath != "" {
|
|
paths[d.FilePath] = true
|
|
}
|
|
}
|
|
for _, d := range target {
|
|
if d.ParseStatus != types.ParseStatusCompleted || paths[d.FilePath] {
|
|
return fmt.Errorf("复制文件尚未完成或仍引用原文件")
|
|
}
|
|
}
|
|
var a, b int64
|
|
if err := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_base_id=?", j.TenantID, j.SourceID).Count(&a).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_base_id=?", j.TenantID, j.TargetID).Count(&b).Error; err != nil {
|
|
return err
|
|
}
|
|
if a != b {
|
|
return fmt.Errorf("复制后的片段数量不一致")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) finishKnowledgeVersionJob(ctx context.Context, j KnowledgeVersionJob, copyErr error) error {
|
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var current KnowledgeVersionJob
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND lease_token=? AND status='running'", j.ID, j.LeaseToken).First(¤t).Error; err != nil {
|
|
return err
|
|
}
|
|
current.LeaseUntil = nil
|
|
current.LeaseToken = ""
|
|
if copyErr != nil {
|
|
return s.failKnowledgeVersionJob(tx, ¤t, copyErr)
|
|
} else {
|
|
current.Status = "completed"
|
|
current.Processed = current.Total
|
|
state := "snapshot"
|
|
temporary := true
|
|
if j.Purpose == "fork" {
|
|
state = ""
|
|
temporary = false
|
|
if err := tx.Model(&ResourceFork{}).Where("id=?", j.ForkID).Update("status", "ready").Error; err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := tx.Model(&ResourceSubmission{}).Where("id=? AND status='snapshotting'", j.SubmissionID).Update("status", "pending").Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Model(&types.KnowledgeBase{}).Where("id=? AND tenant_id=?", j.TargetID, j.TenantID).Updates(map[string]any{"version_state": state, "is_temporary": temporary}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Save(¤t).Error
|
|
})
|
|
}
|
|
|
|
func (s *Service) failKnowledgeVersionJob(tx *gorm.DB, job *KnowledgeVersionJob, cause error) error {
|
|
job.Status, job.LeaseToken, job.LeaseUntil = "failed", "", nil
|
|
job.Error = "知识库版本复制失败,请重试;若原资料已修改或删除,请重新提交。"
|
|
if job.Purpose == "fork" {
|
|
if err := tx.Model(&ResourceFork{}).Where("tenant_id=? AND id=?", job.TenantID, job.ForkID).Update("status", "failed").Error; err != nil {
|
|
return err
|
|
}
|
|
} else if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND id=?", job.TenantID, job.SubmissionID).Update("status", "snapshot_failed").Error; err != nil {
|
|
return err
|
|
}
|
|
// Keep storage/provider diagnostics in the protected audit ledger.
|
|
details, err := json.Marshal(map[string]any{"error": cause.Error()})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(&types.AuditLog{TenantID: job.TenantID, ActorUserID: job.OwnerID, ActorRole: "system", Action: "resource.knowledge_copy_failed", TargetType: "knowledge_version_job", TargetID: job.ID, Outcome: types.AuditOutcomeFailed, Details: types.JSON(details), CreatedAt: time.Now()}).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Save(job).Error
|
|
}
|