package repository import ( "errors" "time" "infinite-canvas/backend/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) type LogicalModelGraph struct { Model model.LogicalModel Revision *model.LogicalModelRevision Routes []model.LogicalModelRoute ChannelModels []model.ChannelModel } var ErrLogicalModelInUse = errors.New("该模型正在被使用,无法删除或停用") var ErrLogicalModelUnavailable = errors.New("所选模型已停用或不可用,请重新选择") func (r *Repository) LogicalModels(includeDisabled bool) ([]model.LogicalModel, error) { var items []model.LogicalModel query := r.db.Where("archived_at IS NULL").Order("sort_order asc, created_at asc") if !includeDisabled { query = query.Where("enabled = ?", true) } return items, query.Find(&items).Error } func (r *Repository) LogicalModel(id string) (*model.LogicalModel, error) { var item model.LogicalModel if err := r.db.First(&item, "id = ?", id).Error; err != nil { return nil, err } return &item, nil } func (r *Repository) LogicalModelsBySourceChannelModelID(channelModelID string) ([]model.LogicalModel, error) { var items []model.LogicalModel return items, r.db.Where("source_channel_model_id = ? AND archived_at IS NULL", channelModelID).Order("sort_order asc, created_at asc").Find(&items).Error } func (r *Repository) LogicalModelsByOnlyActiveChannelModelID(channelModelID string) ([]model.LogicalModel, error) { var items []model.LogicalModel query := r.db.Model(&model.LogicalModel{}). Joins("JOIN logical_model_routes AS route ON route.logical_model_revision_id = logical_models.active_revision_id"). Where("logical_models.archived_at IS NULL"). Group("logical_models.id"). Having("COUNT(route.id) = 1 AND MAX(route.channel_model_id) = ?", channelModelID). Order("logical_models.sort_order asc, logical_models.created_at asc") return items, query.Find(&items).Error } func (r *Repository) LogicalModelRevision(id string) (*model.LogicalModelRevision, error) { var item model.LogicalModelRevision if err := r.db.First(&item, "id = ?", id).Error; err != nil { return nil, err } return &item, nil } func (r *Repository) LogicalModelRevisions(logicalModelID string) ([]model.LogicalModelRevision, error) { var items []model.LogicalModelRevision return items, r.db.Where("logical_model_id = ?", logicalModelID).Order("version desc").Find(&items).Error } func (r *Repository) LogicalModelRoutes(revisionID string, includeDisabled bool) ([]model.LogicalModelRoute, error) { var items []model.LogicalModelRoute query := r.db.Where("logical_model_revision_id = ?", revisionID).Order("priority desc, created_at asc") if !includeDisabled { query = query.Where("enabled = ? AND weight > ?", true, 0) } return items, query.Find(&items).Error } func (r *Repository) LogicalModelRoute(id string) (*model.LogicalModelRoute, error) { var item model.LogicalModelRoute if err := r.db.First(&item, "id = ?", id).Error; err != nil { return nil, err } return &item, nil } func (r *Repository) ChannelModelsByIDs(ids []string) ([]model.ChannelModel, error) { var items []model.ChannelModel if len(ids) == 0 { return items, nil } if err := r.db.Where("id IN ?", ids).Find(&items).Error; err != nil { return nil, err } return items, r.PopulateChannelModelPriceTiers(items) } func (r *Repository) SystemChannelsByIDs(ids []string, includeDisabled bool) ([]model.ModelChannel, error) { var items []model.ModelChannel if len(ids) == 0 { return items, nil } query := r.db.Where("id IN ? AND scope = ?", uniqueStrings(ids), model.ChannelScopeSystem) if !includeDisabled { query = query.Where("enabled = ?", true) } return items, query.Find(&items).Error } func (r *Repository) ChannelModel(id string) (*model.ChannelModel, error) { var item model.ChannelModel if err := r.db.First(&item, "id = ?", id).Error; err != nil { return nil, err } if err := r.PopulateChannelModelPriceTier(&item); err != nil { return nil, err } return &item, nil } func (r *Repository) LogicalModelGraph(id string, includeDisabled bool) (*LogicalModelGraph, error) { item, err := r.LogicalModel(id) if err != nil { return nil, err } if !includeDisabled && !item.Enabled { return nil, gorm.ErrRecordNotFound } graphs, err := r.LogicalModelGraphs([]model.LogicalModel{*item}, includeDisabled) if err != nil { return nil, err } return graphs[item.ID], nil } // LogicalModelGraphs 批量加载前台模型的当前 revision、供应线路和渠道模型。 // 管理列表与路由目录都需要同一张关系图,集中按 IN 查询避免逐模型 N+1。 func (r *Repository) LogicalModelGraphs(items []model.LogicalModel, includeDisabled bool) (map[string]*LogicalModelGraph, error) { graphs := make(map[string]*LogicalModelGraph, len(items)) if len(items) == 0 { return graphs, nil } revisionIDs := make([]string, 0, len(items)) for _, item := range items { graphs[item.ID] = &LogicalModelGraph{Model: item} if item.ActiveRevisionID != "" { revisionIDs = append(revisionIDs, item.ActiveRevisionID) } } if len(revisionIDs) == 0 { return graphs, nil } var revisions []model.LogicalModelRevision if err := r.db.Where("id IN ?", uniqueStrings(revisionIDs)).Find(&revisions).Error; err != nil { return nil, err } revisionByID := make(map[string]model.LogicalModelRevision, len(revisions)) for _, revision := range revisions { revisionByID[revision.ID] = revision } routeRevisionIDs := make([]string, 0, len(revisions)) for _, item := range items { graph := graphs[item.ID] revision, ok := revisionByID[item.ActiveRevisionID] if !ok { continue } r := revision graph.Revision = &r routeRevisionIDs = append(routeRevisionIDs, revision.ID) } var routes []model.LogicalModelRoute if len(routeRevisionIDs) > 0 { query := r.db.Where("logical_model_revision_id IN ?", uniqueStrings(routeRevisionIDs)).Order("priority desc, created_at asc") if !includeDisabled { query = query.Where("enabled = ? AND weight > ?", true, 0) } if err := query.Find(&routes).Error; err != nil { return nil, err } } routesByRevision := make(map[string][]model.LogicalModelRoute, len(routeRevisionIDs)) channelModelIDs := make([]string, 0, len(routes)) for _, route := range routes { routesByRevision[route.LogicalModelRevisionID] = append(routesByRevision[route.LogicalModelRevisionID], route) channelModelIDs = append(channelModelIDs, route.ChannelModelID) } channelModels, err := r.ChannelModelsByIDs(uniqueStrings(channelModelIDs)) if err != nil { return nil, err } channelModelByID := make(map[string]model.ChannelModel, len(channelModels)) for _, channelModel := range channelModels { channelModelByID[channelModel.ID] = channelModel } for _, item := range items { graph := graphs[item.ID] if graph.Revision == nil { continue } graph.Routes = routesByRevision[graph.Revision.ID] graph.ChannelModels = make([]model.ChannelModel, 0, len(graph.Routes)) seenChannelModels := make(map[string]bool, len(graph.Routes)) for _, route := range graph.Routes { if channelModel, channelOK := channelModelByID[route.ChannelModelID]; channelOK && !seenChannelModels[channelModel.ID] { seenChannelModels[channelModel.ID] = true graph.ChannelModels = append(graph.ChannelModels, channelModel) } } } return graphs, nil } func uniqueStrings(values []string) []string { result := make([]string, 0, len(values)) seen := make(map[string]bool, len(values)) for _, value := range values { if value == "" || seen[value] { continue } seen[value] = true result = append(result, value) } return result } func (r *Repository) SaveLogicalModel(item *model.LogicalModel) error { return r.db.Save(item).Error } func (r *Repository) CreateLogicalModel(item *model.LogicalModel) error { return r.db.Create(item).Error } func (r *Repository) CreateLogicalModelRevision(item *model.LogicalModelRevision) error { return r.db.Create(item).Error } func (r *Repository) SaveLogicalModelRevision(item *model.LogicalModelRevision) error { return r.db.Save(item).Error } func (r *Repository) CreateLogicalModelRoute(item *model.LogicalModelRoute) error { return r.db.Create(item).Error } func (r *Repository) SaveLogicalModelRoute(item *model.LogicalModelRoute) error { return r.db.Save(item).Error } func (r *Repository) CreateRouteAttempt(item *model.RouteAttempt) error { return r.db.Create(item).Error } func (r *Repository) SaveRouteAttempt(item *model.RouteAttempt) error { return r.db.Save(item).Error } func (r *Repository) MarkRouteAttemptDispatching(id string) error { result := r.db.Model(&model.RouteAttempt{}).Where("id = ? AND dispatch_state = ?", id, "not_sent"). Updates(map[string]any{"status": "dispatching", "dispatch_state": "submission_unknown"}) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return ErrCreationConflict } return nil } func (r *Repository) LatestRouteAttempt(taskID string) (*model.RouteAttempt, error) { var item model.RouteAttempt if err := r.db.Where("task_id = ?", taskID).Order("route_run desc, attempt_number desc").First(&item).Error; err != nil { return nil, err } return &item, nil } func (r *Repository) RouteAttempts(taskID string, routeRun int) ([]model.RouteAttempt, error) { var items []model.RouteAttempt query := r.db.Where("task_id = ? AND route_run = ?", taskID, routeRun) return items, query.Order("attempt_number asc").Find(&items).Error } // SwitchTaskLogicalRoute 同时更新任务执行目标和账单。跟随供应价格时会原子调整预留积分, // 保证明确未创建上游任务后的故障切线不会沿用上一条线路的价格快照。 func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID string, routeID string, inputJSON string, billingOrderID string, channelID string, channelModelID string, replacement *model.BillingOrder, cost model.BillingCostSnapshot) error { return r.db.Transaction(func(tx *gorm.DB) error { updated := tx.Model(&model.Task{}). Where("id = ? AND status = ? AND route_id = ?", taskID, model.TaskStatusRunning, expectedRouteID). Updates(map[string]any{"route_id": routeID, "channel_model_id": channelModelID, "input_json": inputJSON, "updated_at": time.Now()}) if updated.Error != nil { return updated.Error } if updated.RowsAffected != 1 { return ErrTaskStateConflict } if billingOrderID == "" { return nil } var order model.BillingOrder if err := tx.First(&order, "id = ? AND task_id = ? AND status IN ?", billingOrderID, taskID, []model.BillingStatus{model.BillingStatusReserved, model.BillingStatusRunning}).Error; err != nil { return err } now := time.Now() updates := map[string]any{"channel_id": channelID, "channel_model_id": channelModelID, "updated_at": now} updates["cost_configured"] = cost.CostPricing.Configured updates["cost_unit_price_microcredits"] = cost.CostPricing.UnitPriceMicrocredits updates["cost_input_token_price_microcredits"] = cost.CostPricing.InputTokenPriceMicrocredits updates["cost_output_token_price_microcredits"] = cost.CostPricing.OutputTokenPriceMicrocredits updates["cost_cached_token_price_microcredits"] = cost.CostPricing.CachedTokenPriceMicrocredits updates["cost_billing_mode"] = cost.CostBillingMode updates["cost_quantity"] = cost.CostQuantity updates["cost_video_formula_tokens"] = cost.CostVideoFormulaTokens if replacement != nil { if replacement.UserID != order.UserID || replacement.TaskID != taskID || replacement.AmountMicrocredits <= 0 { return ErrBillingStateConflict } // The new supplier price cannot expand the user's original authorization. // Keep the existing cap; replacement is a price snapshot, not a new grant. if err := validateBillingChargeLimit(order, replacement.AmountMicrocredits); err != nil { return err } reserved := order.ReservedAmountMicrocredits if reserved <= 0 { reserved = order.AmountMicrocredits } delta := replacement.AmountMicrocredits - reserved accountUpdates := map[string]any{"version": gorm.Expr("version + 1"), "updated_at": now} accountQuery := tx.Model(&model.CreditAccount{}).Where("user_id = ?", order.UserID) if delta > 0 { accountQuery = accountQuery.Where("available_microcredits >= ?", delta) accountUpdates["available_microcredits"] = gorm.Expr("available_microcredits - ?", delta) accountUpdates["reserved_microcredits"] = gorm.Expr("reserved_microcredits + ?", delta) } else if delta < 0 { release := -delta accountQuery = accountQuery.Where("reserved_microcredits >= ?", release) accountUpdates["available_microcredits"] = gorm.Expr("available_microcredits + ?", release) accountUpdates["reserved_microcredits"] = gorm.Expr("reserved_microcredits - ?", release) } if delta != 0 { accountUpdated := accountQuery.Updates(accountUpdates) if accountUpdated.Error != nil { return accountUpdated.Error } if accountUpdated.RowsAffected != 1 { if delta > 0 { return ErrInsufficientCredits } return errors.New("reserved credit balance is inconsistent") } var account model.CreditAccount if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { return err } entryType := model.CreditLedgerReserve if delta < 0 { entryType = model.CreditLedgerRefund } if err := tx.Create(&model.CreditLedgerEntry{ ID: newRepositoryID(), UserID: order.UserID, Type: entryType, AvailableDeltaMicrocredits: -delta, ReservedDeltaMicrocredits: delta, AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits, BillingOrderID: order.ID, Model: order.Model, ChannelID: channelID, Scene: order.Scene, Note: "备用供应线路价格调整", }).Error; err != nil { return err } } updates["billing_mode"] = replacement.BillingMode updates["price_version"] = replacement.PriceVersion updates["price_tier_id"] = replacement.PriceTierID updates["price_tier_version"] = replacement.PriceTierVersion updates["unit_price_microcredits"] = replacement.UnitPriceMicrocredits updates["multiplier_basis_points"] = replacement.MultiplierBasisPoints updates["quantity"] = replacement.Quantity updates["amount_microcredits"] = replacement.AmountMicrocredits updates["reserved_amount_microcredits"] = replacement.AmountMicrocredits updates["input_token_price_microcredits"] = replacement.InputTokenPriceMicrocredits updates["output_token_price_microcredits"] = replacement.OutputTokenPriceMicrocredits updates["cached_token_price_microcredits"] = replacement.CachedTokenPriceMicrocredits } billingUpdated := tx.Model(&model.BillingOrder{}). Where("id = ? AND task_id = ? AND status IN ?", billingOrderID, taskID, []model.BillingStatus{model.BillingStatusReserved, model.BillingStatusRunning}). Updates(updates) if billingUpdated.Error != nil { return billingUpdated.Error } if billingUpdated.RowsAffected != 1 { return ErrBillingStateConflict } return nil }) } func (r *Repository) SaveLogicalModelBundle(item *model.LogicalModel, revision *model.LogicalModelRevision, routes []model.LogicalModelRoute, creating bool) error { return r.db.Transaction(func(tx *gorm.DB) error { if creating { if err := tx.Create(item).Error; err != nil { return err } } else { result := tx.Model(&model.LogicalModel{}).Where("id = ?", item.ID).Updates(map[string]any{ "code": item.Code, "name": item.Name, "icon": item.Icon, "description": item.Description, "capability": item.Capability, "enabled": item.Enabled, "sort_order": item.SortOrder, "source_channel_model_id": item.SourceChannelModelID, "price_policy": item.PricePolicy, "billing_mode": item.BillingMode, "unit_price_microcredits": item.UnitPriceMicrocredits, "input_price_microcredits": item.InputPriceMicrocredits, "output_price_microcredits": item.OutputPriceMicrocredits, "cached_price_microcredits": item.CachedPriceMicrocredits, "legacy_model_ids_json": item.LegacyModelIDsJSON, "updated_at": item.UpdatedAt, }) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return gorm.ErrRecordNotFound } } // 版本号必须由模型行原子递增。MAX(version)+1 会让两个并发事务分配同一版本。 result := tx.Model(&model.LogicalModel{}). Where("id = ?", item.ID). UpdateColumn("revision_sequence", gorm.Expr("revision_sequence + ?", 1)) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return gorm.ErrRecordNotFound } var sequence struct{ RevisionSequence int } if err := tx.Model(&model.LogicalModel{}). Select("revision_sequence"). Where("id = ?", item.ID). Take(&sequence).Error; err != nil { return err } revision.Version = sequence.RevisionSequence if err := tx.Create(revision).Error; err != nil { return err } for index := range routes { routes[index].LogicalModelRevisionID = revision.ID if err := tx.Create(&routes[index]).Error; err != nil { return err } } if err := tx.Model(&model.LogicalModel{}).Where("id = ?", item.ID).Updates(map[string]any{"active_revision_id": revision.ID, "updated_at": item.UpdatedAt}).Error; err != nil { return err } item.ActiveRevisionID = revision.ID item.RevisionSequence = revision.Version return nil }) } func (r *Repository) ArchiveLogicalModel(id string, audit *model.AdminAuditEvent, now time.Time) error { return r.db.Transaction(func(tx *gorm.DB) error { // PostgreSQL 下锁定主体,使任务创建与归档在同一行上串行,避免检查后仍写入新任务。 var item model.LogicalModel query := tx.Where("id = ? AND archived_at IS NULL", id) if r.Dialect() == "postgres" { query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } if err := query.First(&item).Error; err != nil { return err } var activeTasks int64 if err := tx.Model(&model.Task{}). Where("logical_model_id = ? AND status IN ?", id, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). Count(&activeTasks).Error; err != nil { return err } if activeTasks > 0 { return ErrLogicalModelInUse } archived := tx.Model(&model.LogicalModel{}). Where("id = ? AND archived_at IS NULL", id). Updates(map[string]any{"enabled": false, "archived_at": now, "updated_at": now}) if archived.Error != nil { return archived.Error } if archived.RowsAffected != 1 { return gorm.ErrRecordNotFound } if audit != nil { if err := tx.Create(audit).Error; err != nil { return err } } return nil }) } // requireActiveLogicalModelForTask 与归档共用主体行锁,保证新任务只引用仍在目录中的当前 revision。 func (r *Repository) requireActiveLogicalModelForTask(tx *gorm.DB, task *model.Task) error { if task == nil || task.LogicalModelID == "" { return nil } var item model.LogicalModel query := tx.Select("id").Where( "id = ? AND enabled = ? AND archived_at IS NULL AND active_revision_id = ?", task.LogicalModelID, true, task.LogicalModelRevisionID, ) if r.Dialect() == "postgres" { query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } if err := query.First(&item).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrLogicalModelUnavailable } return err } return nil } func (r *Repository) DeleteLogicalModelRoute(id string) error { return r.db.Model(&model.LogicalModelRoute{}).Where("id = ?", id).Updates(map[string]any{"enabled": false, "weight": 0, "updated_at": time.Now()}).Error }