fix(*): 支付与Agent运行 - 修复商品策略并保证审批恢复稳定

This commit is contained in:
ddcat666
2026-09-29 11:07:11 +08:00
parent 37bb4d7242
commit 3dd84ff25b
17 changed files with 1836 additions and 226 deletions

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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"`

View File

@@ -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)
}
}

View File

@@ -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 {