909 lines
32 KiB
Go
909 lines
32 KiB
Go
package app
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"infinite-canvas/backend/internal/model"
|
|
"infinite-canvas/backend/internal/repository"
|
|
)
|
|
|
|
type CreationGuard struct {
|
|
ExecutionEpoch int64 `json:"executionEpoch"`
|
|
Owner string `json:"owner"`
|
|
}
|
|
type CreationRequest struct {
|
|
CreationGuard
|
|
ClientKey string `json:"clientKey"`
|
|
CanvasID string `json:"canvasId"`
|
|
Revision int64 `json:"revision"`
|
|
ExpectedEpoch int64 `json:"expectedEpoch"`
|
|
State map[string]any `json:"state"`
|
|
Status string `json:"status"`
|
|
ProposalVersion int64 `json:"proposalVersion"`
|
|
Proposal json.RawMessage `json:"proposal"`
|
|
Ops []CreationCanvasOp `json:"ops"`
|
|
ItemKey string `json:"itemKey"`
|
|
Request CreateTaskRequest `json:"request"`
|
|
SubmissionIDs []string `json:"submissionIds"`
|
|
SubmissionID string `json:"submissionId"`
|
|
ExpectedSnapshotHash string `json:"expectedSnapshotHash"`
|
|
Document json.RawMessage `json:"document"`
|
|
}
|
|
type CreationRunOutput struct {
|
|
model.CreationRun
|
|
State map[string]any `json:"state"`
|
|
}
|
|
type CreationQuote struct {
|
|
Model string `json:"model"`
|
|
BillingMode string `json:"billingMode"`
|
|
Quantity int64 `json:"quantity"`
|
|
AmountMicrocredits int64 `json:"amountMicrocredits"`
|
|
Estimated bool `json:"estimated"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
QuoteHash string `json:"quoteHash"`
|
|
Options map[string]any `json:"options,omitempty"`
|
|
}
|
|
type CreationSubmissionOutput struct {
|
|
model.CreationSubmission
|
|
Quote CreationQuote `json:"quote"`
|
|
}
|
|
type CreationDetail struct {
|
|
Run CreationRunOutput `json:"run"`
|
|
Submissions []CreationSubmissionOutput `json:"submissions"`
|
|
}
|
|
type creationTaskPreparation struct{ Order *model.BillingOrder }
|
|
|
|
func creationConflict(message string) error {
|
|
return &AppError{Status: 409, Code: 409, Message: message}
|
|
}
|
|
func creationError(err error) error {
|
|
if errors.Is(err, repository.ErrInsufficientCredits) {
|
|
return BadAuthRequest("积分不足,请充值后重试")
|
|
}
|
|
if errors.Is(err, repository.ErrActiveTaskLimit) {
|
|
return BadAuthRequest("同时排队或运行的任务已达到上限")
|
|
}
|
|
if errors.Is(err, repository.ErrLogicalModelUnavailable) {
|
|
return creationConflict("模型已更新或停用,请重新准备报价")
|
|
}
|
|
if errors.Is(err, repository.ErrCreationConflict) {
|
|
return creationConflict("创作状态已变化,请重新读取后继续")
|
|
}
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return &AppError{Status: 404, Code: 404, Message: "创作记录不存在或无权访问"}
|
|
}
|
|
return err
|
|
}
|
|
func creationHash(value any) string {
|
|
b, _ := json.Marshal(value)
|
|
h := sha256.Sum256(b)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
func creationRunOutput(run model.CreationRun) CreationRunOutput {
|
|
state := map[string]any{}
|
|
_ = json.Unmarshal([]byte(run.StateJSON), &state)
|
|
return CreationRunOutput{run, state}
|
|
}
|
|
func creationSubmissionOutput(item model.CreationSubmission) CreationSubmissionOutput {
|
|
var quote CreationQuote
|
|
_ = json.Unmarshal([]byte(item.QuoteJSON), "e)
|
|
return CreationSubmissionOutput{item, quote}
|
|
}
|
|
func validateCreationGuard(run *model.CreationRun, guard CreationGuard) error {
|
|
if guard.Owner == "" || run.ExecutionOwner != guard.Owner || run.ExecutionEpoch != guard.ExecutionEpoch || run.LeaseExpiresAt == nil || !run.LeaseExpiresAt.After(time.Now()) {
|
|
return creationConflict("执行控制权已过期,请在当前页面重新接管")
|
|
}
|
|
return nil
|
|
}
|
|
func validateCreationJSON(value any) error {
|
|
b, err := json.Marshal(value)
|
|
if err != nil || len(b) > 1<<20 {
|
|
return BadAuthRequest("创作内容超过限制或格式无效")
|
|
}
|
|
var decoded any
|
|
_ = json.Unmarshal(b, &decoded)
|
|
var visit func(any) bool
|
|
visit = func(v any) bool {
|
|
switch item := v.(type) {
|
|
case map[string]any:
|
|
for key, child := range item {
|
|
switch strings.ToLower(key) {
|
|
case "apikey", "secretkey", "authorization", "cookie", "headers", "baseurl", "token", "accesstoken", "refresh_token":
|
|
if child != nil && child != "" {
|
|
return false
|
|
}
|
|
}
|
|
if !visit(child) {
|
|
return false
|
|
}
|
|
}
|
|
case []any:
|
|
for _, child := range item {
|
|
if !visit(child) {
|
|
return false
|
|
}
|
|
}
|
|
case string:
|
|
if strings.HasPrefix(item, "data:") || strings.Contains(item, "X-Amz-Signature=") || strings.Contains(item, "X-Tos-Signature=") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
if !visit(decoded) {
|
|
return BadAuthRequest("创作记录不能包含密钥、内嵌媒体或临时签名链接")
|
|
}
|
|
return nil
|
|
}
|
|
func (s *Service) CreateCreationRun(userID string, req CreationRequest) (*CreationDetail, error) {
|
|
if strings.TrimSpace(userID) == "" || strings.TrimSpace(req.ClientKey) == "" || len(req.ClientKey) > 120 {
|
|
return nil, BadAuthRequest("缺少稳定会话键")
|
|
}
|
|
if err := validateCreationJSON(req.State); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.CanvasID != "" {
|
|
if _, err := s.repo.CanvasProjectForUser(userID, req.CanvasID); err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
}
|
|
b, _ := json.Marshal(req.State)
|
|
run := model.CreationRun{ID: newID(), UserID: userID, ClientKey: req.ClientKey, CreateHash: creationHash([]any{req.CanvasID, req.State}), CanvasID: req.CanvasID, Revision: 1, Status: "idle", StateJSON: string(b)}
|
|
if old, e := s.repo.CreationRunByClientKey(userID, req.ClientKey); e == nil {
|
|
if old.CreateHash != run.CreateHash {
|
|
return nil, creationConflict("同一会话键的内容不同")
|
|
}
|
|
return s.GetCreationRun(userID, old.ID)
|
|
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
|
|
return nil, e
|
|
}
|
|
s.storageMu.Lock()
|
|
defer s.storageMu.Unlock()
|
|
if err := s.validateCreationStorage(s.repo, userID, true, int64(len(b))); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.repo.CreateCreationRun(&run); err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
return s.GetCreationRun(userID, run.ID)
|
|
}
|
|
func (s *Service) GetCreationRun(userID, id string) (*CreationDetail, error) {
|
|
run, err := s.repo.CreationRun(userID, id)
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
items, err := s.repo.CreationSubmissions(userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := &CreationDetail{Run: creationRunOutput(*run), Submissions: []CreationSubmissionOutput{}}
|
|
for _, item := range items {
|
|
out.Submissions = append(out.Submissions, creationSubmissionOutput(item))
|
|
}
|
|
return out, nil
|
|
}
|
|
func (s *Service) ListCreationRuns(userID string) (map[string]any, error) {
|
|
items, err := s.repo.CreationRuns(userID)
|
|
out := []CreationRunOutput{}
|
|
for _, run := range items {
|
|
out = append(out, creationRunOutput(run))
|
|
}
|
|
return map[string]any{"runs": out}, err
|
|
}
|
|
func (s *Service) ChangeCreationRun(userID, id, action string, req CreationRequest) (any, error) {
|
|
if action == "save" || action == "proposal-approve" || action == "proposal-invalidate" {
|
|
s.storageMu.Lock()
|
|
defer s.storageMu.Unlock()
|
|
}
|
|
var out any
|
|
err := s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error {
|
|
previousBytes := len(run.StateJSON) + len(run.ApprovedOperationsJSON) + len(run.ApprovedCanvasJSON)
|
|
now := time.Now()
|
|
if action == "claim" {
|
|
if req.Owner == "" || len(req.Owner) > 120 || req.ExpectedEpoch != run.ExecutionEpoch {
|
|
return creationConflict("执行代次已变化")
|
|
}
|
|
run.ExecutionEpoch++
|
|
run.ExecutionOwner = req.Owner
|
|
until := now.Add(45 * time.Second)
|
|
run.LeaseExpiresAt = &until
|
|
} else {
|
|
if err := validateCreationGuard(run, req.CreationGuard); err != nil {
|
|
return err
|
|
}
|
|
switch action {
|
|
case "heartbeat":
|
|
until := now.Add(45 * time.Second)
|
|
run.LeaseExpiresAt = &until
|
|
out = map[string]any{"leaseExpiresAt": until}
|
|
return nil
|
|
case "release":
|
|
run.LeaseExpiresAt = nil
|
|
out = map[string]any{"released": true}
|
|
return nil
|
|
case "save":
|
|
if req.Revision != run.Revision {
|
|
return repository.ErrCreationConflict
|
|
}
|
|
if err := validateCreationJSON(req.State); err != nil {
|
|
return err
|
|
}
|
|
if !strings.Contains("|idle|running|waiting_answer|waiting_proposal|waiting_canvas|waiting_payment|waiting_task|paused|completed|cancelled|", "|"+req.Status+"|") || req.Status == "" {
|
|
return BadAuthRequest("创作状态无效")
|
|
}
|
|
if run.Status == "cancelled" && req.Status != "cancelled" {
|
|
return creationConflict("已取消会话不能继续执行")
|
|
}
|
|
b, _ := json.Marshal(req.State)
|
|
run.StateJSON = string(b)
|
|
run.Status = req.Status
|
|
case "proposal-approve":
|
|
if req.Revision != run.Revision || req.ProposalVersion <= 0 {
|
|
return repository.ErrCreationConflict
|
|
}
|
|
if err := validateCreationOps(req.Ops); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCreationJSON(req.Proposal); err != nil {
|
|
return err
|
|
}
|
|
for _, op := range req.Ops {
|
|
if key := stringValue(op.Metadata["storageKey"]); key != "" {
|
|
if !strings.HasPrefix(key, "resource:") {
|
|
return BadAuthRequest("已有素材必须来自当前账号资源库")
|
|
}
|
|
resource, e := repo.ResourceForUser(userID, strings.TrimPrefix(key, "resource:"))
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if resource.Status != model.ResourceStatusReady {
|
|
return BadAuthRequest("已有素材尚未就绪")
|
|
}
|
|
}
|
|
}
|
|
hash := creationHash([]any{req.Proposal, req.Ops})
|
|
if req.ProposalVersion <= run.ApprovedProposalVersion {
|
|
if req.ProposalVersion == run.ApprovedProposalVersion && hash == run.ApprovedProposalHash {
|
|
out = creationRunOutput(*run)
|
|
return nil
|
|
}
|
|
return repository.ErrCreationConflict
|
|
}
|
|
if err := repo.RevokeCreationSubmissions(id); err != nil {
|
|
return err
|
|
}
|
|
b, _ := json.Marshal(req.Ops)
|
|
run.ApprovedOperationsJSON = string(b)
|
|
run.ApprovedProposalVersion = req.ProposalVersion
|
|
run.ApprovedProposalHash = hash
|
|
run.ApprovedAt = &now
|
|
run.ApprovedCanvasJSON = ""
|
|
if run.CanvasID != "" {
|
|
canvas, e := repo.CanvasProjectForUser(userID, run.CanvasID)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
baseline, e := creationApprovalBaseline(canvas.PayloadJSON, req.Ops)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
run.ApprovedCanvasJSON = baseline
|
|
}
|
|
case "proposal-invalidate":
|
|
if req.Revision != run.Revision {
|
|
return repository.ErrCreationConflict
|
|
}
|
|
if err := repo.RevokeCreationSubmissions(id); err != nil {
|
|
return err
|
|
}
|
|
run.ApprovedAt = nil
|
|
run.ApprovedProposalHash = ""
|
|
run.ApprovedOperationsJSON = ""
|
|
run.ApprovedCanvasJSON = ""
|
|
default:
|
|
return BadAuthRequest("未知创作操作")
|
|
}
|
|
}
|
|
run.Revision++
|
|
if action == "save" || action == "proposal-approve" {
|
|
delta := int64(len(run.StateJSON) + len(run.ApprovedOperationsJSON) + len(run.ApprovedCanvasJSON) - previousBytes)
|
|
if e := s.validateCreationStorage(repo, userID, false, delta); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
out = creationRunOutput(*run)
|
|
return nil
|
|
})
|
|
return out, creationError(err)
|
|
}
|
|
|
|
func (s *Service) prepareCreationTask(userID string, req CreateTaskRequest) (*model.Task, *model.BillingOrder, string, error) {
|
|
config, _ := req.Input["config"].(map[string]any)
|
|
if req.LogicalModelID == "" && strings.TrimSpace(stringValue(config["channelId"])) == "" {
|
|
return nil, nil, "", BadAuthRequest("智能创作目前仅支持后端受管模型,请在原入口使用其他渠道")
|
|
}
|
|
if taskInputUsesWorkflowProvider(req.Input) || isTextReplayTaskRequest(req.Input) {
|
|
return nil, nil, "", BadAuthRequest("智能创作不支持本机、工作流或文本回放任务")
|
|
}
|
|
// System selectors are the only authority; discard frontend sentinel credentials.
|
|
safeConfig := map[string]any{}
|
|
for _, key := range []string{"channelId", "channelModelKey", "model", "priceTierId", "apiFormat", "interfaceType", "size", "quality", "transparentBackground", "count", "videoSeconds", "vquality", "videoGenerateAudio", "videoWatermark", "videoArkPrivateAssetUpload", "systemPrompt"} {
|
|
if value, ok := config[key]; ok {
|
|
safeConfig[key] = value
|
|
}
|
|
}
|
|
config = safeConfig
|
|
req.Input["config"] = safeConfig
|
|
if err := validateCreationJSON(req); err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
if req.Type != "canvas_text" && req.Type != "text" && req.Type != "canvas_image" && req.Type != "canvas_video" {
|
|
return nil, nil, "", BadAuthRequest("智能创作任务类型不受支持")
|
|
}
|
|
expectedMode := map[string]string{"text": "text", "canvas_text": "text", "canvas_image": "image", "canvas_video": "video"}[req.Type]
|
|
if stringValue(req.Input["mode"]) != expectedMode || strings.TrimSpace(stringValue(req.Input["prompt"])) != strings.TrimSpace(req.Prompt) {
|
|
return nil, nil, "", BadAuthRequest("任务类型、模式和实际提示词必须一致")
|
|
}
|
|
if expectedMode != "text" && req.Input["agentRequests"] != nil {
|
|
return nil, nil, "", BadAuthRequest("媒体任务不允许携带独立模型协议请求")
|
|
}
|
|
if count := stringValue(config["count"]); count != "" && count != "1" {
|
|
return nil, nil, "", BadAuthRequest("每个获批执行项只能生成一个产物")
|
|
}
|
|
if req.Input["mask"] != nil {
|
|
return nil, nil, "", BadAuthRequest("本期智能创作暂不支持蒙版任务")
|
|
}
|
|
if expectedMode != "text" {
|
|
canvas, e := s.repo.CanvasProjectForUser(userID, req.ProjectID)
|
|
if e != nil {
|
|
return nil, nil, "", creationError(e)
|
|
}
|
|
doc, e := creationDocument(canvas.PayloadJSON)
|
|
if e != nil {
|
|
return nil, nil, "", e
|
|
}
|
|
nodes, e := creationObjects(doc["nodes"])
|
|
if e != nil {
|
|
return nil, nil, "", e
|
|
}
|
|
if refs, ok := req.Input["referenceImages"].([]any); ok {
|
|
for _, raw := range refs {
|
|
ref, _ := raw.(map[string]any)
|
|
node := nodes[stringValue(ref["id"])]
|
|
meta, _ := node["metadata"].(map[string]any)
|
|
if node == nil || node["type"] != "image" || meta["status"] != "success" || stringValue(ref["storageKey"]) != stringValue(meta["storageKey"]) {
|
|
return nil, nil, "", creationConflict("参考素材已变化或尚未就绪")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, name := range []string{"referenceImages", "referenceVideos", "referenceAudios"} {
|
|
if list, ok := req.Input[name].([]any); ok {
|
|
for _, raw := range list {
|
|
media, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, nil, "", BadAuthRequest("素材引用格式无效")
|
|
}
|
|
key := stringValue(media["storageKey"])
|
|
if !strings.HasPrefix(key, "resource:") {
|
|
return nil, nil, "", BadAuthRequest("请先将参考素材保存到当前账号资源库")
|
|
}
|
|
resource, err := s.repo.ResourceForUser(userID, strings.TrimPrefix(key, "resource:"))
|
|
if err != nil {
|
|
return nil, nil, "", creationError(err)
|
|
}
|
|
if resource.Status != "ready" {
|
|
return nil, nil, "", BadAuthRequest("参考素材尚未就绪")
|
|
}
|
|
delete(media, "url")
|
|
delete(media, "dataUrl")
|
|
media["bytes"] = resource.Size
|
|
media["durationMs"] = resource.DurationMs
|
|
}
|
|
}
|
|
}
|
|
prepared := &creationTaskPreparation{}
|
|
req.creationPrepare = prepared
|
|
task, err := s.CreateTask(userID, req)
|
|
if err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
var input map[string]any
|
|
_ = json.Unmarshal([]byte(task.InputJSON), &input)
|
|
resolved, _ := input["config"].(map[string]any)
|
|
for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "count"} {
|
|
if requested := stringValue(config[key]); requested != "" && !strings.EqualFold(requested, stringValue(resolved[key])) {
|
|
return nil, nil, "", creationConflict("模型解析后的生成规格与请求不同,请调整方案后重新报价")
|
|
}
|
|
}
|
|
channel, channelErr := s.repo.SystemChannel(stringValue(resolved["channelId"]))
|
|
if channelErr != nil {
|
|
return nil, nil, "", channelErr
|
|
}
|
|
if expectedMode == "text" {
|
|
var typed canvasGenerationInput
|
|
if err = json.Unmarshal([]byte(task.InputJSON), &typed); err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
if len(typed.ReferenceImages) > 0 {
|
|
cm, e := s.repo.ChannelModelByKey(channel.ID, stringValue(resolved["model"]))
|
|
if e != nil {
|
|
return nil, nil, "", e
|
|
}
|
|
profile, e := DecodeModelCapabilityConfig(cm.CapabilityConfigJSON)
|
|
if e != nil || profile == nil || profile.Text == nil || profile.Text.References.MaxImages < len(typed.ReferenceImages) {
|
|
return nil, nil, "", BadAuthRequest("当前文本模型未配置足够的图片理解能力")
|
|
}
|
|
}
|
|
if err = validateAgentResourcePlaceholders(typed); err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
}
|
|
sig, err := s.repo.CreationPriceSignature(task, stringValue(resolved["channelId"]), stringValue(resolved["model"]))
|
|
return task, prepared.Order, sig, err
|
|
}
|
|
func creationQuoteFor(task *model.Task, order *model.BillingOrder, signature string, expires time.Time) CreationQuote {
|
|
quote := CreationQuote{Model: task.Model, BillingMode: "free", Quantity: 1, ExpiresAt: expires}
|
|
if order != nil {
|
|
quote.BillingMode = order.BillingMode
|
|
quote.Quantity = order.Quantity
|
|
quote.AmountMicrocredits = order.AmountMicrocredits
|
|
quote.Estimated = order.BillingMode == "token"
|
|
}
|
|
var input map[string]any
|
|
_ = json.Unmarshal([]byte(task.InputJSON), &input)
|
|
config, _ := input["config"].(map[string]any)
|
|
quote.Options = map[string]any{}
|
|
for _, key := range []string{"size", "videoSeconds", "vquality", "quality", "maxTokens"} {
|
|
if value, ok := config[key]; ok {
|
|
quote.Options[key] = value
|
|
}
|
|
}
|
|
quote.QuoteHash = creationHash([]any{signature, quote.BillingMode, quote.Quantity, quote.AmountMicrocredits, quote.Options})
|
|
return quote
|
|
}
|
|
func validateCreationSubmissionScope(run *model.CreationRun, version int64, req CreateTaskRequest) error {
|
|
if run.Status == "paused" || run.Status == "cancelled" || run.Status == "completed" {
|
|
return creationConflict("请先恢复创作任务")
|
|
}
|
|
if req.Type == "canvas_text" || req.Type == "text" {
|
|
if req.ProjectID != "" && req.ProjectID != run.CanvasID {
|
|
return creationConflict("规划任务的画布关联不匹配")
|
|
}
|
|
return nil
|
|
}
|
|
if run.CanvasID == "" || req.ProjectID != run.CanvasID {
|
|
return creationConflict("媒体任务必须提交到当前创作画布")
|
|
}
|
|
if run.ApprovedAt == nil || version != run.ApprovedProposalVersion || run.ApprovedProposalHash == "" {
|
|
return creationConflict("请先确认当前方案")
|
|
}
|
|
var ops []CreationCanvasOp
|
|
_ = json.Unmarshal([]byte(run.ApprovedOperationsJSON), &ops)
|
|
nodeID := stringValue(req.Input["nodeId"])
|
|
metadata, _ := req.Input["metadata"].(map[string]any)
|
|
if nodeID == "" {
|
|
nodeID = stringValue(metadata["nodeId"])
|
|
}
|
|
for _, op := range ops {
|
|
if op.ID != nodeID || nodeID == "" {
|
|
continue
|
|
}
|
|
if op.Type != "add_node" && op.Type != "update_node" {
|
|
continue
|
|
}
|
|
meta := op.Metadata
|
|
if nested, ok := op.Patch["metadata"].(map[string]any); ok {
|
|
meta = mergeCreationMaps(nested, meta)
|
|
}
|
|
prompt := stringValue(meta["prompt"])
|
|
if prompt == "" {
|
|
prompt = stringValue(meta["text"])
|
|
}
|
|
if strings.TrimSpace(req.Prompt) != strings.TrimSpace(prompt) || prompt == "" {
|
|
return creationConflict("任务提示词已超出获批方案")
|
|
}
|
|
if stringValue(meta["model"]) == "" || stringValue(meta["model"]) != req.Model {
|
|
return creationConflict("模型与已批准方案不同")
|
|
}
|
|
config, _ := req.Input["config"].(map[string]any)
|
|
for _, key := range []string{"size", "videoSeconds", "vquality", "quality"} {
|
|
metadataKey := key
|
|
if key == "videoSeconds" {
|
|
metadataKey = "seconds"
|
|
}
|
|
approved := strings.TrimSpace(stringValue(meta[metadataKey]))
|
|
candidate := strings.TrimSpace(stringValue(config[key]))
|
|
if metadataKey == "quality" {
|
|
approvedNorm := strings.ToLower(approved)
|
|
candidateNorm := strings.ToLower(candidate)
|
|
// auto/any 与空缺在图片生成中等价,前端 omittedImageQuality 可能会省略默认 quality
|
|
if (approvedNorm == "auto" || approvedNorm == "any" || approvedNorm == "") && (candidateNorm == "auto" || candidateNorm == "any" || candidateNorm == "") {
|
|
continue
|
|
}
|
|
if approvedNorm == candidateNorm {
|
|
continue
|
|
}
|
|
}
|
|
if approved != "" && approved != candidate {
|
|
return creationConflict("生成规格与已批准方案不同")
|
|
}
|
|
}
|
|
refs, _ := meta["referenceNodeIds"].([]any)
|
|
images, _ := req.Input["referenceImages"].([]any)
|
|
if len(refs) != len(images) {
|
|
return creationConflict("参考素材数量与已批准方案不同")
|
|
}
|
|
for index, ref := range refs {
|
|
image, _ := images[index].(map[string]any)
|
|
if stringValue(ref) != stringValue(image["id"]) {
|
|
return creationConflict("参考素材与已批准方案不同")
|
|
}
|
|
}
|
|
for _, kind := range []string{"referenceVideos", "referenceAudios"} {
|
|
if values, ok := req.Input[kind].([]any); ok && len(values) > 0 {
|
|
return creationConflict("本期方案尚未授权视频或音频参考输入")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return creationConflict("任务节点不在已批准方案范围内")
|
|
}
|
|
func (s *Service) PrepareCreationSubmission(userID, id string, req CreationRequest) (*CreationSubmissionOutput, error) {
|
|
if req.ItemKey == "" || len(req.ItemKey) > 160 {
|
|
return nil, BadAuthRequest("缺少稳定执行项键")
|
|
}
|
|
if strings.HasPrefix(req.ItemKey, "requote:") {
|
|
return nil, BadAuthRequest("该执行项键由报价刷新接口保留")
|
|
}
|
|
run, err := s.repo.CreationRun(userID, id)
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
if err = validateCreationGuard(run, req.CreationGuard); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = validateCreationSubmissionScope(run, req.ProposalVersion, req.Request); err != nil {
|
|
return nil, err
|
|
}
|
|
item, original, err := s.buildCreationSubmission(userID, run, req.ItemKey, req.ProposalVersion, req.Request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.storageMu.Lock()
|
|
defer s.storageMu.Unlock()
|
|
err = s.repo.MutateCreationRun(userID, id, func(current *model.CreationRun, repo *repository.Repository) error {
|
|
if e := validateCreationGuard(current, req.CreationGuard); e != nil {
|
|
return e
|
|
}
|
|
if e := validateCreationSubmissionScope(current, req.ProposalVersion, original); e != nil {
|
|
return e
|
|
}
|
|
items, e := repo.CreationSubmissions(userID, id)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
for _, old := range items {
|
|
if old.ItemKey == req.ItemKey {
|
|
if old.RequestHash != item.RequestHash || old.ProposalVersion != item.ProposalVersion {
|
|
return repository.ErrCreationConflict
|
|
}
|
|
item = old
|
|
return nil
|
|
}
|
|
}
|
|
if e = s.validateCreationStorage(repo, userID, false, int64(len(item.RequestJSON)+len(item.QuoteJSON)+len(item.PriceSignature))); e != nil {
|
|
return e
|
|
}
|
|
return repo.SaveCreationSubmission(&item)
|
|
})
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
out := creationSubmissionOutput(item)
|
|
return &out, nil
|
|
}
|
|
|
|
func (s *Service) buildCreationSubmission(userID string, run *model.CreationRun, itemKey string, proposalVersion int64, request CreateTaskRequest) (model.CreationSubmission, CreateTaskRequest, error) {
|
|
// JSON normalization also isolates the immutable snapshot from mutable caller maps.
|
|
b, err := json.Marshal(request)
|
|
if err != nil {
|
|
return model.CreationSubmission{}, CreateTaskRequest{}, err
|
|
}
|
|
var normalized CreateTaskRequest
|
|
if err = json.Unmarshal(b, &normalized); err != nil {
|
|
return model.CreationSubmission{}, normalized, err
|
|
}
|
|
task, order, signature, err := s.prepareCreationTask(userID, normalized)
|
|
if err != nil {
|
|
return model.CreationSubmission{}, normalized, err
|
|
}
|
|
expires := time.Now().Add(5 * time.Minute)
|
|
quote := creationQuoteFor(task, order, signature, expires)
|
|
quoteJSON, _ := json.Marshal(quote)
|
|
requestJSON, _ := json.Marshal(normalized)
|
|
item := model.CreationSubmission{ID: newID(), UserID: userID, RunID: run.ID, ItemKey: itemKey, ProposalVersion: proposalVersion, ProposalHash: run.ApprovedProposalHash, RequestJSON: string(requestJSON), RequestHash: creationHash(normalized), QuoteJSON: string(quoteJSON), PriceSignature: signature, ExpiresAt: expires}
|
|
return item, normalized, nil
|
|
}
|
|
|
|
// Refresh creates an unapproved successor. The old quote and approval remain immutable.
|
|
func (s *Service) RefreshCreationSubmission(userID, id string, req CreationRequest) (*CreationSubmissionOutput, error) {
|
|
run, err := s.repo.CreationRun(userID, id)
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
if err = validateCreationGuard(run, req.CreationGuard); err != nil {
|
|
return nil, err
|
|
}
|
|
old, err := s.repo.CreationSubmission(userID, id, req.SubmissionID)
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
if old.TaskID != nil {
|
|
return nil, creationConflict("任务已提交,请查看原任务,不需要刷新报价")
|
|
}
|
|
successorKey := "requote:" + old.ID
|
|
items, err := s.repo.CreationSubmissions(userID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range items {
|
|
if item.ItemKey == successorKey {
|
|
out := creationSubmissionOutput(item)
|
|
return &out, nil
|
|
}
|
|
}
|
|
if old.RevokedAt != nil {
|
|
return nil, creationConflict("原方案已撤销,请重新准备生成项")
|
|
}
|
|
var request CreateTaskRequest
|
|
if err = json.Unmarshal([]byte(old.RequestJSON), &request); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = validateCreationSubmissionScope(run, old.ProposalVersion, request); err != nil {
|
|
return nil, err
|
|
}
|
|
if old.ProposalVersion > 0 && old.ProposalHash != run.ApprovedProposalHash {
|
|
return nil, creationConflict("原方案已变化,请重新准备生成项")
|
|
}
|
|
item, normalized, err := s.buildCreationSubmission(userID, run, successorKey, old.ProposalVersion, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.storageMu.Lock()
|
|
defer s.storageMu.Unlock()
|
|
err = s.repo.MutateCreationRun(userID, id, func(current *model.CreationRun, repo *repository.Repository) error {
|
|
if e := validateCreationGuard(current, req.CreationGuard); e != nil {
|
|
return e
|
|
}
|
|
fresh, e := repo.CreationSubmission(userID, id, old.ID)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if fresh.TaskID != nil {
|
|
return creationConflict("任务已经提交,不能刷新报价")
|
|
}
|
|
all, e := repo.CreationSubmissions(userID, id)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
for _, existing := range all {
|
|
if existing.ItemKey == successorKey {
|
|
item = existing
|
|
return nil
|
|
}
|
|
}
|
|
if fresh.RevokedAt != nil {
|
|
return creationConflict("原报价已撤销")
|
|
}
|
|
if e = validateCreationSubmissionScope(current, fresh.ProposalVersion, normalized); e != nil {
|
|
return e
|
|
}
|
|
if fresh.ProposalVersion > 0 && fresh.ProposalHash != current.ApprovedProposalHash {
|
|
return repository.ErrCreationConflict
|
|
}
|
|
if e = s.validateCreationStorage(repo, userID, false, int64(len(item.RequestJSON)+len(item.QuoteJSON)+len(item.PriceSignature))); e != nil {
|
|
return e
|
|
}
|
|
now := time.Now()
|
|
fresh.RevokedAt = &now
|
|
if e = repo.SaveCreationSubmission(fresh); e != nil {
|
|
return e
|
|
}
|
|
return repo.SaveCreationSubmission(&item)
|
|
})
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
out := creationSubmissionOutput(item)
|
|
return &out, nil
|
|
}
|
|
|
|
func (s *Service) validateCreationStorage(repo *repository.Repository, userID string, creating bool, delta int64) error {
|
|
usage, err := repo.UserStorageUsage(userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, bytes, err := repo.CreationStorageUsage(userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
policy, err := s.RuntimePolicy()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return validateStructuredStorageQuotaWithPolicy(usage, "canvas", creating, bytes+delta, policy.Resource)
|
|
}
|
|
func (s *Service) ApproveCreationSubmissions(userID, id string, req CreationRequest) (map[string]any, error) {
|
|
if len(req.SubmissionIDs) == 0 || len(req.SubmissionIDs) > 20 {
|
|
return nil, BadAuthRequest("请选择 1 到 20 项生成任务")
|
|
}
|
|
// Compute current prices before the write transaction; transaction checks their config signatures again.
|
|
prepared := map[string]*model.Task{}
|
|
signatures := map[string]string{}
|
|
for _, sid := range req.SubmissionIDs {
|
|
item, err := s.repo.CreationSubmission(userID, id, sid)
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
var request CreateTaskRequest
|
|
_ = json.Unmarshal([]byte(item.RequestJSON), &request)
|
|
task, order, sig, err := s.prepareCreationTask(userID, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if creationQuoteFor(task, order, sig, item.ExpiresAt).QuoteHash != creationSubmissionOutput(*item).Quote.QuoteHash {
|
|
return nil, creationConflict("报价已变化,请重新准备并确认")
|
|
}
|
|
prepared[sid] = task
|
|
signatures[sid] = sig
|
|
}
|
|
out := []CreationSubmissionOutput{}
|
|
err := s.repo.MutateCreationRun(userID, id, func(run *model.CreationRun, repo *repository.Repository) error {
|
|
if e := validateCreationGuard(run, req.CreationGuard); e != nil {
|
|
return e
|
|
}
|
|
now := time.Now()
|
|
for _, sid := range req.SubmissionIDs {
|
|
item, e := repo.CreationSubmission(userID, id, sid)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if item.RevokedAt != nil || !item.ExpiresAt.After(now) {
|
|
return creationConflict("报价已过期或撤销")
|
|
}
|
|
var request CreateTaskRequest
|
|
_ = json.Unmarshal([]byte(item.RequestJSON), &request)
|
|
if e = validateCreationSubmissionScope(run, item.ProposalVersion, request); e != nil {
|
|
return e
|
|
}
|
|
if item.ProposalVersion > 0 && item.ProposalHash != run.ApprovedProposalHash {
|
|
return repository.ErrCreationConflict
|
|
}
|
|
if e = checkCreationPriceSignature(repo, prepared[sid], signatures[sid]); e != nil {
|
|
return e
|
|
}
|
|
if item.ApprovedAt == nil {
|
|
item.ApprovedAt = &now
|
|
if e = repo.SaveCreationSubmission(item); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
out = append(out, creationSubmissionOutput(*item))
|
|
}
|
|
return nil
|
|
})
|
|
return map[string]any{"submissions": out}, creationError(err)
|
|
}
|
|
func checkCreationPriceSignature(repo *repository.Repository, task *model.Task, want string) error {
|
|
var input map[string]any
|
|
_ = json.Unmarshal([]byte(task.InputJSON), &input)
|
|
config, _ := input["config"].(map[string]any)
|
|
got, err := repo.CreationPriceSignature(task, stringValue(config["channelId"]), stringValue(config["model"]))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if got != want {
|
|
return creationConflict("模型或价格配置已更新,请重新确认")
|
|
}
|
|
return nil
|
|
}
|
|
func (s *Service) ExecuteCreationSubmission(userID, id string, req CreationRequest) (*model.Task, error) {
|
|
run, err := s.repo.CreationRun(userID, id)
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
if err = validateCreationGuard(run, req.CreationGuard); err != nil {
|
|
return nil, err
|
|
}
|
|
item, err := s.repo.CreationSubmission(userID, id, req.SubmissionID)
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
if item.TaskID != nil {
|
|
task, e := s.repo.TaskForUser(userID, *item.TaskID)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
return taskForOutput(*task), nil
|
|
}
|
|
var request CreateTaskRequest
|
|
if err = json.Unmarshal([]byte(item.RequestJSON), &request); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = validateCreationSubmissionScope(run, item.ProposalVersion, request); err != nil {
|
|
return nil, err
|
|
}
|
|
task, order, signature, err := s.prepareCreationTask(userID, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if creationQuoteFor(task, order, signature, item.ExpiresAt).QuoteHash != creationSubmissionOutput(*item).Quote.QuoteHash {
|
|
return nil, creationConflict("报价已变化,请重新确认")
|
|
}
|
|
var input map[string]any
|
|
_ = json.Unmarshal([]byte(task.InputJSON), &input)
|
|
if err = s.protectTaskSecrets(input); err != nil {
|
|
return nil, err
|
|
}
|
|
b, _ := json.Marshal(input)
|
|
task.InputJSON = string(b)
|
|
task.CreationSubmissionID = &item.ID
|
|
if order != nil {
|
|
task.BillingOrderID = order.ID
|
|
}
|
|
policy, err := s.RuntimePolicy()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.storageMu.Lock()
|
|
defer s.storageMu.Unlock()
|
|
err = s.repo.MutateCreationRun(userID, id, func(current *model.CreationRun, repo *repository.Repository) error {
|
|
if e := validateCreationGuard(current, req.CreationGuard); e != nil {
|
|
return e
|
|
}
|
|
fresh, e := repo.CreationSubmission(userID, id, item.ID)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if fresh.TaskID != nil {
|
|
existing, e := repo.TaskForUser(userID, *fresh.TaskID)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
task = existing
|
|
return nil
|
|
}
|
|
if fresh.ApprovedAt == nil || fresh.RevokedAt != nil || !fresh.ExpiresAt.After(time.Now()) {
|
|
return creationConflict("任务尚未批准或报价已过期")
|
|
}
|
|
if e = validateCreationSubmissionScope(current, fresh.ProposalVersion, request); e != nil {
|
|
return e
|
|
}
|
|
if fresh.ProposalVersion > 0 && fresh.ProposalHash != current.ApprovedProposalHash {
|
|
return repository.ErrCreationConflict
|
|
}
|
|
if e = checkCreationPriceSignature(repo, task, signature); e != nil {
|
|
return e
|
|
}
|
|
if e = createTaskWithStorageQuotaRepository(repo, task, order, policy); e != nil {
|
|
return e
|
|
}
|
|
fresh.TaskID = &task.ID
|
|
if e = repo.SaveCreationSubmission(fresh); e != nil {
|
|
return e
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, creationError(err)
|
|
}
|
|
return taskForOutput(*task), nil
|
|
}
|