Files
ttstd-infinite-canvas/backend/internal/repository/logical_models.go

524 lines
20 KiB
Go

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
}