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 {
|
||||
|
||||
BIN
plugin-packages/km-kemei-video.yingce-plugin
Normal file
BIN
plugin-packages/km-kemei-video.yingce-plugin
Normal file
Binary file not shown.
5
plugin-packages/km-kemei-video/README.md
Normal file
5
plugin-packages/km-kemei-video/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# KM 可美视频
|
||||
|
||||
该目录是 KM 可美视频 OpenAI 兼容异步视频协议插件源码。后端从生成的 `km-kemei-video.yingce-plugin` 包加载,不依赖系统内置 `host:` 适配器。
|
||||
|
||||
完整接口见 [docs/interface.md](docs/interface.md)。
|
||||
487
plugin-packages/km-kemei-video/docs/interface.md
Normal file
487
plugin-packages/km-kemei-video/docs/interface.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# KM 可美视频接口字段
|
||||
|
||||
## 协议身份
|
||||
|
||||
- 插件 ID:`km-kemei-video`。
|
||||
- Provider ID:`km-kemei-video`。
|
||||
- 能力:`video`。
|
||||
- 默认 Base URL:`https://token.xinhankr.com`。
|
||||
- 鉴权:`Authorization: Bearer <apiKey>`。
|
||||
- 创建:`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` 和错误消息保持失败语义,不包装成成功。
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## 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 递归,此处不重复展开正文。>"
|
||||
}
|
||||
```
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_END -->
|
||||
432
plugin-packages/km-kemei-video/manifest.json
Normal file
432
plugin-packages/km-kemei-video/manifest.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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<WalletModalTab>("topup");
|
||||
const [wallet, setWallet] = useState<WalletSummary | null>(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({
|
||||
<div className="workspace-wallet-shell">
|
||||
<header className="workspace-wallet-header">
|
||||
<div>
|
||||
<span className="workspace-wallet-kicker"><Coins />积分中心</span>
|
||||
<span className="workspace-wallet-kicker">
|
||||
<Coins />
|
||||
积分中心
|
||||
</span>
|
||||
<h2>充值、兑换与消费记录</h2>
|
||||
<p>为下一次创作补充积分,随时查看每一笔收支。</p>
|
||||
</div>
|
||||
@@ -281,63 +278,215 @@ export function WorkspaceWalletModal({
|
||||
</header>
|
||||
|
||||
<div className="workspace-wallet-tabs" role="tablist" aria-label="积分中心">
|
||||
<button type="button" role="tab" aria-selected={tab === "topup"} onClick={() => setTab("topup")}><WalletCards />充值 / 兑换</button>
|
||||
<button type="button" role="tab" aria-selected={tab === "history"} onClick={() => setTab("history")}><History />积分消耗历史</button>
|
||||
<button type="button" role="tab" aria-selected={tab === "topup"} onClick={() => setTab("topup")}>
|
||||
<WalletCards />
|
||||
充值
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={tab === "redeem"} onClick={() => setTab("redeem")}>
|
||||
<TicketCheck />
|
||||
兑换
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={tab === "history"} onClick={() => setTab("history")}>
|
||||
<History />
|
||||
积分消耗历史
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "topup" ? (
|
||||
<div className="workspace-wallet-content is-topup">
|
||||
<section className="workspace-wallet-section">
|
||||
<div className="workspace-wallet-section-heading"><div><h3>在线充值</h3><p>选择积分套餐和支付方式。</p></div><CreditCard /></div>
|
||||
{paymentsLoading ? <Skeleton active paragraph={{ rows: 4 }} /> : products.length && providers.length ? <>
|
||||
<div className="workspace-wallet-products">
|
||||
{products.map((product) => <button key={product.id} type="button" className={cn("workspace-wallet-product", selectedProductId === product.id && "is-selected")} aria-pressed={selectedProductId === product.id} onClick={() => setSelectedProductId(product.id)}>
|
||||
<span>{product.name}</span><strong>{formatCredits(product.creditsMicrocredits, 6)} 积分</strong><small>¥ {(product.amountFen / 100).toFixed(2)}{product.description ? ` · ${product.description}` : ""}</small>{selectedProductId === product.id ? <Check /> : null}
|
||||
</button>)}
|
||||
<div className="workspace-wallet-section-heading">
|
||||
<div>
|
||||
<h3>在线充值</h3>
|
||||
<p>选择积分套餐和支付方式。</p>
|
||||
</div>
|
||||
<div className="workspace-wallet-provider-row">
|
||||
<div className="workspace-wallet-providers" role="radiogroup" aria-label="支付方式">
|
||||
{providers.map((provider) => <button key={provider.id} type="button" role="radio" aria-checked={selectedProviderId === provider.id} className={selectedProviderId === provider.id ? "is-selected" : ""} onClick={() => setSelectedProviderId(provider.id)}><CreditCard />{provider.name}</button>)}
|
||||
<CreditCard />
|
||||
</div>
|
||||
{paymentsLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
) : products.length && providers.length ? (
|
||||
<>
|
||||
<div className="workspace-wallet-products">
|
||||
{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 (
|
||||
<button
|
||||
key={product.id}
|
||||
type="button"
|
||||
className={cn("workspace-wallet-product", selectedProductId === product.id && "is-selected")}
|
||||
aria-pressed={selectedProductId === product.id}
|
||||
disabled={!product.canPurchase}
|
||||
onClick={() => setSelectedProductId(product.id)}
|
||||
>
|
||||
<span>{product.name}</span>
|
||||
<strong>{formatCredits(product.creditsMicrocredits, 6)} 积分</strong>
|
||||
<small>
|
||||
¥ {(product.amountFen / 100).toFixed(2)} · {unavailableLabel}
|
||||
{product.description ? ` · ${product.description}` : ""}
|
||||
</small>
|
||||
{selectedProductId === product.id ? <Check /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="workspace-wallet-provider-row">
|
||||
<div className="workspace-wallet-providers" role="radiogroup" aria-label="支付方式">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selectedProviderId === provider.id}
|
||||
className={selectedProviderId === provider.id ? "is-selected" : ""}
|
||||
onClick={() => setSelectedProviderId(provider.id)}
|
||||
>
|
||||
<CreditCard />
|
||||
{provider.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button type="primary" size="large" loading={paymentCreating} disabled={!selectedProduct || !selectedProvider} onClick={() => void startPayment()}>
|
||||
立即充值
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="workspace-wallet-inline-state">
|
||||
<CircleAlert />
|
||||
<div>
|
||||
<strong>在线充值暂不可用</strong>
|
||||
<span>当前没有已启用的充值商品或支付渠道,请使用兑换码或联系管理员。</span>
|
||||
</div>
|
||||
<Button type="primary" size="large" loading={paymentCreating} disabled={!selectedProduct || !selectedProvider} onClick={() => void startPayment()}>立即充值</Button>
|
||||
</div>
|
||||
</> : <div className="workspace-wallet-inline-state"><CircleAlert /><div><strong>在线充值暂不可用</strong><span>当前没有已启用的充值商品或支付渠道,请使用兑换码或联系管理员。</span></div></div>}
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
) : tab === "redeem" ? (
|
||||
<div className="workspace-wallet-content is-redeem">
|
||||
<section className="workspace-wallet-section is-redeem">
|
||||
<div className="workspace-wallet-section-heading"><div><h3>兑换码</h3><p>输入兑换码,将积分存入当前账户。</p></div><TicketCheck /></div>
|
||||
<div className="workspace-wallet-section-heading">
|
||||
<div>
|
||||
<h3>兑换码</h3>
|
||||
<p>输入兑换码,将积分存入当前账户。</p>
|
||||
</div>
|
||||
<TicketCheck />
|
||||
</div>
|
||||
<div className="workspace-wallet-redeem-row">
|
||||
<Input size="large" value={code} maxLength={32} placeholder="输入 32 位兑换码" onChange={(event) => setCode(event.target.value.replace(/\s/g, ""))} onPressEnter={() => void redeem()} />
|
||||
<Button size="large" loading={redeeming} disabled={code.trim().length !== 32} onClick={() => void redeem()}>确认兑换</Button>
|
||||
<Button size="large" loading={redeeming} disabled={code.trim().length !== 32} onClick={() => void redeem()}>
|
||||
确认兑换
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-wallet-content is-history">
|
||||
<div className="workspace-wallet-history-toolbar"><div><h3>积分消耗历史</h3><p>包含充值、兑换、生成消费、冻结与退款。</p></div><Button type="text" icon={<RefreshCw />} loading={walletLoading} onClick={() => void reloadWallet(page)}>刷新</Button></div>
|
||||
<div className="workspace-wallet-history-toolbar">
|
||||
<div>
|
||||
<h3>积分消耗历史</h3>
|
||||
<p>包含充值、兑换、生成消费、冻结与退款。</p>
|
||||
</div>
|
||||
<Button type="text" icon={<RefreshCw />} loading={walletLoading} onClick={() => void reloadWallet(page)}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="workspace-wallet-history-scroll">
|
||||
{walletError ? <div className="workspace-wallet-inline-state is-error"><CircleAlert /><div><strong>记录加载失败</strong><span>{walletError}</span></div><Button onClick={() => void reloadWallet(page)}>重试</Button></div> : walletLoading && !wallet ? <Skeleton active paragraph={{ rows: 6 }} /> : wallet?.entries.length ? <div className="workspace-wallet-ledger">
|
||||
{wallet.entries.map((entry) => <WalletLedgerRow key={entry.id} entry={entry} />)}
|
||||
</div> : <div className="workspace-wallet-empty"><History /><strong>还没有积分记录</strong><span>完成充值、兑换或生成任务后,记录会显示在这里。</span></div>}
|
||||
{walletError ? (
|
||||
<div className="workspace-wallet-inline-state is-error">
|
||||
<CircleAlert />
|
||||
<div>
|
||||
<strong>记录加载失败</strong>
|
||||
<span>{walletError}</span>
|
||||
</div>
|
||||
<Button onClick={() => void reloadWallet(page)}>重试</Button>
|
||||
</div>
|
||||
) : walletLoading && !wallet ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : wallet?.entries.length ? (
|
||||
<div className="workspace-wallet-ledger">
|
||||
{wallet.entries.map((entry) => (
|
||||
<WalletLedgerRow key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-wallet-empty">
|
||||
<History />
|
||||
<strong>还没有积分记录</strong>
|
||||
<span>完成充值、兑换或生成任务后,记录会显示在这里。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="workspace-wallet-pagination">
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<button type="button" disabled={page <= 1 || walletLoading} aria-label="上一页" onClick={() => { const next = page - 1; setPage(next); void reloadWallet(next); }}><ChevronLeft /></button>
|
||||
<button type="button" disabled={page >= totalPages || walletLoading} aria-label="下一页" onClick={() => { const next = page + 1; setPage(next); void reloadWallet(next); }}><ChevronRight /></button>
|
||||
<span>
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1 || walletLoading}
|
||||
aria-label="上一页"
|
||||
onClick={() => {
|
||||
const next = page - 1;
|
||||
setPage(next);
|
||||
void reloadWallet(next);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages || walletLoading}
|
||||
aria-label="下一页"
|
||||
onClick={() => {
|
||||
const next = page + 1;
|
||||
setPage(next);
|
||||
void reloadWallet(next);
|
||||
}}
|
||||
>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
|
||||
<AppModal open={paymentOpen} title={paymentOrder?.status === "credited" ? "充值完成" : paymentOrder?.checkout.mode === "qr_code" ? "扫码支付" : "确认支付结果"} centered width={430} onCancel={() => setPaymentOpen(false)} footer={paymentFooter(paymentOrder, paymentQuerying, () => setPaymentOpen(false), cancelPayment, refreshPaymentStatus, retryCheckout)}>
|
||||
{paymentOrder ? <div className="workspace-wallet-payment">
|
||||
<span className="workspace-wallet-payment-icon"><CreditCard /></span>
|
||||
<strong>¥ {(paymentOrder.amountFen / 100).toFixed(2)}</strong>
|
||||
<p>{paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分</p>
|
||||
{paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? <><PaymentCheckoutCode value={paymentOrder.checkout.value} /><span>请使用支付应用扫码完成支付</span></> : null}
|
||||
<PaymentStatus order={paymentOrder} now={clock} />
|
||||
</div> : null}
|
||||
<AppModal
|
||||
open={paymentOpen}
|
||||
title={paymentOrder?.status === "credited" ? "充值完成" : paymentOrder?.checkout.mode === "qr_code" ? "扫码支付" : "确认支付结果"}
|
||||
centered
|
||||
width={430}
|
||||
onCancel={() => setPaymentOpen(false)}
|
||||
footer={paymentFooter(paymentOrder, paymentQuerying, () => setPaymentOpen(false), cancelPayment, refreshPaymentStatus, retryCheckout)}
|
||||
>
|
||||
{paymentOrder ? (
|
||||
<div className="workspace-wallet-payment">
|
||||
<span className="workspace-wallet-payment-icon">
|
||||
<CreditCard />
|
||||
</span>
|
||||
<strong>¥ {(paymentOrder.amountFen / 100).toFixed(2)}</strong>
|
||||
<p>
|
||||
{paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分
|
||||
</p>
|
||||
{paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? (
|
||||
<>
|
||||
<PaymentCheckoutCode value={paymentOrder.checkout.value} />
|
||||
<span>请使用支付应用扫码完成支付</span>
|
||||
</>
|
||||
) : null}
|
||||
<PaymentStatus order={paymentOrder} now={clock} />
|
||||
</div>
|
||||
) : null}
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
@@ -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 <article className="workspace-wallet-ledger-row"><span className={cn("workspace-wallet-ledger-icon", positive ? "is-income" : "is-consume")}>{positive ? <Coins /> : <CreditCard />}</span><div><strong>{title}</strong><span>{[entry.scene, entry.model, entry.note].filter(Boolean).join(" · ") || "积分账户变动"}</span></div><time>{new Date(entry.createdAt).toLocaleString("zh-CN", { hour12: false })}</time><b className={positive ? "is-income" : "is-consume"}>{positive ? "+" : ""}{formatCredits(entry.amountMicrocredits, 6)}</b></article>;
|
||||
return (
|
||||
<article className="workspace-wallet-ledger-row">
|
||||
<span className={cn("workspace-wallet-ledger-icon", positive ? "is-income" : "is-consume")}>{positive ? <Coins /> : <CreditCard />}</span>
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<span>{[entry.scene, entry.model, entry.note].filter(Boolean).join(" · ") || "积分账户变动"}</span>
|
||||
</div>
|
||||
<time>{new Date(entry.createdAt).toLocaleString("zh-CN", { hour12: false })}</time>
|
||||
<b className={positive ? "is-income" : "is-consume"}>
|
||||
{positive ? "+" : ""}
|
||||
{formatCredits(entry.amountMicrocredits, 6)}
|
||||
</b>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
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 <div className="workspace-wallet-payment-status">订单剩余 {String(hours).padStart(2, "0")}:{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")},将自动确认支付结果</div>;
|
||||
return (
|
||||
<div className="workspace-wallet-payment-status">
|
||||
订单剩余 {String(hours).padStart(2, "0")}:{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")},将自动确认支付结果
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function paymentFooter(order: PaymentOrder | null, loading: boolean, close: () => void, cancel: () => Promise<void>, query: (id?: string, silent?: boolean) => Promise<void>, retry: () => Promise<void>) {
|
||||
if (order?.status === "pending") return [<Button key="cancel" danger disabled={loading} onClick={() => void cancel()}>关闭订单</Button>, <Button key="query" type="primary" loading={loading} onClick={() => void query()}>我已完成支付</Button>];
|
||||
if (order?.status === "create_failed") return [<Button key="close" onClick={close}>稍后处理</Button>, <Button key="retry" type="primary" loading={loading} onClick={() => void retry()}>重新生成支付入口</Button>];
|
||||
return [<Button key="done" type="primary" onClick={close}>完成</Button>];
|
||||
if (order?.status === "pending")
|
||||
return [
|
||||
<Button key="cancel" danger disabled={loading} onClick={() => void cancel()}>
|
||||
关闭订单
|
||||
</Button>,
|
||||
<Button key="query" type="primary" loading={loading} onClick={() => void query()}>
|
||||
我已完成支付
|
||||
</Button>,
|
||||
];
|
||||
if (order?.status === "create_failed")
|
||||
return [
|
||||
<Button key="close" onClick={close}>
|
||||
稍后处理
|
||||
</Button>,
|
||||
<Button key="retry" type="primary" loading={loading} onClick={() => void retry()}>
|
||||
重新生成支付入口
|
||||
</Button>,
|
||||
];
|
||||
return [
|
||||
<Button key="done" type="primary" onClick={close}>
|
||||
完成
|
||||
</Button>,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<string, { label: string; tone: "neutral" | "success" | "warning" | "error" | "info" }> = {
|
||||
@@ -89,6 +95,7 @@ export default function AdminPaymentsPage() {
|
||||
const [productDrawer, setProductDrawer] = useState<TopupProduct | null | undefined>();
|
||||
const [productSaving, setProductSaving] = useState(false);
|
||||
const [productForm] = Form.useForm<ProductFormValues>();
|
||||
const selectedProductStrategy = Form.useWatch("saleStrategy", productForm) || "unlimited";
|
||||
|
||||
const [orders, setOrders] = useState<AdminPaymentOrder[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(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) => <span className="font-medium tabular-nums">¥ {(value / 100).toFixed(2)}</span> },
|
||||
{ title: "到账积分", dataIndex: "creditsMicrocredits", width: 150, align: "right", render: (value) => <span className="tabular-nums">{formatCredits(value)}</span> },
|
||||
{ title: "排序", dataIndex: "sortOrder", width: 90, align: "center" },
|
||||
{ title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (value) => <AdminStatusBadge label={value ? "销售中" : "已停用"} tone={value ? "success" : "neutral"} /> },
|
||||
{
|
||||
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 <span className="text-xs">{label}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 <AdminStatusBadge label={label} tone={label === "销售中" ? "success" : label === "已售罄" || label === "已结束" ? "warning" : "neutral"} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
@@ -452,16 +498,28 @@ export default function AdminPaymentsPage() {
|
||||
width: 230,
|
||||
render: (_, order) => (
|
||||
<div className="min-w-0">
|
||||
{order.user ? <>
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<button type="button" className="admin-table-primary-link truncate font-medium" title={order.user.displayName || order.user.username} onClick={() => setSelectedUserId(order.user!.id)}>{order.user.displayName || order.user.username}</button>
|
||||
<span className="truncate text-xs text-foreground/45" title={`@${order.user.username}`}>@{order.user.username}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-foreground/60" title={order.user.email}>{order.user.email || "未填写邮箱"}</div>
|
||||
</> : <>
|
||||
<div className="text-foreground/60">用户不存在</div>
|
||||
<Typography.Text className="text-xs" copyable={order.userId ? { text: order.userId } : false}>{order.userId ? `${order.userId.slice(0, 8)}…${order.userId.slice(-6)}` : "--"}</Typography.Text>
|
||||
</>}
|
||||
{order.user ? (
|
||||
<>
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<button type="button" className="admin-table-primary-link truncate font-medium" title={order.user.displayName || order.user.username} onClick={() => setSelectedUserId(order.user!.id)}>
|
||||
{order.user.displayName || order.user.username}
|
||||
</button>
|
||||
<span className="truncate text-xs text-foreground/45" title={`@${order.user.username}`}>
|
||||
@{order.user.username}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-foreground/60" title={order.user.email}>
|
||||
{order.user.email || "未填写邮箱"}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-foreground/60">用户不存在</div>
|
||||
<Typography.Text className="text-xs" copyable={order.userId ? { text: order.userId } : false}>
|
||||
{order.userId ? `${order.userId.slice(0, 8)}…${order.userId.slice(-6)}` : "--"}
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -471,7 +529,9 @@ export default function AdminPaymentsPage() {
|
||||
width: 210,
|
||||
render: (_, order) => (
|
||||
<div>
|
||||
<Typography.Text className="font-mono text-xs" title={order.merchantOrderNo} copyable={{ text: order.merchantOrderNo }}>{order.merchantOrderNo.length > 20 ? `${order.merchantOrderNo.slice(0, 10)}…${order.merchantOrderNo.slice(-6)}` : order.merchantOrderNo}</Typography.Text>
|
||||
<Typography.Text className="font-mono text-xs" title={order.merchantOrderNo} copyable={{ text: order.merchantOrderNo }}>
|
||||
{order.merchantOrderNo.length > 20 ? `${order.merchantOrderNo.slice(0, 10)}…${order.merchantOrderNo.slice(-6)}` : order.merchantOrderNo}
|
||||
</Typography.Text>
|
||||
<div className="mt-1 truncate text-xs text-foreground/45" title={order.productName}>
|
||||
{order.productName}
|
||||
</div>
|
||||
@@ -515,7 +575,14 @@ export default function AdminPaymentsPage() {
|
||||
visibleActionCount={0}
|
||||
actions={[
|
||||
{ key: "sync", label: "同步支付状态", icon: <RefreshCw className="size-3.5" />, disabled: Boolean(orderActionId) || ["credited", "closed"].includes(order.status), onClick: () => queryOrder(order) },
|
||||
{ key: "close", label: "关闭订单", icon: <XCircle className="size-3.5" />, danger: true, disabled: Boolean(orderActionId) || !["created", "pending", "create_failed", "closing"].includes(order.status), onClick: () => closeOrder(order) },
|
||||
{
|
||||
key: "close",
|
||||
label: "关闭订单",
|
||||
icon: <XCircle className="size-3.5" />,
|
||||
danger: true,
|
||||
disabled: Boolean(orderActionId) || !["created", "pending", "create_failed", "closing"].includes(order.status),
|
||||
onClick: () => closeOrder(order),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
@@ -748,18 +815,76 @@ export default function AdminPaymentsPage() {
|
||||
/>
|
||||
|
||||
<AdminDrawer title="支付订单详情" size="min(680px, 100vw)" open={Boolean(selectedOrder)} onClose={() => setSelectedOrder(null)}>
|
||||
{selectedOrder && <Descriptions column={1} bordered size="small" items={[
|
||||
{ key: "user", label: "用户", children: selectedOrder.user ? <button type="button" className="admin-table-primary-link" onClick={() => { setSelectedUserId(selectedOrder.user!.id); setSelectedOrder(null); }}>{selectedOrder.user.displayName || selectedOrder.user.username} · @{selectedOrder.user.username}</button> : "用户不存在" },
|
||||
{ key: "email", label: "邮箱", children: selectedOrder.user?.email || "未填写邮箱" },
|
||||
{ key: "userId", label: "用户 ID", children: <Typography.Text copyable className="break-all">{selectedOrder.userId || "--"}</Typography.Text> },
|
||||
{ key: "order", label: "订单号", children: <Typography.Text copyable className="break-all">{selectedOrder.merchantOrderNo}</Typography.Text> },
|
||||
{ key: "trade", label: "渠道交易号", children: selectedOrder.providerTradeNo ? <Typography.Text copyable className="break-all">{selectedOrder.providerTradeNo}</Typography.Text> : "--" },
|
||||
{ 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 && (
|
||||
<Descriptions
|
||||
column={1}
|
||||
bordered
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "user",
|
||||
label: "用户",
|
||||
children: selectedOrder.user ? (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-table-primary-link"
|
||||
onClick={() => {
|
||||
setSelectedUserId(selectedOrder.user!.id);
|
||||
setSelectedOrder(null);
|
||||
}}
|
||||
>
|
||||
{selectedOrder.user.displayName || selectedOrder.user.username} · @{selectedOrder.user.username}
|
||||
</button>
|
||||
) : (
|
||||
"用户不存在"
|
||||
),
|
||||
},
|
||||
{ key: "email", label: "邮箱", children: selectedOrder.user?.email || "未填写邮箱" },
|
||||
{
|
||||
key: "userId",
|
||||
label: "用户 ID",
|
||||
children: (
|
||||
<Typography.Text copyable className="break-all">
|
||||
{selectedOrder.userId || "--"}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "order",
|
||||
label: "订单号",
|
||||
children: (
|
||||
<Typography.Text copyable className="break-all">
|
||||
{selectedOrder.merchantOrderNo}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "trade",
|
||||
label: "渠道交易号",
|
||||
children: selectedOrder.providerTradeNo ? (
|
||||
<Typography.Text copyable className="break-all">
|
||||
{selectedOrder.providerTradeNo}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
"--"
|
||||
),
|
||||
},
|
||||
{ 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]!) : "--" })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</AdminDrawer>
|
||||
<AdminUserDetailDrawer userId={selectedUserId} onClose={() => setSelectedUserId(null)} />
|
||||
|
||||
@@ -850,6 +975,41 @@ export default function AdminPaymentsPage() {
|
||||
<InputNumber min={0.000001} max={1_000_000_000} precision={6} className="w-full" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="saleStrategy" label="上架策略" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "unlimited", label: "不限量(默认)" },
|
||||
{ value: "periodic", label: "周期性" },
|
||||
{ value: "inventory", label: "库存性" },
|
||||
{ value: "timed", label: "限时性" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedProductStrategy === "periodic" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item name="periodDays" label="周期天数" rules={[{ required: true }, { type: "number", min: 1, max: 3650 }]}>
|
||||
<InputNumber min={1} max={3650} precision={0} className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="periodPurchaseLimit" label="周期内可购买次数" rules={[{ required: true }, { type: "number", min: 1, max: 100000 }]}>
|
||||
<InputNumber min={1} max={100000} precision={0} className="w-full" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedProductStrategy === "inventory" ? (
|
||||
<Form.Item name="stockTotal" label="平台总库存" extra="编辑库存时不能低于已售数量;未支付订单会暂时占用库存。" rules={[{ required: true }, { type: "number", min: 1, max: 9_000_000_000 }]}>
|
||||
<InputNumber min={1} max={9_000_000_000} precision={0} className="w-full" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{selectedProductStrategy === "timed" ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Form.Item name="saleStartAt" label="发售时间" rules={[{ required: true, message: "请选择发售时间" }]}>
|
||||
<DatePicker showTime className="w-full" />
|
||||
</Form.Item>
|
||||
<Form.Item name="saleEndAt" label="结束时间" rules={[{ required: true, message: "请选择结束时间" }]}>
|
||||
<DatePicker showTime className="w-full" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
) : null}
|
||||
<Form.Item name="sortOrder" label="排序" rules={[{ required: true }]}>
|
||||
<InputNumber precision={0} className="w-full" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -4,19 +4,9 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { createAdminUser, updateAdminUser, type AdminUser, type LocalUser } from "@/services/api/auth";
|
||||
|
||||
type UserFormValues = Pick<LocalUser, "displayName" | "email" | "role" | "status">;
|
||||
type UserFormValues = Pick<LocalUser, "displayName" | "email" | "role" | "status"> & { password?: string };
|
||||
|
||||
export function AdminUserEditDrawer({
|
||||
user,
|
||||
actorId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
user: AdminUser | null;
|
||||
actorId?: string;
|
||||
onClose: () => void;
|
||||
onSaved: (user: LocalUser) => void;
|
||||
}) {
|
||||
export function AdminUserEditDrawer({ user, actorId, onClose, onSaved }: { user: AdminUser | null; actorId?: string; onClose: () => void; onSaved: (user: LocalUser) => void }) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm<UserFormValues>();
|
||||
@@ -59,6 +49,7 @@ export function AdminUserEditDrawer({
|
||||
email: values.email?.trim() || "",
|
||||
role: values.role,
|
||||
status: values.status,
|
||||
...(values.password?.trim() ? { password: values.password } : {}),
|
||||
});
|
||||
onSaved(result.user);
|
||||
form.resetFields();
|
||||
@@ -79,7 +70,11 @@ export function AdminUserEditDrawer({
|
||||
onClose={close}
|
||||
mask={{ closable: !saving }}
|
||||
destroyOnHidden
|
||||
extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}
|
||||
extra={
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item label="用户名">
|
||||
@@ -91,11 +86,26 @@ export function AdminUserEditDrawer({
|
||||
<Form.Item name="email" label="邮箱" rules={[{ type: "email", message: "请输入有效邮箱" }]}>
|
||||
<Input placeholder="name@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="重置密码" extra="留空表示不修改;保存后该用户已有登录会话会失效。" rules={[{ min: 8, message: "密码至少 8 位" }]}>
|
||||
<Input.Password placeholder="输入新密码以手动重置" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" extra={editingSelf ? "不能在此修改当前管理员自己的角色。" : "角色变更会立即影响后台访问权限。"}>
|
||||
<Select disabled={editingSelf} options={[{ label: "管理员", value: "admin" }, { label: "普通用户", value: "user" }]} />
|
||||
<Select
|
||||
disabled={editingSelf}
|
||||
options={[
|
||||
{ label: "管理员", value: "admin" },
|
||||
{ label: "普通用户", value: "user" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="账号状态" extra={editingSelf ? "不能停用当前登录账号。" : "停用后会清除登录态,但保留身份、任务和积分流水。"}>
|
||||
<Select disabled={editingSelf} options={[{ label: "已启用", value: "active" }, { label: "已停用", value: "disabled" }]} />
|
||||
<Select
|
||||
disabled={editingSelf}
|
||||
options={[
|
||||
{ label: "已启用", value: "active" },
|
||||
{ label: "已停用", value: "disabled" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
@@ -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<CreateUserFormValues>();
|
||||
@@ -177,7 +179,11 @@ export function AdminUserCreateDrawer({
|
||||
onClose={close}
|
||||
mask={{ closable: !saving }}
|
||||
destroyOnHidden
|
||||
extra={<Button type="primary" loading={saving} onClick={() => void save()}>{"\u4fdd\u5b58"}</Button>}
|
||||
extra={
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
{"\u4fdd\u5b58"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="username" label={"\u7528\u6237\u540d"} rules={[{ required: true, whitespace: true, message: "\u8bf7\u8f93\u5165\u7528\u6237\u540d" }]}>
|
||||
@@ -193,10 +199,20 @@ export function AdminUserCreateDrawer({
|
||||
<Input.Password placeholder={"\u81f3\u5c11 8 \u4f4d"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label={"\u89d2\u8272"}>
|
||||
<Select options={[{ label: "\u7ba1\u7406\u5458", value: "admin" }, { label: "\u666e\u901a\u7528\u6237", value: "user" }]} />
|
||||
<Select
|
||||
options={[
|
||||
{ label: "\u7ba1\u7406\u5458", value: "admin" },
|
||||
{ label: "\u666e\u901a\u7528\u6237", value: "user" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label={"\u8d26\u53f7\u72b6\u6001"}>
|
||||
<Select options={[{ label: "\u5df2\u542f\u7528", value: "active" }, { label: "\u5df2\u505c\u7528", value: "disabled" }]} />
|
||||
<Select
|
||||
options={[
|
||||
{ label: "\u5df2\u542f\u7528", value: "active" },
|
||||
{ label: "\u5df2\u505c\u7528", value: "disabled" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
@@ -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<TopupProduct, "name" | "amountFen" | "creditsMicrocredits" | "enabled" | "sortOrder"> & { description?: string };
|
||||
export type TopupProductInput = Pick<TopupProduct, "name" | "amountFen" | "creditsMicrocredits" | "enabled" | "sortOrder" | "saleStrategy"> & {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user