Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_prepared_media.go
huiku 7466119867 feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)
* feat(image): 图片参数 - 统一插件声明配置与生成合同

* docs(image): 图片参数 - 说明声明合同与验证边界

* fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档

* fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方

* fix(image): 调用链 - 保留公开意图并接通独立分辨率

* fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
2026-10-13 19:18:21 +08:00

262 lines
12 KiB
Go

package app
import (
"encoding/json"
"sort"
"strings"
"time"
"yingce/backend/internal/canvas/contract"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
)
const cloudAgentMediaQuoteLifetime = 30 * time.Minute
type cloudAgentMediaQuote struct {
ID string `json:"id"`
AmountMicrocredits int64 `json:"amountMicrocredits"`
BillingMode string `json:"billingMode"`
PriceVersion int64 `json:"priceVersion"`
PriceTierID string `json:"priceTierId,omitempty"`
PriceTierVersion int64 `json:"priceTierVersion"`
ExpiresAt time.Time `json:"expiresAt"`
}
// Prepared inputs contain public resource selectors, never resolved credentials.
// Re-admission must reproduce the same execution and quote before committing.
type cloudAgentPreparedMedia struct {
Version int `json:"version"`
Hash string `json:"hash"`
GenerationID string `json:"generationId"`
DependencyHash string `json:"dependencyHash"`
ResolvedHash string `json:"resolvedHash"`
QuoteHash string `json:"quoteHash"`
Quote cloudAgentMediaQuote `json:"quote"`
Input map[string]any `json:"input,omitempty"`
ResourceSignatures map[string]string `json:"resourceSignatures,omitempty"`
CharacterVersions map[string]string `json:"characterVersions,omitempty"`
}
func (p *cloudAgentPreparedMedia) publicView() *cloudAgentPreparedMedia {
if p == nil {
return nil
}
copy := *p
copy.Input, copy.ResourceSignatures = nil, nil
return &copy
}
func cloudAgentApprovalOutput(approval *cloudAgentApproval) *cloudAgentApproval {
if approval == nil {
return nil
}
copy := *approval
copy.Prepared = approval.Prepared.publicView()
return &copy
}
func cloudAgentMediaDependencyHash(doc map[string]any, args cloudAgentMediaArgs) (string, error) {
nodes, err := creationObjects(doc["nodes"])
if err != nil {
return "", err
}
projectionNode := func(node map[string]any) (map[string]any, error) {
metadata, _ := node["metadata"].(map[string]any)
input, err := contract.NodeGenerationProjection(metadata)
if err != nil {
return nil, BadAuthRequest("生成依赖字段无效:" + err.Error())
}
return input, nil
}
resourceProjection := func(metadata map[string]any) map[string]any {
resource := map[string]any{}
for _, key := range []string{"storageKey", "assetId", "resourceId", "generationTaskId", "outputReference", "status", "mimeType", "naturalWidth", "naturalHeight", "width", "height", "durationMs", "etag", "characterAssetId", "characterVersionId", "characterVersionPolicy"} {
if value, exists := metadata[key]; exists {
resource[key] = value
}
}
return resource
}
target := nodes[args.NodeID]
var targetInput any
if target != nil {
input, err := projectionNode(target)
if err != nil {
return "", err
}
metadata, _ := target["metadata"].(map[string]any)
for _, key := range []string{"locked", "taskId", "generationTaskId", "agentDraftRunId", "status"} {
input[key] = metadata[key]
}
targetInput = map[string]any{"id": target["id"], "type": target["type"], "locked": target["locked"], "input": input, "resource": resourceProjection(metadata)}
}
bindings := make([]any, 0, len(args.ReferenceNodeIDs))
for _, id := range args.ReferenceNodeIDs {
node := nodes[id]
binding := map[string]any{"id": id, "exists": node != nil}
if node != nil {
input, err := projectionNode(node)
if err != nil {
return "", err
}
metadata, _ := node["metadata"].(map[string]any)
binding["type"] = node["type"]
binding["input"] = input
binding["resource"] = resourceProjection(metadata)
}
bindings = append(bindings, binding)
}
var source any
if args.SourceNodeID != "" {
node := nodes[args.SourceNodeID]
metadata, _ := node["metadata"].(map[string]any)
source = map[string]any{"id": args.SourceNodeID, "type": node["type"], "content": metadata["content"], "prompt": metadata["prompt"], "characterAssetId": metadata["characterAssetId"], "characterVersionId": metadata["characterVersionId"], "characterVersionPolicy": metadata["characterVersionPolicy"]}
}
edges := []any{}
for _, edge := range creationMaps(doc["connections"]) {
if stringValue(edge["toNodeId"]) == args.NodeID {
edges = append(edges, map[string]any{"fromNodeId": edge["fromNodeId"], "toNodeId": edge["toNodeId"], "fromPort": edge["fromPort"], "toPort": edge["toPort"], "role": edge["role"]})
}
}
return creationHash(map[string]any{"version": 2, "target": targetInput, "bindings": bindings, "source": source, "edges": edges}), nil
}
func cloudAgentResourceSignature(resource *model.Resource) string {
return creationHash(map[string]any{"id": resource.ID, "userId": resource.UserID, "provider": resource.Provider, "endpoint": resource.Endpoint, "bucket": resource.Bucket, "objectKey": resource.ObjectKey, "storageSettingId": resource.StorageSettingID, "etag": resource.ETag, "size": resource.Size, "mimeType": resource.MimeType, "width": resource.Width, "height": resource.Height, "durationMs": resource.DurationMs})
}
func cloudAgentPreparedReferences(repo *repository.Repository, userID string, prepared *cloudAgentPreparedMedia) (map[string]any, error) {
refs := map[string]any{}
for _, field := range []string{"referenceImages", "referenceVideos", "referenceAudios"} {
items := []any{}
for _, ref := range creationMaps(prepared.Input[field]) {
id := strings.TrimPrefix(stringValue(ref["storageKey"]), "resource:")
resource, err := repo.ResourceForUser(userID, id)
if err != nil || resource.Status != model.ResourceStatusReady || prepared.ResourceSignatures[id] != cloudAgentResourceSignature(resource) {
return nil, creationConflict("已批准的参考资源已失效或不再属于当前用户,请重新准备生成;未提交任务")
}
copy := map[string]any{}
for key, value := range ref {
copy[key] = value
}
items = append(items, copy)
}
if len(items) > 0 {
refs[field] = items
}
}
return refs, nil
}
func cloudAgentResolvedMediaHash(task *model.Task) (string, error) {
var input map[string]any
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
return "", err
}
config, _ := input["config"].(map[string]any)
public := map[string]any{}
if values, exists := config["imageParameters"]; exists {
public["imageParameters"] = values
}
for _, key := range []string{"channelId", "channelModelKey", "providerModelKey", "priceTierId", "model", "apiFormat", "interfaceType", "capabilityConfig", "size", "imageResolution", "quality", "transparentBackground", "count", "videoSeconds", "vquality", "videoGenerateAudio", "videoWatermark", "audioVoice", "audioFormat", "audioSpeed", "audioInstructions"} {
if value, exists := config[key]; exists {
public[key] = value
}
}
return creationHash(map[string]any{"mode": input["mode"], "prompt": input["prompt"], "config": public, "referenceImages": input["referenceImages"], "referenceVideos": input["referenceVideos"], "referenceAudios": input["referenceAudios"], "operation": task.Operation, "logicalModelId": task.LogicalModelID, "logicalModelRevisionId": task.LogicalModelRevisionID, "channelModelId": task.ChannelModelID}), nil
}
func cloudAgentQuoteHash(order *model.BillingOrder) string {
if order == nil {
return creationHash(nil)
}
return creationHash(map[string]any{"amount": order.AmountMicrocredits, "billingMode": order.BillingMode, "priceVersion": order.PriceVersion, "priceTierId": order.PriceTierID, "priceTierVersion": order.PriceTierVersion, "quantity": order.Quantity, "unitPrice": order.UnitPriceMicrocredits, "inputTokenPrice": order.InputTokenPriceMicrocredits, "outputTokenPrice": order.OutputTokenPriceMicrocredits, "cachedTokenPrice": order.CachedTokenPriceMicrocredits, "multiplier": order.MultiplierBasisPoints})
}
func prepareCloudAgentMediaApproval(repo *repository.Repository, userID string, doc map[string]any, plan *cloudAgentMediaPlan, request CreateTaskRequest, task *model.Task, order *model.BillingOrder) (*cloudAgentPreparedMedia, error) {
dependency, err := cloudAgentMediaDependencyHash(doc, plan.Args)
if err != nil {
return nil, err
}
resolved, err := cloudAgentResolvedMediaHash(task)
if err != nil {
return nil, err
}
prepared := &cloudAgentPreparedMedia{Version: 1, GenerationID: newID(), DependencyHash: dependency, ResolvedHash: resolved, QuoteHash: cloudAgentQuoteHash(order), Input: request.Input, ResourceSignatures: map[string]string{}, Quote: cloudAgentMediaQuote{ID: newID(), ExpiresAt: time.Now().UTC().Add(cloudAgentMediaQuoteLifetime)}}
prepared.CharacterVersions = plan.Args.CharacterVersions
if order != nil {
prepared.Quote.AmountMicrocredits, prepared.Quote.BillingMode = order.AmountMicrocredits, order.BillingMode
prepared.Quote.PriceVersion, prepared.Quote.PriceTierID, prepared.Quote.PriceTierVersion = order.PriceVersion, order.PriceTierID, order.PriceTierVersion
}
for _, field := range []string{"referenceImages", "referenceVideos", "referenceAudios"} {
for _, ref := range creationMaps(request.Input[field]) {
key := stringValue(ref["storageKey"])
if !strings.HasPrefix(key, "resource:") {
return nil, BadAuthRequest("生成引用必须先保存到资源库")
}
id := strings.TrimPrefix(key, "resource:")
resource, err := repo.ResourceForUser(userID, id)
if err != nil || resource.Status != model.ResourceStatusReady {
return nil, BadAuthRequest("参考资源已失效")
}
prepared.ResourceSignatures[id] = cloudAgentResourceSignature(resource)
}
}
prepared.Hash = creationHash(prepared)
return prepared, nil
}
func pinCloudAgentPreparedMedia(repo *repository.Repository, userID, runID, approvalID string, prepared *cloudAgentPreparedMedia) error {
ids := make([]string, 0, len(prepared.ResourceSignatures))
for id := range prepared.ResourceSignatures {
ids = append(ids, id)
}
sort.Strings(ids)
return repo.UpsertCloudAgentResourceLeases(userID, runID, approvalID, ids, prepared.Quote.ExpiresAt)
}
func validateCloudAgentPreparedAdmission(repo *repository.Repository, userID string, prepared *cloudAgentPreparedMedia, task *model.Task, order *model.BillingOrder, doc map[string]any, args cloudAgentMediaArgs) error {
if prepared == nil || prepared.Version != 1 || prepared.Hash == "" || time.Now().After(prepared.Quote.ExpiresAt) {
return creationConflict("生成报价已过期,请刷新报价并重新批准;未提交任务")
}
dependency, err := cloudAgentMediaDependencyHash(doc, args)
if err != nil {
return err
}
if dependency != prepared.DependencyHash {
return creationConflict("画布生成依赖已变化,请重新准备并批准;未提交任务")
}
if len(prepared.CharacterVersions) > 0 {
canvas, err := repo.CanvasProjectForUser(userID, task.ProjectID)
if err != nil {
return err
}
nodes, err := creationObjects(doc["nodes"])
if err != nil {
return err
}
for id, versionID := range prepared.CharacterVersions {
node := nodes[id]
if node == nil || !cloudAgentCharacterNode(node) {
return creationConflict("角色卡已变化,请重新准备并批准;未提交任务")
}
character, err := cloudAgentResolveCharacter(repo, userID, canvas.ProjectID, node)
if err != nil || character.Card.VersionID != versionID {
return creationConflict("角色卡版本已变化,请重新准备并批准;未提交任务")
}
}
}
hash, err := cloudAgentResolvedMediaHash(task)
if err != nil {
return err
}
if hash != prepared.ResolvedHash || cloudAgentQuoteHash(order) != prepared.QuoteHash {
return creationConflict("模型规格或价格已变化,请刷新报价并重新批准;未提交任务")
}
if _, err := cloudAgentPreparedReferences(repo, userID, prepared); err != nil {
return err
}
return nil
}