From 3dd84ff25ba097c8df8d222c88cf20c6c683d29d Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:07:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(*):=20=E6=94=AF=E4=BB=98=E4=B8=8EAgent?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=20-=20=E4=BF=AE=E5=A4=8D=E5=95=86=E5=93=81?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=B9=B6=E4=BF=9D=E8=AF=81=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=A8=B3=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/cloud_agent_pi_coordinator.go | 40 +- .../app/cloud_agent_pi_runner_test.go | 16 + backend/internal/app/cloud_agent_runtime.go | 157 +++--- backend/internal/app/payment.go | 115 ++++- backend/internal/app/service.go | 6 +- backend/internal/database/migrations.go | 5 +- backend/internal/model/models_payment.go | 40 +- .../internal/protocol/km_kemei_video_test.go | 51 ++ backend/internal/repository/payment.go | 120 +++-- plugin-packages/km-kemei-video.yingce-plugin | Bin 0 -> 9195 bytes plugin-packages/km-kemei-video/README.md | 5 + .../km-kemei-video/docs/interface.md | 487 ++++++++++++++++++ plugin-packages/km-kemei-video/manifest.json | 432 ++++++++++++++++ .../layout/workspace-wallet-modal.tsx | 286 ++++++++-- .../pages/admin/payments/payments-page.tsx | 212 +++++++- web/src/pages/admin/users/users-drawer.tsx | 70 ++- web/src/services/api/payments.ts | 20 +- 17 files changed, 1836 insertions(+), 226 deletions(-) create mode 100644 backend/internal/protocol/km_kemei_video_test.go create mode 100644 plugin-packages/km-kemei-video.yingce-plugin create mode 100644 plugin-packages/km-kemei-video/README.md create mode 100644 plugin-packages/km-kemei-video/docs/interface.md create mode 100644 plugin-packages/km-kemei-video/manifest.json diff --git a/backend/internal/app/cloud_agent_pi_coordinator.go b/backend/internal/app/cloud_agent_pi_coordinator.go index eb43a7a60..5cdef7833 100644 --- a/backend/internal/app/cloud_agent_pi_coordinator.go +++ b/backend/internal/app/cloud_agent_pi_coordinator.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "time" "gorm.io/gorm" @@ -52,7 +53,13 @@ func (s *Service) startCloudAgentPi(userID, runID string) { defer func() { s.piRunnerMu.Lock() delete(s.piRunners, runID) + _, restart := s.piRunnerRestarts[runID] + delete(s.piRunnerRestarts, runID) s.piRunnerMu.Unlock() + // 先登记新会话再 Done,关闭流程等待时不会漏掉刚恢复的会话。 + if restart { + s.startCloudAgentPi(userID, runID) + } s.piRunnerWg.Done() }() @@ -63,6 +70,26 @@ func (s *Service) startCloudAgentPi(userID, runID string) { }() } +// resumeCloudAgentPi 用于审批后恢复运行。审批暂停的会话在中止后仍要刷新事件和 +// 会话快照,用户很快批准时它可能还没退出;这时不能像普通重复启动那样丢弃, +// 而是登记一次重启,等旧会话退出后用已保存的恢复提示词继续。 +func (s *Service) resumeCloudAgentPi(userID, runID string) { + if s == nil || s.disablePiRuntime || runID == "" { + return + } + s.piRunnerMu.Lock() + if _, exists := s.piRunners[runID]; exists && !s.piRunnersClosed { + if s.piRunnerRestarts == nil { + s.piRunnerRestarts = make(map[string]struct{}) + } + s.piRunnerRestarts[runID] = struct{}{} + s.piRunnerMu.Unlock() + return + } + s.piRunnerMu.Unlock() + s.startCloudAgentPi(userID, runID) +} + // closeCloudAgentPiRunners cancels and joins every Pi session before the // service's database and other dependencies are closed. Without the join, a // test or graceful shutdown can close the repository while a child runtime is @@ -308,12 +335,17 @@ func (s *Service) runCloudAgentPiSession(ctx context.Context, userID, runID stri } // 7. 运行 Pi 会话 + var pausedForApproval atomic.Bool err = runCloudAgentPi(ctx, request, cloudAgentPiBridge{ Model: func(callCtx context.Context, payload map[string]json.RawMessage) (any, error) { return s.cloudAgentPiModel(callCtx, userID, runID, payload) }, Tool: func(callCtx context.Context, payload map[string]json.RawMessage) (any, error) { - return s.cloudAgentPiTool(callCtx, userID, runID, payload) + result, err := s.cloudAgentPiTool(callCtx, userID, runID, payload) + if paused, ok := result.(map[string]any); ok && paused["pause"] == true { + pausedForApproval.Store(true) + } + return result, err }, Event: func(callCtx context.Context, payload map[string]json.RawMessage) (any, error) { return s.cloudAgentPiEvent(callCtx, userID, runID, payload) @@ -323,6 +355,12 @@ func (s *Service) runCloudAgentPiSession(ctx context.Context, userID, runID stri return err } + // 本轮因审批暂停:运行由审批决定接管,不能在这里完成。用户可能在会话退出前 + // 就已批准,此时状态已回到 running,若继续会把未恢复的运行误标为完成。 + if pausedForApproval.Load() { + return nil + } + // 8. 清理恢复提示词 if runtimeState.PiResumePrompt != "" { if err := s.saveCloudAgentPiResumePrompt(userID, runID, ""); err != nil { diff --git a/backend/internal/app/cloud_agent_pi_runner_test.go b/backend/internal/app/cloud_agent_pi_runner_test.go index c0f16c408..32927f59c 100644 --- a/backend/internal/app/cloud_agent_pi_runner_test.go +++ b/backend/internal/app/cloud_agent_pi_runner_test.go @@ -16,3 +16,19 @@ func TestStartCloudAgentPiDoesNotRestartAnActiveRunner(t *testing.T) { t.Fatalf("duplicate start cancelled the active Pi runner: %v", err) } } + +// 审批恢复时旧会话可能还在收尾:不能取消它,也不能丢弃恢复请求。 +func TestResumeCloudAgentPiQueuesRestartWhileRunnerIsExiting(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s := &Service{piRunners: map[string]context.CancelFunc{"run-a": cancel}} + s.resumeCloudAgentPi("user-a", "run-a") + + if err := ctx.Err(); err != nil { + t.Fatalf("resume cancelled the exiting Pi runner: %v", err) + } + if _, queued := s.piRunnerRestarts["run-a"]; !queued { + t.Fatal("resume request was dropped while the previous runner was still exiting") + } +} diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 8517e9026..44162769f 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -2454,80 +2454,119 @@ func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, rea return creationConflict("审批不存在或已过期") } // 只在记录审批决定的这次写入期间持锁:随后的工具执行(advanceCloudAgentTool) - // 会自己获取 storageMu,持锁到函数返回会造成自锁。 - s.storageMu.Lock() - err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { - if settings != nil { - if err := s.updateCloudAgentMediaApproval(repo, run, &state, *settings); err != nil { + // 会自己获取 storageMu,持锁到函数返回会造成自锁。旧 Pi 会话退出前仍可能 + // 写入最后一个事件,因此审批决定采用重读后 CAS 重试。 + for attempt := 0; attempt < 8; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 20 * time.Millisecond) + run, err = s.repo.CloudAgent(userID, id) + if err != nil { return err } - } - state.Approval.Decision = decision - state.Approval.Reason = reason - state.Decisions[approvalID] = decision - if settings != nil { - if state.DecisionSettings == nil { - state.DecisionSettings = map[string]string{} - } - state.DecisionSettings[approvalID] = creationHash(settings) - } - if decision == "approve" && state.Approval.Prepared != nil { - if state.DecisionPreparedHashes == nil { - state.DecisionPreparedHashes = map[string]string{} - } - state.DecisionPreparedHashes[approvalID] = state.Approval.Prepared.Hash - } - if decision == "reject" { - // Rejection is a user control-plane decision, not a failed tool - // invocation. Make it terminal before the scheduler can advance the - // pending call; no tool result, canvas mutation, generation task or - // follow-up model request may be produced from this decision. - current.Status = "rejected" - current.FailureMessage = "" - state.Approval = nil - state.event(id, "approval_decided", map[string]any{ - "approvalId": approvalID, - "decision": decision, - "reason": reason, - "text": "已拒绝本次生成,草稿节点仍保留在画布中;未提交任务、未产生扣费。你可以继续编辑后重新申请。", - }) - if err := repo.ReleaseCloudAgentResourceLeases(userID, approvalID); err != nil { + state, err = cloudAgentDecodeForExecution(run) + if err != nil { return err } + if previous, ok := state.Decisions[approvalID]; ok { + if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) { + return nil + } + return creationConflict("该审批已有不同决定") + } + if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID { + return creationConflict("审批不存在或已过期") + } + } + s.storageMu.Lock() + err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + if settings != nil { + if err := s.updateCloudAgentMediaApproval(repo, run, &state, *settings); err != nil { + return err + } + } + state.Approval.Decision = decision + state.Approval.Reason = reason + state.Decisions[approvalID] = decision + if settings != nil { + if state.DecisionSettings == nil { + state.DecisionSettings = map[string]string{} + } + state.DecisionSettings[approvalID] = creationHash(settings) + } + if decision == "approve" && state.Approval.Prepared != nil { + if state.DecisionPreparedHashes == nil { + state.DecisionPreparedHashes = map[string]string{} + } + state.DecisionPreparedHashes[approvalID] = state.Approval.Prepared.Hash + } + if decision == "reject" { + // Rejection is a user control-plane decision, not a failed tool + // invocation. Make it terminal before the scheduler can advance the + // pending call; no tool result, canvas mutation, generation task or + // follow-up model request may be produced from this decision. + current.Status = "rejected" + current.FailureMessage = "" + state.Approval = nil + state.event(id, "approval_decided", map[string]any{ + "approvalId": approvalID, + "decision": decision, + "reason": reason, + "text": "已拒绝本次生成,草稿节点仍保留在画布中;未提交任务、未产生扣费。你可以继续编辑后重新申请。", + }) + if err := repo.ReleaseCloudAgentResourceLeases(userID, approvalID); err != nil { + return err + } + return cloudAgentSave(current, &state) + } + current.Status = "running" + payload := map[string]any{"approvalId": approvalID, "decision": decision, "arguments": json.RawMessage(state.Approval.Call.Function.Arguments), "preview": state.Approval.Preview, "modelName": state.Approval.ModelName} + if state.Approval.Prepared != nil { + payload["preparedHash"] = state.Approval.Prepared.Hash + payload["generationId"] = state.Approval.Prepared.GenerationID + } + state.event(id, "approval_decided", payload) return cloudAgentSave(current, &state) + }) + s.storageMu.Unlock() + if !errors.Is(err, repository.ErrCreationConflict) { + break } - current.Status = "running" - payload := map[string]any{"approvalId": approvalID, "decision": decision, "arguments": json.RawMessage(state.Approval.Call.Function.Arguments), "preview": state.Approval.Preview, "modelName": state.Approval.ModelName} - if state.Approval.Prepared != nil { - payload["preparedHash"] = state.Approval.Prepared.Hash - payload["generationId"] = state.Approval.Prepared.GenerationID - } - state.event(id, "approval_decided", payload) - return cloudAgentSave(current, &state) - }) - s.storageMu.Unlock() + } if err != nil || decision != "approve" { return err } // Approval only releases the paused tool. Execute it once in the Go business // executor, then give the durable result back to Pi; Go never asks a model // what to do next. - latest, err := s.repo.CloudAgent(userID, id) - if err != nil { - return err + var mediaTaskID string + for attempt := 0; attempt < 8; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 20 * time.Millisecond) + } + latest, err := s.repo.CloudAgent(userID, id) + if err != nil { + return err + } + state, err = cloudAgentDecode(latest) + if err != nil { + return err + } + err = s.advanceCloudAgentTool(latest, &state) + if !errors.Is(err, repository.ErrCreationConflict) { + if err != nil { + return err + } + mediaTaskID = state.MediaTaskID + break + } + if attempt == 7 { + return err + } } - state, err = cloudAgentDecode(latest) - if err != nil { - return err - } - if err = s.advanceCloudAgentTool(latest, &state); err != nil { - return err - } - if state.MediaTaskID == "" { + if mediaTaskID == "" { return s.resumeCloudAgentAfterApproval(userID, id, &state) } // 媒体生成可能要几分钟:审批请求立即返回,等待与回写在后台完成后再恢复运行。 - mediaTaskID := state.MediaTaskID s.startApprovedCloudAgentMediaWaiter(userID, id, mediaTaskID) return nil } @@ -2564,7 +2603,7 @@ func (s *Service) resumeCloudAgentAfterApproval(userID, id string, state *cloudA if err := s.saveCloudAgentPiResumePrompt(userID, id, state.PiResumePrompt); err != nil { return err } - s.startCloudAgentPi(userID, id) + s.resumeCloudAgentPi(userID, id) return nil } diff --git a/backend/internal/app/payment.go b/backend/internal/app/payment.go index 1fd320121..af51d2b9c 100644 --- a/backend/internal/app/payment.go +++ b/backend/internal/app/payment.go @@ -57,12 +57,18 @@ type UpdatePaymentProviderConfigRequest struct { } type TopupProductRequest struct { - Name string `json:"name"` - Description string `json:"description"` - AmountFen int64 `json:"amountFen"` - CreditsMicrocredits int64 `json:"creditsMicrocredits"` - Enabled bool `json:"enabled"` - SortOrder int `json:"sortOrder"` + Name string `json:"name"` + Description string `json:"description"` + AmountFen int64 `json:"amountFen"` + CreditsMicrocredits int64 `json:"creditsMicrocredits"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + SaleStrategy model.TopupSaleStrategy `json:"saleStrategy"` + PeriodDays int `json:"periodDays"` + PeriodPurchaseLimit int `json:"periodPurchaseLimit"` + StockTotal int64 `json:"stockTotal"` + SaleStartAt *time.Time `json:"saleStartAt"` + SaleEndAt *time.Time `json:"saleEndAt"` } type CreatePaymentOrderRequest struct { @@ -430,28 +436,63 @@ func (s *Service) TopupProducts(actor *model.User) ([]model.TopupProduct, error) if err := s.RequireFeature(FeatureCredits); err != nil { return nil, err } - return s.repo.TopupProducts(false) + products, err := s.repo.TopupProducts(false) + if err != nil { + return nil, err + } + return decorateTopupProducts(products, time.Now()), nil } func (s *Service) AdminTopupProducts(actor *model.User) ([]model.TopupProduct, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } - return s.repo.TopupProducts(true) + products, err := s.repo.TopupProducts(true) + if err != nil { + return nil, err + } + return decorateTopupProducts(products, time.Now()), nil +} + +func decorateTopupProducts(products []model.TopupProduct, now time.Time) []model.TopupProduct { + for index := range products { + product := &products[index] + if product.SaleStrategy == "" { + product.SaleStrategy = model.TopupSaleStrategyUnlimited + } + product.CanPurchase = product.Enabled + product.SaleStatus = "on_sale" + if !product.Enabled { + product.CanPurchase = false + product.SaleStatus = "disabled" + } else if product.SaleStrategy == model.TopupSaleStrategyInventory && product.StockRemaining <= 0 { + product.CanPurchase = false + product.SaleStatus = "sold_out" + } else if product.SaleStrategy == model.TopupSaleStrategyTimed { + if product.SaleStartAt != nil && now.Before(*product.SaleStartAt) { + product.CanPurchase = false + product.SaleStatus = "upcoming" + } else if product.SaleEndAt != nil && !now.Before(*product.SaleEndAt) { + product.CanPurchase = false + product.SaleStatus = "ended" + } + } + } + return products } func (s *Service) CreateTopupProduct(actor *model.User, request TopupProductRequest) (*model.TopupProduct, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } - product, err := topupProductFromRequest(newID(), actor.ID, request) + product, err := topupProductFromRequest(newID(), actor.ID, request, 0, 0) if err != nil { return nil, err } if err := s.repo.CreateTopupProduct(product); err != nil { return nil, err } - if err := s.appendAdminAudit(actor, "topup_product.create", "topup_product", product.ID, "创建积分充值商品", map[string]any{"amountFen": product.AmountFen, "creditsMicrocredits": product.CreditsMicrocredits}); err != nil { + if err := s.appendAdminAudit(actor, "topup_product.create", "topup_product", product.ID, "创建积分充值商品", map[string]any{"amountFen": product.AmountFen, "creditsMicrocredits": product.CreditsMicrocredits, "saleStrategy": product.SaleStrategy}); err != nil { return nil, err } return product, nil @@ -461,23 +502,32 @@ func (s *Service) UpdateTopupProduct(actor *model.User, id string, request Topup if err := s.RequireAdmin(actor); err != nil { return nil, err } - if _, err := s.repo.TopupProduct(id); err != nil { + existing, err := s.repo.TopupProduct(id) + if err != nil { return nil, err } - product, err := topupProductFromRequest(strings.TrimSpace(id), actor.ID, request) + consumed := existing.StockTotal - existing.StockRemaining + if existing.SaleStrategy != model.TopupSaleStrategyInventory { + consumed = 0 + } + product, err := topupProductFromRequest(strings.TrimSpace(id), actor.ID, request, existing.StockTotal, consumed) if err != nil { return nil, err } if err := s.repo.UpdateTopupProduct(product); err != nil { return nil, err } - if err := s.appendAdminAudit(actor, "topup_product.update", "topup_product", product.ID, "更新积分充值商品", map[string]any{"enabled": product.Enabled}); err != nil { + if err := s.appendAdminAudit(actor, "topup_product.update", "topup_product", product.ID, "更新积分充值商品", map[string]any{"enabled": product.Enabled, "saleStrategy": product.SaleStrategy}); err != nil { return nil, err } return s.repo.TopupProduct(product.ID) } -func topupProductFromRequest(id, actorID string, request TopupProductRequest) (*model.TopupProduct, error) { +func topupProductFromRequest(id, actorID string, request TopupProductRequest, stockArgs ...int64) (*model.TopupProduct, error) { + var consumed int64 + if len(stockArgs) > 1 { + consumed = stockArgs[1] + } name := strings.TrimSpace(request.Name) if name == "" || len([]rune(name)) > 120 { return nil, BadAuthRequest("充值商品名称不能为空且不能超过 120 个字符") @@ -488,10 +538,40 @@ func topupProductFromRequest(id, actorID string, request TopupProductRequest) (* if request.CreditsMicrocredits <= 0 || request.CreditsMicrocredits > maxTopupCreditsMicrocredits { return nil, BadAuthRequest("充值积分必须为 0.000001 至 10 亿积分") } + strategy := request.SaleStrategy + if strategy == "" { + strategy = model.TopupSaleStrategyUnlimited + } + if strategy != model.TopupSaleStrategyUnlimited && strategy != model.TopupSaleStrategyPeriodic && strategy != model.TopupSaleStrategyInventory && strategy != model.TopupSaleStrategyTimed { + return nil, BadAuthRequest("上架策略无效") + } + if strategy == model.TopupSaleStrategyPeriodic && (request.PeriodDays <= 0 || request.PeriodDays > 3650 || request.PeriodPurchaseLimit <= 0 || request.PeriodPurchaseLimit > 100000) { + return nil, BadAuthRequest("周期性商品需设置有效的周期天数和购买次数") + } + if strategy == model.TopupSaleStrategyInventory && request.StockTotal <= 0 { + return nil, BadAuthRequest("库存性商品库存必须大于 0") + } + if strategy == model.TopupSaleStrategyTimed && (request.SaleStartAt == nil || request.SaleEndAt == nil || !request.SaleEndAt.After(*request.SaleStartAt)) { + return nil, BadAuthRequest("限时商品必须设置有效的发售和结束时间") + } + stockRemaining := int64(0) + if strategy == model.TopupSaleStrategyInventory { + if consumed < 0 { + consumed = 0 + } + if request.StockTotal < consumed { + return nil, BadAuthRequest(fmt.Sprintf("库存不能低于已售数量(至少 %d)", consumed)) + } + stockRemaining = request.StockTotal - consumed + } return &model.TopupProduct{ ID: id, Name: name, Description: truncateRunes(strings.TrimSpace(request.Description), 500), AmountFen: request.AmountFen, CreditsMicrocredits: request.CreditsMicrocredits, - Enabled: request.Enabled, SortOrder: request.SortOrder, CreatedBy: actorID, UpdatedBy: actorID, + Enabled: request.Enabled, SortOrder: request.SortOrder, SaleStrategy: strategy, + PeriodDays: request.PeriodDays, PeriodPurchaseLimit: request.PeriodPurchaseLimit, + StockTotal: request.StockTotal, StockRemaining: stockRemaining, + SaleStartAt: request.SaleStartAt, SaleEndAt: request.SaleEndAt, + CreatedBy: actorID, UpdatedBy: actorID, }, nil } @@ -551,7 +631,10 @@ func (s *Service) CreatePaymentOrder(ctx context.Context, actor *model.User, req Status: model.PaymentOrderCreated, CheckoutMode: provider.Descriptor().CheckoutMode, ExpiresAt: now.Add(time.Duration(config.CloseAfterMinutes) * time.Minute), } - order, created, err := s.repo.CreatePaymentOrder(order) + order, created, err := s.repo.CreatePaymentOrderWithProductReservation(order) + if errors.Is(err, repository.ErrTopupUnavailable) { + return nil, NewAppError(http.StatusConflict, "充值商品当前不可购买") + } if err != nil { return nil, err } diff --git a/backend/internal/app/service.go b/backend/internal/app/service.go index 1d2f5c1ae..404d5e5e3 100644 --- a/backend/internal/app/service.go +++ b/backend/internal/app/service.go @@ -81,8 +81,10 @@ type Service struct { piRunnerMu sync.Mutex piRunnerWg sync.WaitGroup piRunners map[string]context.CancelFunc - piRunnersClosed bool - disablePiRuntime bool + // piRunnerRestarts 记录审批恢复时旧会话仍在收尾的运行,旧会话退出后再启动一次。 + piRunnerRestarts map[string]struct{} + piRunnersClosed bool + disablePiRuntime bool // legacyCloudAgentRootTask is enabled only by tests that exercise the pre-Pi // model-worker path. Runtime availability must not change root task semantics. legacyCloudAgentRootTask bool diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index ce560edef..bcf2de164 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 42 +const CurrentSchemaVersion int64 = 43 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -142,6 +142,9 @@ var schemaMigrations = []migration{ {version: 42, name: "cloud_agent_pi_sessions", checksum: "sha256:cloud-agent-pi-sessions-v42-20260928", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.CloudAgentPiSession{}) }}, + {version: 43, name: "topup_sale_strategies", checksum: "sha256:topup-sale-strategies-v43-20260929", apply: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.TopupProduct{}, &model.PaymentOrder{}) + }}, } func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { diff --git a/backend/internal/model/models_payment.go b/backend/internal/model/models_payment.go index 2996617af..f7efcb6ab 100644 --- a/backend/internal/model/models_payment.go +++ b/backend/internal/model/models_payment.go @@ -6,6 +6,14 @@ type PaymentOrderStatus string type PaymentNotificationStatus string type PaymentReconciliationStatus string type PaymentReconciliationResult string +type TopupSaleStrategy string + +const ( + TopupSaleStrategyUnlimited TopupSaleStrategy = "unlimited" + TopupSaleStrategyPeriodic TopupSaleStrategy = "periodic" + TopupSaleStrategyInventory TopupSaleStrategy = "inventory" + TopupSaleStrategyTimed TopupSaleStrategy = "timed" +) const ( PaymentOrderCreated PaymentOrderStatus = "created" @@ -35,17 +43,26 @@ const ( // TopupProduct is the server-owned price and credit snapshot source. Clients // select a product ID and can never submit their own payable amount or credits. type TopupProduct struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Name string `json:"name" gorm:"size:120"` - Description string `json:"description,omitempty" gorm:"size:500"` - AmountFen int64 `json:"amountFen" gorm:"index"` - CreditsMicrocredits int64 `json:"creditsMicrocredits"` - Enabled bool `json:"enabled" gorm:"index"` - SortOrder int `json:"sortOrder" gorm:"index"` - CreatedBy string `json:"createdBy" gorm:"index;size:36"` - UpdatedBy string `json:"updatedBy" gorm:"index;size:36"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id" gorm:"primaryKey;size:36"` + Name string `json:"name" gorm:"size:120"` + Description string `json:"description,omitempty" gorm:"size:500"` + AmountFen int64 `json:"amountFen" gorm:"index"` + CreditsMicrocredits int64 `json:"creditsMicrocredits"` + Enabled bool `json:"enabled" gorm:"index"` + SortOrder int `json:"sortOrder" gorm:"index"` + SaleStrategy TopupSaleStrategy `json:"saleStrategy" gorm:"size:24;index"` + PeriodDays int `json:"periodDays,omitempty"` + PeriodPurchaseLimit int `json:"periodPurchaseLimit,omitempty"` + StockTotal int64 `json:"stockTotal,omitempty"` + StockRemaining int64 `json:"stockRemaining,omitempty"` + SaleStartAt *time.Time `json:"saleStartAt,omitempty"` + SaleEndAt *time.Time `json:"saleEndAt,omitempty"` + SaleStatus string `json:"saleStatus,omitempty" gorm:"-"` + CanPurchase bool `json:"canPurchase" gorm:"-"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + UpdatedBy string `json:"updatedBy" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // PaymentProviderConfig is immutable and versioned. Payment orders pin one @@ -71,6 +88,7 @@ type PaymentOrder struct { MerchantOrderNo string `json:"merchantOrderNo" gorm:"size:32;uniqueIndex"` ProductID string `json:"productId" gorm:"size:36;index"` ProductName string `json:"productName" gorm:"size:120"` + StockReserved bool `json:"-" gorm:"not null;default:false"` ProviderID string `json:"providerId" gorm:"size:80;index;uniqueIndex:idx_payment_provider_trade,priority:1"` PluginID string `json:"pluginId" gorm:"size:120;index"` PluginVersion string `json:"pluginVersion" gorm:"size:40"` diff --git a/backend/internal/protocol/km_kemei_video_test.go b/backend/internal/protocol/km_kemei_video_test.go new file mode 100644 index 000000000..bb946bdcc --- /dev/null +++ b/backend/internal/protocol/km_kemei_video_test.go @@ -0,0 +1,51 @@ +package protocol + +import ( + "context" + "testing" +) + +func TestKemeiVideoCreatePollAndCancel(t *testing.T) { + adapter := officialPackageAdapter(t, "km-kemei-video.yingce-plugin", "km-kemei-video") + create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{ + Model: "kling-v3-omni", Prompt: "推开欧式古典大门,展现奇幻云海城堡", Duration: 5, + AspectRatio: "16:9", Resolution: "1080p", GenerateAudio: true, Watermark: false, + Images: []MediaReference{{URL: "https://cdn.example/first.png", Role: "first_frame", Order: 1}, {URL: "https://cdn.example/last.png", Role: "last_frame", Order: 2}}, + Videos: []MediaReference{{URL: "https://cdn.example/ref.mp4", Role: "reference_video", Order: 1}}, + ProviderOptions: map[string]map[string]any{"km-kemei-video": { + "negative_prompt": "模糊, 水印", "files": []any{"https://cdn.example/spec.pdf"}, "links": []any{"https://example.com/reference"}, + "ratio": "adaptive", "web_search": true, "seed": 42, + }}, + }}) + if err != nil { + t.Fatal(err) + } + if create.Method != "POST" || create.Path != "/v1/video/generations" || create.Auth.Type != "bearer" { + t.Fatalf("create = %#v", create) + } + body := manifestTestBody(t, create) + if body["model"] != "kling-v3-omni" || body["prompt"] != "推开欧式古典大门,展现奇幻云海城堡" || body["ratio"] != "adaptive" || body["seed"] != float64(42) { + t.Fatalf("body = %#v", body) + } + images, _ := body["images"].([]any) + if len(images) != 2 || images[0].(map[string]any)["role"] != "first_frame" || images[1].(map[string]any)["role"] != "last_frame" { + t.Fatalf("images = %#v", images) + } + + created, err := adapter.ParseCreate(context.Background(), []byte(`{"id":"video_task_abc123xyz789","status":"pending"}`)) + if err != nil || created.TaskID != "video_task_abc123xyz789" || created.Status != StatusPending { + t.Fatalf("created = %#v err=%v", created, err) + } + poll, err := adapter.BuildPoll(context.Background(), PollContext{TaskID: created.TaskID}) + if err != nil || poll.Path != "/v1/video/generations/video_task_abc123xyz789" { + t.Fatalf("poll = %#v err=%v", poll, err) + } + succeeded, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: created.TaskID}, []byte(`{"task_id":"video_task_abc123xyz789","status":"completed","data":[{"url":"https://example.com/output/generated_video.mp4"}]}`)) + if err != nil || succeeded.Status != StatusSucceeded || len(succeeded.Result.Videos) != 1 || succeeded.Result.Videos[0].URL != "https://example.com/output/generated_video.mp4" || !succeeded.Result.Videos[0].Ephemeral { + t.Fatalf("succeeded = %#v err=%v", succeeded, err) + } + cancel, err := adapter.BuildCancel(context.Background(), PollContext{TaskID: created.TaskID}) + if err != nil || cancel.Method != "DELETE" || cancel.Path != "/v1/video/generations/video_task_abc123xyz789" { + t.Fatalf("cancel = %#v err=%v", cancel, err) + } +} diff --git a/backend/internal/repository/payment.go b/backend/internal/repository/payment.go index 6e326e104..36f81a149 100644 --- a/backend/internal/repository/payment.go +++ b/backend/internal/repository/payment.go @@ -16,6 +16,7 @@ var ( ErrPaymentEvidenceMismatch = errors.New("支付凭证与订单不一致") ErrPaymentTradeNoConflict = errors.New("该支付流水号已被使用") ErrPaymentCreditOverflow = errors.New("积分余额超出上限,无法入账") + ErrTopupUnavailable = errors.New("充值商品当前不可购买") ) // Credit balances are serialized to JavaScript clients as JSON numbers. @@ -54,7 +55,11 @@ func (r *Repository) UpdateTopupProduct(product *model.TopupProduct) error { return r.db.Model(&model.TopupProduct{}).Where("id = ?", product.ID).Updates(map[string]any{ "name": product.Name, "description": product.Description, "amount_fen": product.AmountFen, "credits_microcredits": product.CreditsMicrocredits, "enabled": product.Enabled, - "sort_order": product.SortOrder, "updated_by": product.UpdatedBy, "updated_at": time.Now(), + "sort_order": product.SortOrder, "sale_strategy": product.SaleStrategy, + "period_days": product.PeriodDays, "period_purchase_limit": product.PeriodPurchaseLimit, + "stock_total": product.StockTotal, "stock_remaining": product.StockRemaining, + "sale_start_at": product.SaleStartAt, "sale_end_at": product.SaleEndAt, + "updated_by": product.UpdatedBy, "updated_at": time.Now(), }).Error } @@ -80,22 +85,71 @@ func (r *Repository) PaymentProviderConfig(id string) (*model.PaymentProviderCon return &config, r.db.First(&config, "id = ?", strings.TrimSpace(id)).Error } -// CreatePaymentOrder claims the user idempotency key before contacting a -// provider. The returned created flag is false when a retry found its order. -func (r *Repository) CreatePaymentOrder(order *model.PaymentOrder) (*model.PaymentOrder, bool, error) { - created := r.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "user_id"}, {Name: "idempotency_key"}}, - DoNothing: true, - }).Create(order) - if created.Error != nil { - return nil, false, created.Error - } - if created.RowsAffected == 1 { - return order, true, nil - } +// CreatePaymentOrderWithProductReservation claims the user's idempotency key and, +// for inventory products, reserves one unit in the same transaction. +func (r *Repository) CreatePaymentOrderWithProductReservation(order *model.PaymentOrder) (*model.PaymentOrder, bool, error) { var existing model.PaymentOrder - err := r.db.Where("user_id = ? AND idempotency_key = ?", order.UserID, order.IdempotencyKey).First(&existing).Error - return &existing, false, err + created := false + err := r.db.Transaction(func(tx *gorm.DB) error { + var product model.TopupProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, "id = ?", order.ProductID).Error; err != nil { + return err + } + strategy := product.SaleStrategy + if strategy == "" { + strategy = model.TopupSaleStrategyUnlimited + } + now := time.Now() + if !product.Enabled { + return ErrTopupUnavailable + } + if strategy == model.TopupSaleStrategyTimed && ((product.SaleStartAt != nil && now.Before(*product.SaleStartAt)) || (product.SaleEndAt != nil && !now.Before(*product.SaleEndAt))) { + return ErrTopupUnavailable + } + if strategy == model.TopupSaleStrategyPeriodic && product.PeriodDays > 0 && product.PeriodPurchaseLimit > 0 { + var count int64 + if err := tx.Model(&model.PaymentOrder{}).Where("user_id = ? AND product_id = ? AND status = ? AND created_at >= ?", order.UserID, order.ProductID, model.PaymentOrderCredited, now.Add(-time.Duration(product.PeriodDays)*24*time.Hour)).Count(&count).Error; err != nil { + return err + } + if count >= int64(product.PeriodPurchaseLimit) { + return ErrTopupUnavailable + } + } + if strategy == model.TopupSaleStrategyInventory { + updated := tx.Model(&model.TopupProduct{}).Where("id = ? AND stock_remaining > 0", product.ID).Updates(map[string]any{"stock_remaining": gorm.Expr("stock_remaining - 1"), "updated_at": now}) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return ErrTopupUnavailable + } + order.StockReserved = true + } + result := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}, {Name: "idempotency_key"}}, DoNothing: true}).Create(order) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 1 { + created = true + return nil + } + if err := tx.First(&existing, "user_id = ? AND idempotency_key = ?", order.UserID, order.IdempotencyKey).Error; err != nil { + return err + } + if order.StockReserved { + if err := tx.Model(&model.TopupProduct{}).Where("id = ?", order.ProductID).Updates(map[string]any{"stock_remaining": gorm.Expr("stock_remaining + 1"), "updated_at": now}).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, false, err + } + if !created { + return &existing, false, nil + } + return order, true, nil } func (r *Repository) SetPaymentOrderCheckout(id string, checkoutMode, checkoutValue string, checkoutExpiresAt time.Time) error { @@ -115,7 +169,7 @@ func (r *Repository) SetPaymentOrderCheckout(id string, checkoutMode, checkoutVa } func (r *Repository) SetPaymentOrderCreateFailure(id, message string) error { - return r.db.Model(&model.PaymentOrder{}).Where("id = ? AND status = ?", id, model.PaymentOrderCreated).Updates(map[string]any{ + return r.db.Model(&model.PaymentOrder{}).Where("id = ? AND status IN ?", id, []model.PaymentOrderStatus{model.PaymentOrderCreated, model.PaymentOrderCreateFailed}).Updates(map[string]any{ "status": model.PaymentOrderCreateFailed, "last_error": message, "updated_at": time.Now(), }).Error } @@ -282,7 +336,7 @@ func (r *Repository) CompletePaymentOrder(providerID, merchantOrderNo string, ev } tradeNo := evidence.ProviderTradeNo updated := tx.Model(&model.PaymentOrder{}).Where("id = ? AND status <> ?", order.ID, model.PaymentOrderCredited).Updates(map[string]any{ - "status": model.PaymentOrderCredited, "provider_trade_no": &tradeNo, + "status": model.PaymentOrderCredited, "stock_reserved": false, "provider_trade_no": &tradeNo, "provider_status": evidence.ProviderStatus, "provider_paid_at": &paidAt, "credited_at": &now, "last_error": "", "updated_at": now, }) @@ -312,25 +366,27 @@ func (r *Repository) RecordPaymentQuery(id, providerStatus string) error { func (r *Repository) MarkPaymentOrderClosed(id, providerStatus string) error { now := time.Now() - updated := r.db.Model(&model.PaymentOrder{}).Where("id = ? AND status IN ?", id, []model.PaymentOrderStatus{ - model.PaymentOrderCreated, model.PaymentOrderPending, model.PaymentOrderClosing, model.PaymentOrderCreateFailed, - }).Updates(map[string]any{ - "status": model.PaymentOrderClosed, "provider_status": providerStatus, - "closed_at": &now, "last_error": "", "updated_at": now, - }) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected == 0 { + return r.db.Transaction(func(tx *gorm.DB) error { var order model.PaymentOrder - if err := r.db.First(&order, "id = ?", id).Error; err != nil { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, "id = ?", id).Error; err != nil { return err } - if order.Status != model.PaymentOrderCredited && order.Status != model.PaymentOrderClosed { + if order.Status == model.PaymentOrderCredited || order.Status == model.PaymentOrderClosed { + return nil + } + if order.Status != model.PaymentOrderCreated && order.Status != model.PaymentOrderPending && order.Status != model.PaymentOrderClosing && order.Status != model.PaymentOrderCreateFailed { return ErrPaymentOrderStateConflict } - } - return nil + if order.StockReserved { + if err := tx.Model(&model.TopupProduct{}).Where("id = ?", order.ProductID).Updates(map[string]any{"stock_remaining": gorm.Expr("stock_remaining + 1"), "updated_at": now}).Error; err != nil { + return err + } + } + return tx.Model(&model.PaymentOrder{}).Where("id = ?", id).Updates(map[string]any{ + "status": model.PaymentOrderClosed, "stock_reserved": false, "provider_status": providerStatus, + "closed_at": &now, "last_error": "", "updated_at": now, + }).Error + }) } func (r *Repository) RestoreClosingPaymentOrder(id, message string) error { diff --git a/plugin-packages/km-kemei-video.yingce-plugin b/plugin-packages/km-kemei-video.yingce-plugin new file mode 100644 index 0000000000000000000000000000000000000000..c03490d50df597b0e697e2532e809a9593391ac2 GIT binary patch literal 9195 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。 +- 创建:`POST /v1/video/generations`。 +- 查询:`GET /v1/video/generations/{task_id}`,也兼容上游文档中的 `/v1/tasks/{task_id}`。 +- 取消:`DELETE /v1/video/generations/{task_id}`。 + +## 提交任务 + +请求体使用 JSON。`model` 与 `prompt` 为必填字段;其它统一字段按上游 snake_case 名称发送。`images` 支持 URL、Base64 字符串和带 `role` 的对象,图片按输入顺序保留;单图、双图和三图以上的首帧/尾帧/多图参考推断由 KM 网关执行。 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `model` | string | 是 | 如 `doubao-seedance-2-5`、`kling-v3-omni`、`wan3.0-video`。 | +| `prompt` | string | 是 | 描述 motion 与画面的提示词。 | +| `negative_prompt` | string | 否 | 负向提示词,通过 `providerOptions.km-kemei-video.negative_prompt` 透传。 | +| `images` / `image_urls` | array | 否 | 图片 URL、Base64 或带 `role` 的图片对象。插件使用 `images` 字段。 | +| `videos` | array | 否 | 参考视频 URL。 | +| `audios` | array | 否 | 参考音频 URL。 | +| `files` | array | 否 | 参考文件 URL,通过 provider options 透传。 | +| `links` | array | 否 | 参考网页 URL,通过 provider options 透传。 | +| `resolution` | string | 否 | `1080p`、`720p`、`480p` 等。 | +| `ratio` | string | 否 | `16:9`、`9:16`、`adaptive`;未设置时回退统一 `aspectRatio`。 | +| `duration` | integer | 否 | 时长秒数。 | +| `generate_audio` | boolean | 否 | 是否生成背景音效或配音。 | +| `watermark` | boolean | 否 | 是否添加水印。 | +| `web_search` | boolean | 否 | 是否启用联网搜索。 | +| `seed` | integer | 否 | 随机数种子。 | + +扩展字段命名空间为 `providerOptions.km-kemei-video`,支持 `negative_prompt`、`ratio`、`files`、`links`、`web_search`、`seed` 及其它网关扩展字段。该命名空间可通过自定义渠道元数据传入。 + +## 图片角色 + +显式图片角色支持 `first_frame` / `first`、`last_frame` / `end_frame` / `last` / `tail`、`reference_image`。插件会把统一媒体的 `role` 字段编码为对象形式;没有角色时保留为纯 URL 或 Base64 字符串,让网关执行数量推断。 + +## 异步生命周期 + +创建响应的 `id`、`task_id` 或对应 `data` 字段映射为统一任务 ID。任务状态从 `pending`、`processing`、`completed`、`succeeded`、`failed`、`cancelled` 等值归一化。成功结果从 `data[].url` 以及常见的 `video_url`、`output_url`、`result_url` 和顶层等价字段提取;临时 URL 标记为 ephemeral,由宿主下载并持久化。 + +取消只对未完成任务生效。KM 网关返回的 HTTP 错误、`error.code` 和错误消息保持失败语义,不包装成成功。 + + +## Manifest 完整接口定义 + +以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。 + +```json +{ + "apiVersion": "yingce.plugin/v2", + "id": "km-kemei-video", + "name": "KM 可美视频", + "version": "1.0.0", + "author": "KM 可美 / 影策", + "description": "KM 可美视频 OpenAI 兼容异步视频生成协议插件。", + "permissions": [ + "generation.run", + "media.read" + ], + "configuration": { + "fields": [ + { + "name": "apiKey", + "type": "secret", + "label": "API Key", + "required": true + } + ] + }, + "contributes": { + "providers": [ + { + "id": "km-kemei-video", + "label": "KM 可美视频", + "capabilities": [ + "video" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], + "baseUrl": "https://token.xinhankr.com", + "requiresPublicMediaUrls": false, + "auth": { + "type": "bearer", + "field": "apiKey" + }, + "parameters": [ + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "视频生成模型名称。" + }, + { + "name": "prompt", + "type": "string", + "required": true, + "mapping": "prompt", + "description": "描述视频 motion 与画面的提示词。" + }, + { + "name": "negative_prompt", + "type": "string", + "required": false, + "mapping": "providerOptions.km-kemei-video.negative_prompt", + "description": "负向提示词。" + }, + { + "name": "images", + "type": "media[]", + "required": false, + "mapping": "images", + "description": "首帧、尾帧或参考图片,支持 URL 与 Base64。" + }, + { + "name": "image_urls", + "type": "media[]", + "required": false, + "mapping": "images", + "description": "images 的别名。" + }, + { + "name": "videos", + "type": "media[]", + "required": false, + "mapping": "videos", + "description": "参考视频链接数组。" + }, + { + "name": "audios", + "type": "media[]", + "required": false, + "mapping": "audios", + "description": "参考音频链接数组。" + }, + { + "name": "files", + "type": "string[]", + "required": false, + "mapping": "providerOptions.km-kemei-video.files", + "description": "参考文件 URL 数组。" + }, + { + "name": "links", + "type": "string[]", + "required": false, + "mapping": "providerOptions.km-kemei-video.links", + "description": "参考网页 URL 数组。" + }, + { + "name": "resolution", + "type": "string", + "required": false, + "mapping": "resolution", + "description": "目标分辨率,如 1080p、720p、480p。" + }, + { + "name": "ratio", + "type": "string", + "required": false, + "mapping": "providerOptions.km-kemei-video.ratio/aspectRatio", + "description": "宽高比,如 16:9、9:16、adaptive。" + }, + { + "name": "duration", + "type": "integer", + "required": false, + "mapping": "duration", + "description": "视频时长,单位为秒。" + }, + { + "name": "generate_audio", + "type": "boolean", + "required": false, + "mapping": "generateAudio", + "description": "是否同步生成匹配音频。" + }, + { + "name": "watermark", + "type": "boolean", + "required": false, + "mapping": "watermark", + "description": "是否添加水印。" + }, + { + "name": "web_search", + "type": "boolean", + "required": false, + "mapping": "providerOptions.km-kemei-video.web_search", + "description": "是否启用联网搜索。" + }, + { + "name": "seed", + "type": "integer", + "required": false, + "mapping": "providerOptions.km-kemei-video.seed", + "description": "随机数种子。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "KM 可美协议扩展字段。" + } + ], + "create": { + "method": "POST", + "path": "/v1/video/generations", + "contentType": "application/json", + "body": { + "model": { + "$ref": "request.model" + }, + "prompt": { + "$ref": "request.prompt" + }, + "negative_prompt": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.negative_prompt" + } + }, + "images": { + "$omitEmpty": { + "$map": { + "from": { + "$sortByOrder": { + "$ref": "request.images" + } + }, + "as": "media", + "in": { + "$if": { + "condition": { + "$ne": [ + { + "$ref": "media.role" + }, + "" + ] + }, + "then": { + "url": { + "$ref": "media.value" + }, + "role": { + "$ref": "media.role" + } + }, + "else": { + "$ref": "media.value" + } + } + } + } + } + }, + "videos": { + "$omitEmpty": { + "$map": { + "from": { + "$sortByOrder": { + "$ref": "request.videos" + } + }, + "as": "media", + "in": { + "$ref": "media.value" + } + } + } + }, + "audios": { + "$omitEmpty": { + "$map": { + "from": { + "$sortByOrder": { + "$ref": "request.audios" + } + }, + "as": "media", + "in": { + "$ref": "media.value" + } + } + } + }, + "files": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.files" + } + }, + "links": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.links" + } + }, + "resolution": { + "$omitEmpty": { + "$ref": "request.resolution" + } + }, + "ratio": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.providerOptions.km-kemei-video.ratio" + }, + { + "$ref": "request.aspectRatio" + } + ] + } + }, + "duration": { + "$omitEmpty": { + "$ref": "request.duration" + } + }, + "generate_audio": { + "$ref": "request.generateAudio" + }, + "watermark": { + "$ref": "request.watermark" + }, + "web_search": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.web_search" + } + }, + "seed": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.seed" + } + } + } + }, + "poll": { + "method": "GET", + "path": "/v1/video/generations/{{taskId}}", + "contentType": "application/json" + }, + "cancel": { + "method": "DELETE", + "path": "/v1/video/generations/{{taskId}}", + "contentType": "application/json" + }, + "response": { + "taskId": { + "$coalesce": [ + { + "$ref": "response.task_id" + }, + { + "$ref": "response.taskId" + }, + { + "$ref": "response.id" + }, + { + "$ref": "response.data.task_id" + }, + { + "$ref": "response.data.taskId" + }, + { + "$ref": "response.data.id" + }, + { + "$ref": "taskId" + } + ] + }, + "status": { + "$coalesce": [ + { + "$ref": "response.status" + }, + { + "$ref": "response.state" + }, + { + "$ref": "response.data.status" + }, + "pending" + ] + }, + "message": { + "$coalesce": [ + { + "$ref": "response.error.message" + }, + { + "$ref": "response.data.error.message" + }, + { + "$ref": "response.message" + }, + { + "$ref": "response.detail" + } + ] + }, + "videos": { + "$coalesce": [ + { + "$map": { + "from": { + "$ref": "response.data" + }, + "as": "item", + "in": { + "$coalesce": [ + { + "$ref": "item.url" + }, + { + "$ref": "item.video_url" + }, + { + "$ref": "item.videoUrl" + }, + { + "$ref": "item.output_url" + }, + { + "$ref": "item.outputUrl" + } + ] + } + } + }, + { + "$ref": "response.data.url" + }, + { + "$ref": "response.data.video_url" + }, + { + "$ref": "response.data.videoUrl" + }, + { + "$ref": "response.data.output_url" + }, + { + "$ref": "response.data.result_url" + }, + { + "$ref": "response.url" + }, + { + "$ref": "response.video_url" + }, + { + "$ref": "response.videoUrl" + }, + { + "$ref": "response.output_url" + }, + { + "$ref": "response.result_url" + } + ] + }, + "errorPaths": [ + "error.code", + "data.error.code" + ], + "resultEphemeral": true + } + } + ] + }, + "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>" +} +``` + diff --git a/plugin-packages/km-kemei-video/manifest.json b/plugin-packages/km-kemei-video/manifest.json new file mode 100644 index 000000000..6ae918a59 --- /dev/null +++ b/plugin-packages/km-kemei-video/manifest.json @@ -0,0 +1,432 @@ +{ + "apiVersion": "yingce.plugin/v2", + "id": "km-kemei-video", + "name": "KM 可美视频", + "version": "1.0.0", + "author": "KM 可美 / 影策", + "description": "KM 可美视频 OpenAI 兼容异步视频生成协议插件。", + "permissions": [ + "generation.run", + "media.read" + ], + "configuration": { + "fields": [ + { + "name": "apiKey", + "type": "secret", + "label": "API Key", + "required": true + } + ] + }, + "contributes": { + "providers": [ + { + "id": "km-kemei-video", + "label": "KM 可美视频", + "capabilities": [ + "video" + ], + "scopes": [ + "admin.system-channel", + "user.custom-channel", + "canvas", + "creation", + "agent" + ], + "baseUrl": "https://token.xinhankr.com", + "requiresPublicMediaUrls": false, + "auth": { + "type": "bearer", + "field": "apiKey" + }, + "parameters": [ + { + "name": "model", + "type": "string", + "required": true, + "mapping": "model", + "description": "视频生成模型名称。" + }, + { + "name": "prompt", + "type": "string", + "required": true, + "mapping": "prompt", + "description": "描述视频 motion 与画面的提示词。" + }, + { + "name": "negative_prompt", + "type": "string", + "required": false, + "mapping": "providerOptions.km-kemei-video.negative_prompt", + "description": "负向提示词。" + }, + { + "name": "images", + "type": "media[]", + "required": false, + "mapping": "images", + "description": "首帧、尾帧或参考图片,支持 URL 与 Base64。" + }, + { + "name": "image_urls", + "type": "media[]", + "required": false, + "mapping": "images", + "description": "images 的别名。" + }, + { + "name": "videos", + "type": "media[]", + "required": false, + "mapping": "videos", + "description": "参考视频链接数组。" + }, + { + "name": "audios", + "type": "media[]", + "required": false, + "mapping": "audios", + "description": "参考音频链接数组。" + }, + { + "name": "files", + "type": "string[]", + "required": false, + "mapping": "providerOptions.km-kemei-video.files", + "description": "参考文件 URL 数组。" + }, + { + "name": "links", + "type": "string[]", + "required": false, + "mapping": "providerOptions.km-kemei-video.links", + "description": "参考网页 URL 数组。" + }, + { + "name": "resolution", + "type": "string", + "required": false, + "mapping": "resolution", + "description": "目标分辨率,如 1080p、720p、480p。" + }, + { + "name": "ratio", + "type": "string", + "required": false, + "mapping": "providerOptions.km-kemei-video.ratio/aspectRatio", + "description": "宽高比,如 16:9、9:16、adaptive。" + }, + { + "name": "duration", + "type": "integer", + "required": false, + "mapping": "duration", + "description": "视频时长,单位为秒。" + }, + { + "name": "generate_audio", + "type": "boolean", + "required": false, + "mapping": "generateAudio", + "description": "是否同步生成匹配音频。" + }, + { + "name": "watermark", + "type": "boolean", + "required": false, + "mapping": "watermark", + "description": "是否添加水印。" + }, + { + "name": "web_search", + "type": "boolean", + "required": false, + "mapping": "providerOptions.km-kemei-video.web_search", + "description": "是否启用联网搜索。" + }, + { + "name": "seed", + "type": "integer", + "required": false, + "mapping": "providerOptions.km-kemei-video.seed", + "description": "随机数种子。" + }, + { + "name": "providerOptions", + "type": "object", + "required": false, + "mapping": "provider-specific fields", + "description": "KM 可美协议扩展字段。" + } + ], + "create": { + "method": "POST", + "path": "/v1/video/generations", + "contentType": "application/json", + "body": { + "model": { + "$ref": "request.model" + }, + "prompt": { + "$ref": "request.prompt" + }, + "negative_prompt": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.negative_prompt" + } + }, + "images": { + "$omitEmpty": { + "$map": { + "from": { + "$sortByOrder": { + "$ref": "request.images" + } + }, + "as": "media", + "in": { + "$if": { + "condition": { + "$ne": [ + { + "$ref": "media.role" + }, + "" + ] + }, + "then": { + "url": { + "$ref": "media.value" + }, + "role": { + "$ref": "media.role" + } + }, + "else": { + "$ref": "media.value" + } + } + } + } + } + }, + "videos": { + "$omitEmpty": { + "$map": { + "from": { + "$sortByOrder": { + "$ref": "request.videos" + } + }, + "as": "media", + "in": { + "$ref": "media.value" + } + } + } + }, + "audios": { + "$omitEmpty": { + "$map": { + "from": { + "$sortByOrder": { + "$ref": "request.audios" + } + }, + "as": "media", + "in": { + "$ref": "media.value" + } + } + } + }, + "files": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.files" + } + }, + "links": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.links" + } + }, + "resolution": { + "$omitEmpty": { + "$ref": "request.resolution" + } + }, + "ratio": { + "$omitEmpty": { + "$coalesce": [ + { + "$ref": "request.providerOptions.km-kemei-video.ratio" + }, + { + "$ref": "request.aspectRatio" + } + ] + } + }, + "duration": { + "$omitEmpty": { + "$ref": "request.duration" + } + }, + "generate_audio": { + "$ref": "request.generateAudio" + }, + "watermark": { + "$ref": "request.watermark" + }, + "web_search": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.web_search" + } + }, + "seed": { + "$omitEmpty": { + "$ref": "request.providerOptions.km-kemei-video.seed" + } + } + } + }, + "poll": { + "method": "GET", + "path": "/v1/video/generations/{{taskId}}", + "contentType": "application/json" + }, + "cancel": { + "method": "DELETE", + "path": "/v1/video/generations/{{taskId}}", + "contentType": "application/json" + }, + "response": { + "taskId": { + "$coalesce": [ + { + "$ref": "response.task_id" + }, + { + "$ref": "response.taskId" + }, + { + "$ref": "response.id" + }, + { + "$ref": "response.data.task_id" + }, + { + "$ref": "response.data.taskId" + }, + { + "$ref": "response.data.id" + }, + { + "$ref": "taskId" + } + ] + }, + "status": { + "$coalesce": [ + { + "$ref": "response.status" + }, + { + "$ref": "response.state" + }, + { + "$ref": "response.data.status" + }, + "pending" + ] + }, + "message": { + "$coalesce": [ + { + "$ref": "response.error.message" + }, + { + "$ref": "response.data.error.message" + }, + { + "$ref": "response.message" + }, + { + "$ref": "response.detail" + } + ] + }, + "videos": { + "$coalesce": [ + { + "$map": { + "from": { + "$ref": "response.data" + }, + "as": "item", + "in": { + "$coalesce": [ + { + "$ref": "item.url" + }, + { + "$ref": "item.video_url" + }, + { + "$ref": "item.videoUrl" + }, + { + "$ref": "item.output_url" + }, + { + "$ref": "item.outputUrl" + } + ] + } + } + }, + { + "$ref": "response.data.url" + }, + { + "$ref": "response.data.video_url" + }, + { + "$ref": "response.data.videoUrl" + }, + { + "$ref": "response.data.output_url" + }, + { + "$ref": "response.data.result_url" + }, + { + "$ref": "response.url" + }, + { + "$ref": "response.video_url" + }, + { + "$ref": "response.videoUrl" + }, + { + "$ref": "response.output_url" + }, + { + "$ref": "response.result_url" + } + ] + }, + "errorPaths": [ + "error.code", + "data.error.code" + ], + "resultEphemeral": true + } + } + ] + }, + "documentation": "# KM 可美视频\n\n该目录是 KM 可美视频 OpenAI 兼容异步视频协议插件源码。后端从生成的 `km-kemei-video.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。\n\n完整接口见 [docs/interface.md](docs/interface.md)。\n\n---\n\n# KM 可美视频接口字段\n\n## 协议身份\n\n- 插件 ID:`km-kemei-video`。\n- Provider ID:`km-kemei-video`。\n- 能力:`video`。\n- 默认 Base URL:`https://token.xinhankr.com`。\n- 鉴权:`Authorization: Bearer `。\n- 创建:`POST /v1/video/generations`。\n- 查询:`GET /v1/video/generations/{task_id}`,也兼容上游文档中的 `/v1/tasks/{task_id}`。\n- 取消:`DELETE /v1/video/generations/{task_id}`。\n\n## 提交任务\n\n请求体使用 JSON。`model` 与 `prompt` 为必填字段;其它统一字段按上游 snake_case 名称发送。`images` 支持 URL、Base64 字符串和带 `role` 的对象,图片按输入顺序保留;单图、双图和三图以上的首帧/尾帧/多图参考推断由 KM 网关执行。\n\n| 字段 | 类型 | 必填 | 说明 |\n| --- | --- | --- | --- |\n| `model` | string | 是 | 如 `doubao-seedance-2-5`、`kling-v3-omni`、`wan3.0-video`。 |\n| `prompt` | string | 是 | 描述 motion 与画面的提示词。 |\n| `negative_prompt` | string | 否 | 负向提示词,通过 `providerOptions.km-kemei-video.negative_prompt` 透传。 |\n| `images` / `image_urls` | array | 否 | 图片 URL、Base64 或带 `role` 的图片对象。插件使用 `images` 字段。 |\n| `videos` | array | 否 | 参考视频 URL。 |\n| `audios` | array | 否 | 参考音频 URL。 |\n| `files` | array | 否 | 参考文件 URL,通过 provider options 透传。 |\n| `links` | array | 否 | 参考网页 URL,通过 provider options 透传。 |\n| `resolution` | string | 否 | `1080p`、`720p`、`480p` 等。 |\n| `ratio` | string | 否 | `16:9`、`9:16`、`adaptive`;未设置时回退统一 `aspectRatio`。 |\n| `duration` | integer | 否 | 时长秒数。 |\n| `generate_audio` | boolean | 否 | 是否生成背景音效或配音。 |\n| `watermark` | boolean | 否 | 是否添加水印。 |\n| `web_search` | boolean | 否 | 是否启用联网搜索。 |\n| `seed` | integer | 否 | 随机数种子。 |\n\n扩展字段命名空间为 `providerOptions.km-kemei-video`,支持 `negative_prompt`、`ratio`、`files`、`links`、`web_search`、`seed` 及其它网关扩展字段。该命名空间可通过自定义渠道元数据传入。\n\n## 图片角色\n\n显式图片角色支持 `first_frame` / `first`、`last_frame` / `end_frame` / `last` / `tail`、`reference_image`。插件会把统一媒体的 `role` 字段编码为对象形式;没有角色时保留为纯 URL 或 Base64 字符串,让网关执行数量推断。\n\n## 异步生命周期\n\n创建响应的 `id`、`task_id` 或对应 `data` 字段映射为统一任务 ID。任务状态从 `pending`、`processing`、`completed`、`succeeded`、`failed`、`cancelled` 等值归一化。成功结果从 `data[].url` 以及常见的 `video_url`、`output_url`、`result_url` 和顶层等价字段提取;临时 URL 标记为 ephemeral,由宿主下载并持久化。\n\n取消只对未完成任务生效。KM 网关返回的 HTTP 错误、`error.code` 和错误消息保持失败语义,不包装成成功。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v2\",\n \"id\": \"km-kemei-video\",\n \"name\": \"KM 可美视频\",\n \"version\": \"1.0.0\",\n \"author\": \"KM 可美 / 影策\",\n \"description\": \"KM 可美视频 OpenAI 兼容异步视频生成协议插件。\",\n \"permissions\": [\n \"generation.run\",\n \"media.read\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"apiKey\",\n \"type\": \"secret\",\n \"label\": \"API Key\",\n \"required\": true\n }\n ]\n },\n \"contributes\": {\n \"providers\": [\n {\n \"id\": \"km-kemei-video\",\n \"label\": \"KM 可美视频\",\n \"capabilities\": [\n \"video\"\n ],\n \"scopes\": [\n \"admin.system-channel\",\n \"user.custom-channel\",\n \"canvas\",\n \"creation\",\n \"agent\"\n ],\n \"baseUrl\": \"https://token.xinhankr.com\",\n \"requiresPublicMediaUrls\": false,\n \"auth\": {\n \"type\": \"bearer\",\n \"field\": \"apiKey\"\n },\n \"parameters\": [\n {\n \"name\": \"model\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"model\",\n \"description\": \"视频生成模型名称。\"\n },\n {\n \"name\": \"prompt\",\n \"type\": \"string\",\n \"required\": true,\n \"mapping\": \"prompt\",\n \"description\": \"描述视频 motion 与画面的提示词。\"\n },\n {\n \"name\": \"negative_prompt\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"providerOptions.km-kemei-video.negative_prompt\",\n \"description\": \"负向提示词。\"\n },\n {\n \"name\": \"images\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"images\",\n \"description\": \"首帧、尾帧或参考图片,支持 URL 与 Base64。\"\n },\n {\n \"name\": \"image_urls\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"images\",\n \"description\": \"images 的别名。\"\n },\n {\n \"name\": \"videos\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"videos\",\n \"description\": \"参考视频链接数组。\"\n },\n {\n \"name\": \"audios\",\n \"type\": \"media[]\",\n \"required\": false,\n \"mapping\": \"audios\",\n \"description\": \"参考音频链接数组。\"\n },\n {\n \"name\": \"files\",\n \"type\": \"string[]\",\n \"required\": false,\n \"mapping\": \"providerOptions.km-kemei-video.files\",\n \"description\": \"参考文件 URL 数组。\"\n },\n {\n \"name\": \"links\",\n \"type\": \"string[]\",\n \"required\": false,\n \"mapping\": \"providerOptions.km-kemei-video.links\",\n \"description\": \"参考网页 URL 数组。\"\n },\n {\n \"name\": \"resolution\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"resolution\",\n \"description\": \"目标分辨率,如 1080p、720p、480p。\"\n },\n {\n \"name\": \"ratio\",\n \"type\": \"string\",\n \"required\": false,\n \"mapping\": \"providerOptions.km-kemei-video.ratio/aspectRatio\",\n \"description\": \"宽高比,如 16:9、9:16、adaptive。\"\n },\n {\n \"name\": \"duration\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"duration\",\n \"description\": \"视频时长,单位为秒。\"\n },\n {\n \"name\": \"generate_audio\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"generateAudio\",\n \"description\": \"是否同步生成匹配音频。\"\n },\n {\n \"name\": \"watermark\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"watermark\",\n \"description\": \"是否添加水印。\"\n },\n {\n \"name\": \"web_search\",\n \"type\": \"boolean\",\n \"required\": false,\n \"mapping\": \"providerOptions.km-kemei-video.web_search\",\n \"description\": \"是否启用联网搜索。\"\n },\n {\n \"name\": \"seed\",\n \"type\": \"integer\",\n \"required\": false,\n \"mapping\": \"providerOptions.km-kemei-video.seed\",\n \"description\": \"随机数种子。\"\n },\n {\n \"name\": \"providerOptions\",\n \"type\": \"object\",\n \"required\": false,\n \"mapping\": \"provider-specific fields\",\n \"description\": \"KM 可美协议扩展字段。\"\n }\n ],\n \"create\": {\n \"method\": \"POST\",\n \"path\": \"/v1/video/generations\",\n \"contentType\": \"application/json\",\n \"body\": {\n \"model\": {\n \"$ref\": \"request.model\"\n },\n \"prompt\": {\n \"$ref\": \"request.prompt\"\n },\n \"negative_prompt\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.km-kemei-video.negative_prompt\"\n }\n },\n \"images\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.images\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$if\": {\n \"condition\": {\n \"$ne\": [\n {\n \"$ref\": \"media.role\"\n },\n \"\"\n ]\n },\n \"then\": {\n \"url\": {\n \"$ref\": \"media.value\"\n },\n \"role\": {\n \"$ref\": \"media.role\"\n }\n },\n \"else\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n }\n }\n },\n \"videos\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.videos\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"audios\": {\n \"$omitEmpty\": {\n \"$map\": {\n \"from\": {\n \"$sortByOrder\": {\n \"$ref\": \"request.audios\"\n }\n },\n \"as\": \"media\",\n \"in\": {\n \"$ref\": \"media.value\"\n }\n }\n }\n },\n \"files\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.km-kemei-video.files\"\n }\n },\n \"links\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.km-kemei-video.links\"\n }\n },\n \"resolution\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.resolution\"\n }\n },\n \"ratio\": {\n \"$omitEmpty\": {\n \"$coalesce\": [\n {\n \"$ref\": \"request.providerOptions.km-kemei-video.ratio\"\n },\n {\n \"$ref\": \"request.aspectRatio\"\n }\n ]\n }\n },\n \"duration\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.duration\"\n }\n },\n \"generate_audio\": {\n \"$ref\": \"request.generateAudio\"\n },\n \"watermark\": {\n \"$ref\": \"request.watermark\"\n },\n \"web_search\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.km-kemei-video.web_search\"\n }\n },\n \"seed\": {\n \"$omitEmpty\": {\n \"$ref\": \"request.providerOptions.km-kemei-video.seed\"\n }\n }\n }\n },\n \"poll\": {\n \"method\": \"GET\",\n \"path\": \"/v1/video/generations/{{taskId}}\",\n \"contentType\": \"application/json\"\n },\n \"cancel\": {\n \"method\": \"DELETE\",\n \"path\": \"/v1/video/generations/{{taskId}}\",\n \"contentType\": \"application/json\"\n },\n \"response\": {\n \"taskId\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.task_id\"\n },\n {\n \"$ref\": \"response.taskId\"\n },\n {\n \"$ref\": \"response.id\"\n },\n {\n \"$ref\": \"response.data.task_id\"\n },\n {\n \"$ref\": \"response.data.taskId\"\n },\n {\n \"$ref\": \"response.data.id\"\n },\n {\n \"$ref\": \"taskId\"\n }\n ]\n },\n \"status\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.status\"\n },\n {\n \"$ref\": \"response.state\"\n },\n {\n \"$ref\": \"response.data.status\"\n },\n \"pending\"\n ]\n },\n \"message\": {\n \"$coalesce\": [\n {\n \"$ref\": \"response.error.message\"\n },\n {\n \"$ref\": \"response.data.error.message\"\n },\n {\n \"$ref\": \"response.message\"\n },\n {\n \"$ref\": \"response.detail\"\n }\n ]\n },\n \"videos\": {\n \"$coalesce\": [\n {\n \"$map\": {\n \"from\": {\n \"$ref\": \"response.data\"\n },\n \"as\": \"item\",\n \"in\": {\n \"$coalesce\": [\n {\n \"$ref\": \"item.url\"\n },\n {\n \"$ref\": \"item.video_url\"\n },\n {\n \"$ref\": \"item.videoUrl\"\n },\n {\n \"$ref\": \"item.output_url\"\n },\n {\n \"$ref\": \"item.outputUrl\"\n }\n ]\n }\n }\n },\n {\n \"$ref\": \"response.data.url\"\n },\n {\n \"$ref\": \"response.data.video_url\"\n },\n {\n \"$ref\": \"response.data.videoUrl\"\n },\n {\n \"$ref\": \"response.data.output_url\"\n },\n {\n \"$ref\": \"response.data.result_url\"\n },\n {\n \"$ref\": \"response.url\"\n },\n {\n \"$ref\": \"response.video_url\"\n },\n {\n \"$ref\": \"response.videoUrl\"\n },\n {\n \"$ref\": \"response.output_url\"\n },\n {\n \"$ref\": \"response.result_url\"\n }\n ]\n },\n \"errorPaths\": [\n \"error.code\",\n \"data.error.code\"\n ],\n \"resultEphemeral\": true\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" +} diff --git a/web/src/components/layout/workspace-wallet-modal.tsx b/web/src/components/layout/workspace-wallet-modal.tsx index fb08806cd..de6e85d1f 100644 --- a/web/src/components/layout/workspace-wallet-modal.tsx +++ b/web/src/components/layout/workspace-wallet-modal.tsx @@ -12,7 +12,7 @@ import { cn } from "@/lib/utils"; import { openWorkspaceWallet, WORKSPACE_WALLET_OPEN_EVENT, type WorkspaceWalletOpenDetail } from "@/lib/workspace-wallet"; import { useUserStore } from "@/stores/use-user-store"; -type WalletModalTab = "topup" | "history"; +type WalletModalTab = "topup" | "redeem" | "history"; export function WorkspaceWalletHost() { const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); @@ -62,17 +62,7 @@ export function WorkspaceWalletHost() { ); } -export function WorkspaceWalletModal({ - open, - onClose, - pendingPaymentOrderId, - paymentInvalid, -}: { - open: boolean; - onClose: () => void; - pendingPaymentOrderId?: string; - paymentInvalid?: boolean; -}) { +export function WorkspaceWalletModal({ open, onClose, pendingPaymentOrderId, paymentInvalid }: { open: boolean; onClose: () => void; pendingPaymentOrderId?: string; paymentInvalid?: boolean }) { const { message } = App.useApp(); const [tab, setTab] = useState("topup"); const [wallet, setWallet] = useState(null); @@ -121,7 +111,7 @@ export function WorkspaceWalletModal({ .then(([productResult, providerResult]) => { setProducts(productResult.products.filter((item) => item.enabled)); setProviders(providerResult.providers.filter((item) => item.enabled && item.pluginEnabled && item.configured)); - setSelectedProductId((current) => current || productResult.products.find((item) => item.enabled)?.id || ""); + setSelectedProductId((current) => current || productResult.products.find((item) => item.canPurchase)?.id || productResult.products.find((item) => item.enabled)?.id || ""); setSelectedProviderId((current) => current || providerResult.providers.find((item) => item.enabled && item.pluginEnabled && item.configured)?.id || ""); }) .catch((error) => message.error(error instanceof Error ? error.message : "读取充值配置失败")) @@ -194,6 +184,10 @@ export function WorkspaceWalletModal({ message.error("请选择充值商品和支付方式"); return; } + if (!selectedProduct.canPurchase) { + message.error("该充值商品当前不可购买,请刷新后重试"); + return; + } setPaymentCreating(true); try { if (!idempotencyKey.current) idempotencyKey.current = crypto.randomUUID(); @@ -269,7 +263,10 @@ export function WorkspaceWalletModal({
- 积分中心 + + + 积分中心 +

充值、兑换与消费记录

为下一次创作补充积分,随时查看每一笔收支。

@@ -281,63 +278,215 @@ export function WorkspaceWalletModal({
- - + + +
{tab === "topup" ? (
-

在线充值

选择积分套餐和支付方式。

- {paymentsLoading ? : products.length && providers.length ? <> -
- {products.map((product) => )} +
+
+

在线充值

+

选择积分套餐和支付方式。

-
-
- {providers.map((provider) => )} + +
+ {paymentsLoading ? ( + + ) : products.length && providers.length ? ( + <> +
+ {products.map((product) => { + const unavailableLabel = + product.saleStatus === "upcoming" + ? `发售时间:${product.saleStartAt ? new Date(product.saleStartAt).toLocaleString("zh-CN", { hour12: false }) : "待定"}` + : product.saleStatus === "ended" + ? "已结束" + : product.saleStatus === "sold_out" + ? `已售罄(库存 ${product.stockRemaining ?? 0})` + : product.saleStrategy === "inventory" + ? `库存 ${product.stockRemaining ?? 0}` + : product.saleStrategy === "periodic" + ? `每 ${product.periodDays} 天限购 ${product.periodPurchaseLimit} 次` + : "不限量"; + return ( + + ); + })} +
+
+
+ {providers.map((provider) => ( + + ))} +
+ +
+ + ) : ( +
+ +
+ 在线充值暂不可用 + 当前没有已启用的充值商品或支付渠道,请使用兑换码或联系管理员。
-
- :
在线充值暂不可用当前没有已启用的充值商品或支付渠道,请使用兑换码或联系管理员。
} + )}
- +
+ ) : tab === "redeem" ? ( +
-

兑换码

输入兑换码,将积分存入当前账户。

+
+
+

兑换码

+

输入兑换码,将积分存入当前账户。

+
+ +
setCode(event.target.value.replace(/\s/g, ""))} onPressEnter={() => void redeem()} /> - +
) : (
-

积分消耗历史

包含充值、兑换、生成消费、冻结与退款。

+
+
+

积分消耗历史

+

包含充值、兑换、生成消费、冻结与退款。

+
+ +
- {walletError ?
记录加载失败{walletError}
: walletLoading && !wallet ? : wallet?.entries.length ?
- {wallet.entries.map((entry) => )} -
:
还没有积分记录完成充值、兑换或生成任务后,记录会显示在这里。
} + {walletError ? ( +
+ +
+ 记录加载失败 + {walletError} +
+ +
+ ) : walletLoading && !wallet ? ( + + ) : wallet?.entries.length ? ( +
+ {wallet.entries.map((entry) => ( + + ))} +
+ ) : ( +
+ + 还没有积分记录 + 完成充值、兑换或生成任务后,记录会显示在这里。 +
+ )}
- 第 {page} / {totalPages} 页 - - + + 第 {page} / {totalPages} 页 + + +
)}
- setPaymentOpen(false)} footer={paymentFooter(paymentOrder, paymentQuerying, () => setPaymentOpen(false), cancelPayment, refreshPaymentStatus, retryCheckout)}> - {paymentOrder ?
- - ¥ {(paymentOrder.amountFen / 100).toFixed(2)} -

{paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分

- {paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? <>请使用支付应用扫码完成支付 : null} - -
: null} + setPaymentOpen(false)} + footer={paymentFooter(paymentOrder, paymentQuerying, () => setPaymentOpen(false), cancelPayment, refreshPaymentStatus, retryCheckout)} + > + {paymentOrder ? ( +
+ + + + ¥ {(paymentOrder.amountFen / 100).toFixed(2)} +

+ {paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分 +

+ {paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? ( + <> + + 请使用支付应用扫码完成支付 + + ) : null} + +
+ ) : null}
); @@ -346,7 +495,20 @@ export function WorkspaceWalletModal({ function WalletLedgerRow({ entry }: { entry: CreditLedgerEntry }) { const positive = entry.amountMicrocredits > 0; const title = entry.type === "consume" ? "模型调用" : entry.type === "refund" ? "消费退款" : entry.type === "payment_topup" ? "在线充值" : entry.type === "redeem" ? "兑换码充值" : entry.note || "积分调整"; - return
{positive ? : }
{title}{[entry.scene, entry.model, entry.note].filter(Boolean).join(" · ") || "积分账户变动"}
{positive ? "+" : ""}{formatCredits(entry.amountMicrocredits, 6)}
; + return ( +
+ {positive ? : } +
+ {title} + {[entry.scene, entry.model, entry.note].filter(Boolean).join(" · ") || "积分账户变动"} +
+ + + {positive ? "+" : ""} + {formatCredits(entry.amountMicrocredits, 6)} + +
+ ); } function PaymentStatus({ order, now }: { order: PaymentOrder; now: number }) { @@ -357,11 +519,35 @@ function PaymentStatus({ order, now }: { order: PaymentOrder; now: number }) { const hours = Math.floor(remaining / 3600); const minutes = Math.floor((remaining % 3600) / 60); const seconds = remaining % 60; - return
订单剩余 {String(hours).padStart(2, "0")}:{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")},将自动确认支付结果
; + return ( +
+ 订单剩余 {String(hours).padStart(2, "0")}:{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")},将自动确认支付结果 +
+ ); } function paymentFooter(order: PaymentOrder | null, loading: boolean, close: () => void, cancel: () => Promise, query: (id?: string, silent?: boolean) => Promise, retry: () => Promise) { - if (order?.status === "pending") return [, ]; - if (order?.status === "create_failed") return [, ]; - return []; + if (order?.status === "pending") + return [ + , + , + ]; + if (order?.status === "create_failed") + return [ + , + , + ]; + return [ + , + ]; } diff --git a/web/src/pages/admin/payments/payments-page.tsx b/web/src/pages/admin/payments/payments-page.tsx index b32a9acc1..184e36295 100644 --- a/web/src/pages/admin/payments/payments-page.tsx +++ b/web/src/pages/admin/payments/payments-page.tsx @@ -54,6 +54,12 @@ type ProductFormValues = { credits: number; enabled: boolean; sortOrder: number; + saleStrategy: TopupProduct["saleStrategy"]; + periodDays?: number; + periodPurchaseLimit?: number; + stockTotal?: number; + saleStartAt?: Dayjs; + saleEndAt?: Dayjs; }; const paymentOrderStatus: Record = { @@ -89,6 +95,7 @@ export default function AdminPaymentsPage() { const [productDrawer, setProductDrawer] = useState(); const [productSaving, setProductSaving] = useState(false); const [productForm] = Form.useForm(); + const selectedProductStrategy = Form.useWatch("saleStrategy", productForm) || "unlimited"; const [orders, setOrders] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); @@ -262,8 +269,14 @@ export default function AdminPaymentsPage() { credits: product.creditsMicrocredits / 1_000_000, enabled: product.enabled, sortOrder: product.sortOrder, + saleStrategy: product.saleStrategy || "unlimited", + periodDays: product.periodDays, + periodPurchaseLimit: product.periodPurchaseLimit, + stockTotal: product.stockTotal, + saleStartAt: product.saleStartAt ? dayjs(product.saleStartAt) : undefined, + saleEndAt: product.saleEndAt ? dayjs(product.saleEndAt) : undefined, } - : { enabled: true, sortOrder: products.length * 10, amountYuan: 10, credits: 10 }, + : { enabled: true, sortOrder: products.length * 10, amountYuan: 10, credits: 10, saleStrategy: "unlimited" }, ); setProductDrawer(product || null); }; @@ -278,6 +291,12 @@ export default function AdminPaymentsPage() { creditsMicrocredits: Math.round(values.credits * 1_000_000), enabled: values.enabled, sortOrder: values.sortOrder || 0, + saleStrategy: values.saleStrategy || "unlimited", + periodDays: values.periodDays || 0, + periodPurchaseLimit: values.periodPurchaseLimit || 0, + stockTotal: values.stockTotal || 0, + saleStartAt: values.saleStartAt?.toISOString(), + saleEndAt: values.saleEndAt?.toISOString(), }; setProductSaving(true); try { @@ -431,7 +450,34 @@ export default function AdminPaymentsPage() { { title: "售价", dataIndex: "amountFen", width: 130, align: "right", render: (value) => ¥ {(value / 100).toFixed(2)} }, { title: "到账积分", dataIndex: "creditsMicrocredits", width: 150, align: "right", render: (value) => {formatCredits(value)} }, { title: "排序", dataIndex: "sortOrder", width: 90, align: "center" }, - { title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (value) => }, + { + title: "上架策略", + key: "strategy", + width: 180, + render: (_, product) => { + const strategy = product.saleStrategy || "unlimited"; + const label = + strategy === "periodic" + ? `周期 ${product.periodDays} 天 / ${product.periodPurchaseLimit} 次` + : strategy === "inventory" + ? `库存 ${product.stockRemaining ?? 0} / ${product.stockTotal ?? 0}` + : strategy === "timed" + ? `${product.saleStatus === "upcoming" ? "待发售" : product.saleStatus === "ended" ? "已结束" : "限时"}` + : "不限量"; + return {label}; + }, + }, + { + title: "状态", + dataIndex: "enabled", + width: 120, + align: "center", + render: (value, product) => { + const status = product.saleStatus; + const label = !value ? "已停用" : status === "upcoming" ? "待发售" : status === "ended" ? "已结束" : status === "sold_out" ? "已售罄" : "销售中"; + return ; + }, + }, { title: "操作", key: "actions", @@ -452,16 +498,28 @@ export default function AdminPaymentsPage() { width: 230, render: (_, order) => (
- {order.user ? <> -
- - @{order.user.username} -
-
{order.user.email || "未填写邮箱"}
- : <> -
用户不存在
- {order.userId ? `${order.userId.slice(0, 8)}…${order.userId.slice(-6)}` : "--"} - } + {order.user ? ( + <> +
+ + + @{order.user.username} + +
+
+ {order.user.email || "未填写邮箱"} +
+ + ) : ( + <> +
用户不存在
+ + {order.userId ? `${order.userId.slice(0, 8)}…${order.userId.slice(-6)}` : "--"} + + + )}
), }, @@ -471,7 +529,9 @@ export default function AdminPaymentsPage() { width: 210, render: (_, order) => (
- {order.merchantOrderNo.length > 20 ? `${order.merchantOrderNo.slice(0, 10)}…${order.merchantOrderNo.slice(-6)}` : order.merchantOrderNo} + + {order.merchantOrderNo.length > 20 ? `${order.merchantOrderNo.slice(0, 10)}…${order.merchantOrderNo.slice(-6)}` : order.merchantOrderNo} +
{order.productName}
@@ -515,7 +575,14 @@ export default function AdminPaymentsPage() { visibleActionCount={0} actions={[ { key: "sync", label: "同步支付状态", icon: , disabled: Boolean(orderActionId) || ["credited", "closed"].includes(order.status), onClick: () => queryOrder(order) }, - { key: "close", label: "关闭订单", icon: , danger: true, disabled: Boolean(orderActionId) || !["created", "pending", "create_failed", "closing"].includes(order.status), onClick: () => closeOrder(order) }, + { + key: "close", + label: "关闭订单", + icon: , + danger: true, + disabled: Boolean(orderActionId) || !["created", "pending", "create_failed", "closing"].includes(order.status), + onClick: () => closeOrder(order), + }, ]} /> ), @@ -748,18 +815,76 @@ export default function AdminPaymentsPage() { /> setSelectedOrder(null)}> - {selectedOrder && { setSelectedUserId(selectedOrder.user!.id); setSelectedOrder(null); }}>{selectedOrder.user.displayName || selectedOrder.user.username} · @{selectedOrder.user.username} : "用户不存在" }, - { key: "email", label: "邮箱", children: selectedOrder.user?.email || "未填写邮箱" }, - { key: "userId", label: "用户 ID", children: {selectedOrder.userId || "--"} }, - { key: "order", label: "订单号", children: {selectedOrder.merchantOrderNo} }, - { key: "trade", label: "渠道交易号", children: selectedOrder.providerTradeNo ? {selectedOrder.providerTradeNo} : "--" }, - { key: "product", label: "商品", children: selectedOrder.productName }, - { key: "channel", label: "支付渠道", children: providerNames[selectedOrder.providerId] || selectedOrder.providerId }, - { key: "amount", label: "金额 / 积分", children: `¥ ${(selectedOrder.amountFen / 100).toFixed(2)} / ${formatCredits(selectedOrder.creditsMicrocredits)} 积分` }, - { key: "status", label: "状态", children: paymentOrderStatus[selectedOrder.status]?.label || selectedOrder.status }, - ...([{ key: "createdAt", label: "创建时间" }, { key: "expiresAt", label: "过期时间" }, { key: "providerPaidAt", label: "支付时间" }, { key: "creditedAt", label: "入账时间" }, { key: "closedAt", label: "关闭时间" }] as const).map(({ key, label }) => ({ key, label, children: selectedOrder[key] ? formatDateTime(selectedOrder[key]!) : "--" })), - ]} />} + {selectedOrder && ( + { + setSelectedUserId(selectedOrder.user!.id); + setSelectedOrder(null); + }} + > + {selectedOrder.user.displayName || selectedOrder.user.username} · @{selectedOrder.user.username} + + ) : ( + "用户不存在" + ), + }, + { key: "email", label: "邮箱", children: selectedOrder.user?.email || "未填写邮箱" }, + { + key: "userId", + label: "用户 ID", + children: ( + + {selectedOrder.userId || "--"} + + ), + }, + { + key: "order", + label: "订单号", + children: ( + + {selectedOrder.merchantOrderNo} + + ), + }, + { + key: "trade", + label: "渠道交易号", + children: selectedOrder.providerTradeNo ? ( + + {selectedOrder.providerTradeNo} + + ) : ( + "--" + ), + }, + { key: "product", label: "商品", children: selectedOrder.productName }, + { key: "channel", label: "支付渠道", children: providerNames[selectedOrder.providerId] || selectedOrder.providerId }, + { key: "amount", label: "金额 / 积分", children: `¥ ${(selectedOrder.amountFen / 100).toFixed(2)} / ${formatCredits(selectedOrder.creditsMicrocredits)} 积分` }, + { key: "status", label: "状态", children: paymentOrderStatus[selectedOrder.status]?.label || selectedOrder.status }, + ...( + [ + { key: "createdAt", label: "创建时间" }, + { key: "expiresAt", label: "过期时间" }, + { key: "providerPaidAt", label: "支付时间" }, + { key: "creditedAt", label: "入账时间" }, + { key: "closedAt", label: "关闭时间" }, + ] as const + ).map(({ key, label }) => ({ key, label, children: selectedOrder[key] ? formatDateTime(selectedOrder[key]!) : "--" })), + ]} + /> + )} setSelectedUserId(null)} /> @@ -850,6 +975,41 @@ export default function AdminPaymentsPage() {
+ + + + + - - @@ -111,15 +121,7 @@ type CreateUserFormValues = { status: LocalUser["status"]; }; -export function AdminUserCreateDrawer({ - open, - onClose, - onCreated, -}: { - open: boolean; - onClose: () => void; - onCreated: (user: AdminUser) => void; -}) { +export function AdminUserCreateDrawer({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (user: AdminUser) => void }) { const { message, modal } = App.useApp(); const [saving, setSaving] = useState(false); const [form] = Form.useForm(); @@ -177,7 +179,11 @@ export function AdminUserCreateDrawer({ onClose={close} mask={{ closable: !saving }} destroyOnHidden - extra={} + extra={ + + } >
@@ -193,10 +199,20 @@ export function AdminUserCreateDrawer({ - -
diff --git a/web/src/services/api/payments.ts b/web/src/services/api/payments.ts index 1ba2a84b5..4f2ec1a23 100644 --- a/web/src/services/api/payments.ts +++ b/web/src/services/api/payments.ts @@ -12,6 +12,8 @@ export type PaymentProvider = { closeAfterMinutes: number; }; +export type TopupSaleStrategy = "unlimited" | "periodic" | "inventory" | "timed"; + export type TopupProduct = { id: string; name: string; @@ -20,6 +22,15 @@ export type TopupProduct = { creditsMicrocredits: number; enabled: boolean; sortOrder: number; + saleStrategy: TopupSaleStrategy; + periodDays?: number; + periodPurchaseLimit?: number; + stockTotal?: number; + stockRemaining?: number; + saleStartAt?: string; + saleEndAt?: string; + saleStatus?: "disabled" | "on_sale" | "upcoming" | "ended" | "sold_out" | string; + canPurchase: boolean; createdBy: string; updatedBy: string; createdAt: string; @@ -115,7 +126,14 @@ export function listAdminTopupProducts() { return http.get<{ products: TopupProduct[] }>("/admin/payments/products"); } -export type TopupProductInput = Pick & { description?: string }; +export type TopupProductInput = Pick & { + description?: string; + periodDays?: number; + periodPurchaseLimit?: number; + stockTotal?: number; + saleStartAt?: string; + saleEndAt?: string; +}; export function createAdminTopupProduct(input: TopupProductInput) { return http.post<{ product: TopupProduct }>("/admin/payments/products", input);