fix(*): 支付与Agent运行 - 修复商品策略并保证审批恢复稳定
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
51
backend/internal/protocol/km_kemei_video_test.go
Normal file
51
backend/internal/protocol/km_kemei_video_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user