package repository import ( "context" "encoding/json" "errors" "fmt" "github.com/Tencent/WeKnora/internal/accesspolicy" "sort" "strings" "time" "github.com/Tencent/WeKnora/internal/types" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) type DigitalHumanRepository struct{ db *gorm.DB } func NewDigitalHumanRepository(db *gorm.DB) *DigitalHumanRepository { return &DigitalHumanRepository{db: db} } func dhScope(db *gorm.DB, scope types.DigitalHumanScope) *gorm.DB { return db.Where("tenant_id = ? AND owner_id = ?", scope.TenantID, scope.OwnerID) } func dhReadScope(db *gorm.DB, scope types.DigitalHumanScope) *gorm.DB { if scope.ReadAll { return db.Where("tenant_id = ?", scope.TenantID) } q := db.Where("tenant_id = ?", scope.TenantID).Where("owner_id = ? OR id IN ?", scope.OwnerID, accesspolicy.SharedIDs(db.Statement.Context, "digital-humans")) if ids := accesspolicy.HiddenIDs(db.Statement.Context, "digital-humans"); len(ids) > 0 { q = q.Where("id NOT IN ?", ids) } return q } func (r *DigitalHumanRepository) List(ctx context.Context, scope types.DigitalHumanScope, offset, limit int) ([]types.DigitalHuman, error) { result := []types.DigitalHuman{} err := dhReadScope(r.db.WithContext(ctx), scope).Order("created_at DESC, id ASC").Offset(offset).Limit(limit).Find(&result).Error if err == nil { humans := make([]*types.DigitalHuman, len(result)) for i := range result { humans[i] = &result[i] } err = r.populateCreatorNames(ctx, humans) } return result, err } func (r *DigitalHumanRepository) populateCreatorNames(ctx context.Context, humans []*types.DigitalHuman) error { if len(humans) == 0 { return nil } ids := make([]string, 0, len(humans)) for _, human := range humans { ids = append(ids, human.OwnerID) } var users []struct{ ID, Username string } if err := r.db.WithContext(ctx).Model(&types.User{}).Select("id, username").Where("id IN ?", ids).Find(&users).Error; err != nil { return err } names := make(map[string]string, len(users)) for _, user := range users { names[user.ID] = user.Username } for _, human := range humans { human.CreatorName = names[human.OwnerID] } return nil } func (r *DigitalHumanRepository) Get(ctx context.Context, scope types.DigitalHumanScope, id string) (*types.DigitalHumanDetail, error) { result := &types.DigitalHumanDetail{DigitalHuman: &types.DigitalHuman{}, Versions: []types.DigitalHumanVersion{}, Bindings: []types.DigitalHumanBinding{}} err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := dhReadScope(tx, scope).First(result.DigitalHuman, "id = ?", id).Error; err != nil { return dhNotFound(err) } ownerScope := types.DigitalHumanScope{TenantID: result.TenantID, OwnerID: result.OwnerID} // Raw source documents are deliberately excluded from normal detail responses. if err := dhScope(tx, ownerScope).Omit("sources").Where("digital_human_id = ?", id).Order("number DESC").Limit(100).Find(&result.Versions).Error; err != nil { return err } bindings := tx.Where("tenant_id = ? AND digital_human_id = ?", scope.TenantID, id) if !scope.ReadAll && scope.OwnerID != result.OwnerID { bindings = bindings.Where("owner_id = ?", scope.OwnerID) } if err := bindings.Find(&result.Bindings).Error; err != nil { return err } var jobs []types.DigitalHumanJob if err := dhScope(tx, ownerScope).Where("digital_human_id = ?", id).Order("created_at DESC, id ASC").Limit(1).Find(&jobs).Error; err != nil { return err } if len(jobs) > 0 { result.LastJob = &jobs[0] } return nil }) if err == nil { err = r.populateCreatorNames(ctx, []*types.DigitalHuman{result.DigitalHuman}) } return result, err } func dhNotFound(err error) error { if errors.Is(err, gorm.ErrRecordNotFound) { return types.ErrDigitalHumanNotFound } return err } // All mutations lock the owner-scoped human first; related agents and versions // are committed in the same transaction, so review cannot partly publish. func (r *DigitalHumanRepository) mutate(ctx context.Context, scope types.DigitalHumanScope, id string, fn func(*gorm.DB, *types.DigitalHuman) error) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var human types.DigitalHuman if err := dhScope(tx.Clauses(clause.Locking{Strength: "UPDATE"}), scope).First(&human, "id = ?", id).Error; err != nil { return dhNotFound(err) } return fn(tx, &human) }) } func (r *DigitalHumanRepository) Create(ctx context.Context, human *types.DigitalHuman, persona map[string]string) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { base := "按用户当前任务回答,保证上下文衔接和事实准确。数字人表达档案须经人工审核后生效。" agent := &types.CustomAgent{ID: human.PrimaryAgentID, TenantID: human.TenantID, CreatedBy: human.OwnerID, Name: human.Name, Description: "数字人「" + human.Name + "」的默认对话 Agent", Avatar: human.Avatar, Config: types.CustomAgentConfig{AgentMode: types.AgentModeSmartReasoning, AgentType: types.AgentTypeCustom, ModelID: human.ModelID, SystemPrompt: base, KBSelectionMode: "none", SkillsSelectionMode: "none", MCPSelectionMode: "none"}} agent.EnsureDefaults() if err := tx.Create(agent).Error; err != nil { return err } if err := tx.Create(human).Error; err != nil { return err } binding := &types.DigitalHumanBinding{ID: uuid.NewString(), TenantID: human.TenantID, OwnerID: human.OwnerID, DigitalHumanID: human.ID, AgentID: agent.ID, BasePrompt: base} if err := tx.Create(binding).Error; err != nil { return err } return tx.Create(&types.DigitalHumanVersion{ID: uuid.NewString(), TenantID: human.TenantID, OwnerID: human.OwnerID, DigitalHumanID: human.ID, Number: 1, Status: "draft", Persona: persona, Sources: []types.DigitalHumanSource{}}).Error }) } func (r *DigitalHumanRepository) Update(ctx context.Context, scope types.DigitalHumanScope, id string, revision int64, name, description, avatar, modelID string, persona map[string]string) error { return r.mutate(ctx, scope, id, func(tx *gorm.DB, human *types.DigitalHuman) error { if human.Revision != revision || human.ProcessingJobID != "" { return types.ErrDigitalHumanConflict } if persona != nil { var previous types.DigitalHumanVersion if err := dhScope(tx, scope).First(&previous, "digital_human_id = ? AND number = ?", id, human.LatestVersion).Error; err != nil { return err } human.LatestVersion++ v := types.DigitalHumanVersion{ID: uuid.NewString(), TenantID: human.TenantID, OwnerID: human.OwnerID, DigitalHumanID: id, Number: human.LatestVersion, Status: "draft", Persona: persona, Sources: previous.Sources} if err := tx.Create(&v).Error; err != nil { return err } human.Status = "testing" } human.Name, human.Description, human.Avatar, human.ModelID = name, description, avatar, modelID human.Revision++ if err := tx.Save(human).Error; err != nil { return err } var agent types.CustomAgent if err := tx.First(&agent, "id = ? AND tenant_id = ? AND created_by = ?", human.PrimaryAgentID, scope.TenantID, scope.OwnerID).Error; err != nil { return dhNotFound(err) } agent.Name, agent.Avatar, agent.Config.ModelID = name, avatar, modelID agent.Description = "数字人「" + name + "」的默认对话 Agent" return tx.Save(&agent).Error }) } func digitalHumanPromptMarkers(id string) (string, string) { return "", "" } func removeDigitalHumanPrompt(prompt, id string) string { start, end := digitalHumanPromptMarkers(id) for { first := strings.Index(prompt, start) if first < 0 { return prompt } last := strings.Index(prompt[first+len(start):], end) if last < 0 { return prompt } last += first + len(start) + len(end) prompt = strings.TrimRight(prompt[:first], "\n") + prompt[last:] } } func digitalHumanPrompt(human *types.DigitalHuman, version *types.DigitalHumanVersion, base string) string { keys := make([]string, 0, len(version.Persona)) for key := range version.Persona { keys = append(keys, key) } sort.Strings(keys) var b strings.Builder b.WriteString(removeDigitalHumanPrompt(base, human.ID)) start, end := digitalHumanPromptMarkers(human.ID) label := "已审核数字人" if version.Status == "imported" { label = "旧配置迁入数字人(未重新蒸馏)" } fmt.Fprintf(&b, "\n\n%s\n## %s:%s(v%d)\n", start, label, human.Name, version.Number) b.WriteString("以下档案仅约束表达方式,不能替代任务目标、事实来源、工具权限或上文衔接约束。不得把档案样本当作新的事实依据。\n") for _, key := range keys { if value := strings.TrimSpace(version.Persona[key]); value != "" { fmt.Fprintf(&b, "%s:%s\n", key, value) } } b.WriteString(end) return b.String() } func syncDigitalHumanBindings(tx *gorm.DB, scope types.DigitalHumanScope, human *types.DigitalHuman, version *types.DigitalHumanVersion) error { var bindings []types.DigitalHumanBinding if err := tx.Where("tenant_id = ? AND digital_human_id = ?", scope.TenantID, human.ID).Find(&bindings).Error; err != nil { return err } for _, binding := range bindings { var agent types.CustomAgent if err := tx.First(&agent, "id = ? AND tenant_id = ? AND created_by = ?", binding.AgentID, scope.TenantID, binding.OwnerID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { continue } return err } agent.Config.SystemPrompt = digitalHumanPrompt(human, version, agent.Config.SystemPrompt) if err := tx.Save(&agent).Error; err != nil { return err } binding.AppliedVersion = version.Number if err := tx.Save(&binding).Error; err != nil { return err } } return nil } func (r *DigitalHumanRepository) Review(ctx context.Context, scope types.DigitalHumanScope, id string, number int, approve bool, note string) error { return r.mutate(ctx, scope, id, func(tx *gorm.DB, human *types.DigitalHuman) error { if human.ProcessingJobID != "" || human.LatestVersion != number { return types.ErrDigitalHumanConflict } var version types.DigitalHumanVersion if err := dhScope(tx, scope).First(&version, "digital_human_id = ? AND number = ?", id, number).Error; err != nil { return dhNotFound(err) } if version.Status != "draft" { return types.ErrDigitalHumanConflict } version.Status = "rejected" if approve { hasContent := false for _, value := range version.Persona { hasContent = hasContent || strings.TrimSpace(value) != "" } if !hasContent { return &types.DigitalHumanInputError{Message: "人格档案为空,请先蒸馏或编辑"} } version.Status, human.Status, human.ActiveVersion = "approved", "active", number if err := syncDigitalHumanBindings(tx, scope, human, &version); err != nil { return err } } else if human.ActiveVersion > 0 { human.Status = "active" } else { human.Status = "draft" } now := time.Now() version.ReviewedAt, version.ReviewNote = &now, note if err := tx.Save(&version).Error; err != nil { return err } human.Revision++ return tx.Save(human).Error }) } func (r *DigitalHumanRepository) Bind(ctx context.Context, scope types.DigitalHumanScope, id, agentID string) error { return r.bindingTransaction(ctx, scope, id, agentID, false, func(tx *gorm.DB, human *types.DigitalHuman, agent *types.CustomAgent) error { var existing types.DigitalHumanBinding err := tx.Where("tenant_id = ? AND agent_id = ?", scope.TenantID, agentID).First(&existing).Error if err == nil { if existing.DigitalHumanID == id { return nil } return types.ErrDigitalHumanConflict } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } binding := types.DigitalHumanBinding{ID: uuid.NewString(), TenantID: scope.TenantID, OwnerID: agent.CreatedBy, DigitalHumanID: id, AgentID: agentID, BasePrompt: agent.Config.SystemPrompt} if human.ActiveVersion > 0 { var version types.DigitalHumanVersion if err := tx.Where("tenant_id = ? AND owner_id = ?", human.TenantID, human.OwnerID).First(&version, "digital_human_id = ? AND number = ?", id, human.ActiveVersion).Error; err != nil { return err } agent.Config.SystemPrompt, binding.AppliedVersion = digitalHumanPrompt(human, &version, binding.BasePrompt), version.Number if err := tx.Save(agent).Error; err != nil { return err } } return tx.Create(&binding).Error }) } func (r *DigitalHumanRepository) Unbind(ctx context.Context, scope types.DigitalHumanScope, id, agentID string) error { return r.bindingTransaction(ctx, scope, id, agentID, true, func(tx *gorm.DB, human *types.DigitalHuman, agent *types.CustomAgent) error { if human.PrimaryAgentID == agentID { return &types.DigitalHumanInputError{Message: "默认同名 Agent 随数字人管理,请删除数字人时一并解绑"} } var binding types.DigitalHumanBinding if err := tx.Where("tenant_id = ? AND owner_id = ?", scope.TenantID, agent.CreatedBy).First(&binding, "digital_human_id = ? AND agent_id = ?", id, agentID).Error; err != nil { return dhNotFound(err) } if err := restoreDigitalHumanAgent(tx, scope, &binding); err != nil { return err } return tx.Delete(&binding).Error }) } // Mounting a persona changes the target Agent, not the shared persona. Lock // the human first so reviews/deletion cannot race a new binding. func (r *DigitalHumanRepository) bindingTransaction(ctx context.Context, scope types.DigitalHumanScope, id, agentID string, unbind bool, fn func(*gorm.DB, *types.DigitalHuman, *types.CustomAgent) error) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var human types.DigitalHuman q := tx.Clauses(clause.Locking{Strength: "UPDATE"}) if unbind { q = q.Where("tenant_id = ?", scope.TenantID) } else { q = dhReadScope(q, scope) } if err := q.First(&human, "id = ?", id).Error; err != nil { return dhNotFound(err) } var agent types.CustomAgent q = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id = ? AND id = ? AND is_builtin = ?", scope.TenantID, agentID, false) if !scope.ReadAll { q = q.Where("created_by = ?", scope.OwnerID) } if err := q.First(&agent).Error; err != nil { return dhNotFound(err) } return fn(tx, &human, &agent) }) } func restoreDigitalHumanAgent(tx *gorm.DB, scope types.DigitalHumanScope, binding *types.DigitalHumanBinding) error { var agent types.CustomAgent err := tx.First(&agent, "id = ? AND tenant_id = ? AND created_by = ?", binding.AgentID, scope.TenantID, binding.OwnerID).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } if binding.AppliedVersion == 0 { return nil } agent.Config.SystemPrompt = removeDigitalHumanPrompt(agent.Config.SystemPrompt, binding.DigitalHumanID) return tx.Save(&agent).Error } func (r *DigitalHumanRepository) Delete(ctx context.Context, scope types.DigitalHumanScope, id string) error { _, err := r.DeleteWithIMChannels(ctx, scope, id) return err } func (r *DigitalHumanRepository) DeleteWithIMChannels(ctx context.Context, scope types.DigitalHumanScope, id string) ([]string, error) { var channelIDs []string err := r.mutate(ctx, scope, id, func(tx *gorm.DB, human *types.DigitalHuman) error { if human.ProcessingJobID != "" { return types.ErrDigitalHumanConflict } var bindings []types.DigitalHumanBinding if err := tx.Where("tenant_id = ? AND digital_human_id = ?", scope.TenantID, id).Find(&bindings).Error; err != nil { return err } for _, binding := range bindings { if binding.AgentID != human.PrimaryAgentID { if err := restoreDigitalHumanAgent(tx, scope, &binding); err != nil { return err } } } if err := tx.Where("id = ? AND tenant_id = ? AND created_by = ?", human.PrimaryAgentID, scope.TenantID, scope.OwnerID).Delete(&types.CustomAgent{}).Error; err != nil { return err } if err := detachFusionAgent(tx, human.PrimaryAgentID, scope.TenantID); err != nil { return err } var err error channelIDs, err = softDeleteAgentIMChannels(tx, human.PrimaryAgentID, scope.TenantID) if err != nil { return err } if err := tx.Where("tenant_id = ? AND digital_human_id = ?", scope.TenantID, id).Delete(&types.DigitalHumanBinding{}).Error; err != nil { return err } if err := dhScope(tx, scope).Where("digital_human_id = ?", id).Delete(&types.DigitalHumanVersion{}).Error; err != nil { return err } if err := dhScope(tx, scope).Where("digital_human_id = ?", id).Delete(&types.DigitalHumanJob{}).Error; err != nil { return err } return tx.Delete(human).Error }) if err != nil { return nil, err } return channelIDs, nil } func (r *DigitalHumanRepository) Sources(ctx context.Context, scope types.DigitalHumanScope, id string, number int) (*types.DigitalHumanVersion, error) { var version types.DigitalHumanVersion err := dhScope(r.db.WithContext(ctx), scope).First(&version, "digital_human_id = ? AND number = ?", id, number).Error return &version, dhNotFound(err) } func (r *DigitalHumanRepository) StartJob(ctx context.Context, scope types.DigitalHumanScope, id string, revision int64, job *types.DigitalHumanJob) error { return r.mutate(ctx, scope, id, func(tx *gorm.DB, human *types.DigitalHuman) error { if human.ProcessingJobID != "" || human.Revision != revision { return types.ErrDigitalHumanConflict } if err := tx.Create(job).Error; err != nil { return err } human.ProcessingJobID, human.Status = job.ID, "distilling" human.Revision++ return tx.Save(human).Error }) } func (r *DigitalHumanRepository) Job(ctx context.Context, scope types.DigitalHumanScope, id, jobID string) (*types.DigitalHumanJob, error) { var job types.DigitalHumanJob err := dhReadScope(r.db.WithContext(ctx), scope).First(&job, "id = ? AND digital_human_id = ?", jobID, id).Error return &job, dhNotFound(err) } func (r *DigitalHumanRepository) JobProgress(ctx context.Context, scope types.DigitalHumanScope, jobID string, progress int, stage string) error { result := dhScope(r.db.WithContext(ctx).Model(&types.DigitalHumanJob{}), scope). Where("id = ? AND status = ? AND expires_at > ?", jobID, "running", time.Now()). Updates(map[string]interface{}{"progress": progress, "stage": stage, "expires_at": time.Now().Add(types.DigitalHumanJobLease)}) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return types.ErrDigitalHumanConflict } return nil } func (r *DigitalHumanRepository) DistillCheckpoint(ctx context.Context, scope types.DigitalHumanScope, id, fingerprint string) (*types.DigitalHumanDistillCheckpoint, error) { var job types.DigitalHumanJob err := dhScope(r.db.WithContext(ctx), scope). Where("digital_human_id = ? AND checkpoint_fingerprint = ? AND status = ? AND checkpoint IS NOT NULL", id, fingerprint, "failed"). Order("updated_at DESC, id DESC").Take(&job).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return job.Checkpoint, err } // A checkpoint is private retry state. Locking the human fences superseded // workers without turning partially distilled evidence into a visible version. func (r *DigitalHumanRepository) SaveDistillCheckpoint(ctx context.Context, scope types.DigitalHumanScope, id, jobID, fingerprint string, checkpoint *types.DigitalHumanDistillCheckpoint) error { if len(fingerprint) != 64 || checkpoint == nil || checkpoint.CompletedBatches < 0 || checkpoint.CompletedBatches > checkpoint.TotalBatches || checkpoint.TotalBatches < 1 { return types.ErrDigitalHumanConflict } encoded, err := json.Marshal(checkpoint) if err != nil { return err } return r.mutate(ctx, scope, id, func(tx *gorm.DB, human *types.DigitalHuman) error { if human.ProcessingJobID != jobID { return types.ErrDigitalHumanConflict } var job types.DigitalHumanJob if err := dhScope(tx, scope).Where("id = ? AND status = ? AND expires_at > ?", jobID, "running", time.Now()).Take(&job).Error; err != nil { return types.ErrDigitalHumanConflict } if job.CheckpointFingerprint != "" && job.CheckpointFingerprint != fingerprint { return types.ErrDigitalHumanConflict } if job.Checkpoint != nil && (job.Checkpoint.TotalBatches != checkpoint.TotalBatches || job.Checkpoint.CompletedBatches > checkpoint.CompletedBatches) { return types.ErrDigitalHumanConflict } result := dhScope(tx.Model(&types.DigitalHumanJob{}), scope). Where("id = ? AND status = ? AND expires_at > ?", jobID, "running", time.Now()).Updates(map[string]interface{}{ "checkpoint_fingerprint": fingerprint, "checkpoint": string(encoded), "expires_at": time.Now().Add(types.DigitalHumanJobLease), }) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return types.ErrDigitalHumanConflict } return nil }) } func (r *DigitalHumanRepository) FinishJob(ctx context.Context, scope types.DigitalHumanScope, id, jobID string, version *types.DigitalHumanVersion, failure string) error { return r.mutate(ctx, scope, id, func(tx *gorm.DB, human *types.DigitalHuman) error { if human.ProcessingJobID != jobID { return types.ErrDigitalHumanConflict } var job types.DigitalHumanJob if err := dhScope(tx, scope).First(&job, "id = ? AND status = ?", jobID, "running").Error; err != nil { return dhNotFound(err) } job.Status, job.Stage, job.Error = "failed", "蒸馏失败", failure if version != nil && time.Now().After(job.ExpiresAt) { version = nil job.Error = "蒸馏已超时,请重新提交。已审核版本保持可用。" } if version != nil { version.ID, version.TenantID, version.OwnerID, version.DigitalHumanID = uuid.NewString(), scope.TenantID, scope.OwnerID, id version.Number, version.Status = human.LatestVersion+1, "draft" if err := tx.Create(version).Error; err != nil { return err } human.LatestVersion, human.Status = version.Number, "testing" job.Status, job.Progress, job.Stage, job.Version = "completed", 100, "蒸馏完成,等待人工审核", version.Number } else if human.ActiveVersion > 0 { human.Status = "active" } else { human.Status = "draft" } if err := tx.Save(&job).Error; err != nil { return err } human.ProcessingJobID = "" human.Revision++ return tx.Save(human).Error }) } // Recovery is scoped to readable humans but finalization still uses each actual owner. func (r *DigitalHumanRepository) RecoverExpiredJobs(ctx context.Context, scope types.DigitalHumanScope) error { var jobs []types.DigitalHumanJob if err := dhReadScope(r.db.WithContext(ctx), scope).Where("status = ? AND expires_at <= ?", "running", time.Now()).Limit(100).Find(&jobs).Error; err != nil { return err } for _, job := range jobs { ownerScope := types.DigitalHumanScope{TenantID: job.TenantID, OwnerID: job.OwnerID} err := r.FinishJob(ctx, ownerScope, job.DigitalHumanID, job.ID, nil, "蒸馏已超时或服务重启,请重新提交。已审核版本保持可用。") if err != nil && !errors.Is(err, types.ErrDigitalHumanConflict) && !errors.Is(err, types.ErrDigitalHumanNotFound) { return err } } return nil }