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

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

No files matched your search

@@ -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")
}
}
+98 -59
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
}
+99 -16
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
}
+4 -2
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