Files
xinmi/internal/application/repository/digital_human.go
T
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

568 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}