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 }