Files
xinmi/internal/accesscontrol/resource_version_knowledge.go
T

678 lines
26 KiB
Go
Raw Normal View History

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(&current).Error; err != nil {
return err
}
current.LeaseUntil = nil
current.LeaseToken = ""
if copyErr != nil {
return s.failKnowledgeVersionJob(tx, &current, 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(&current).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
}