WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,567 @@
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 "<!-- weknora:digital-human:" + id + ":start -->", "<!-- weknora:digital-human:" + id + ":end -->"
}
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
}