Commit 6ac8ccde authored by erio's avatar erio
Browse files

fix: merge 30 general improvements from release branch

Bug fixes:
- Detached context for GetAccountConcurrencyBatch (prevent all-zero on request cancel)
- Filter soft-deleted users in GetByGroupID
- Stripe CSP policy (allow Stripe.js in script-src and frame-src)
- WebSearch API key validation on save
- RECHARGING status in payment result success check
- Windows test fixes (logger Sync deadlock, config path escaping)

Feature enhancements:
- Webhook multi-instance dispatch (extractOutTradeNo + GetWebhookProvider)
- EasyPay mobile H5 payment (device param + PayURL2)
- SSE error propagation in WebSearch emulation
- AccountStatsCost DTO field for admin usage logs
- Plans sort by sort_order instead of created_at
- UsageMapHook for streaming response usage data
- apicompat Instructions field passthrough
- EffectiveLoadFactor for ops concurrency/metrics
- Usage billing RETURNING balance for notify system
- BulkUpdate mixed channel warning with details
- println to slog migration in auth cache
- Wire ProviderSet cleanup
- CI cache-dependency-path optimization

Frontend:
- Refund eligibility check per provider (canRequestRefund)
- Plan sort_order editing
- Dead code cleanup (simulate_claude_max, client_affinity)
- GroupsView platform switch guard
- channels features_config API type
- UsageView account_stats_cost export
parent f1297a36
...@@ -214,17 +214,23 @@ func writeWebSearchStreamResponse( ...@@ -214,17 +214,23 @@ func writeWebSearchStreamResponse(
) (*ForwardResult, error) { ) (*ForwardResult, error) {
msgID := webSearchMsgIDPrefix + uuid.New().String() msgID := webSearchMsgIDPrefix + uuid.New().String()
toolUseID := webSearchToolUseIDPrefix + uuid.New().String()[:16] toolUseID := webSearchToolUseIDPrefix + uuid.New().String()[:16]
textSummary := buildTextSummary(query, resp.Results)
setSSEHeaders(c) setSSEHeaders(c)
if err := writeSSEMessageStart(c.Writer, msgID, model); err != nil { w := c.Writer
return nil, fmt.Errorf("web search emulation: SSE write: %w", err) for _, fn := range []func() error{
func() error { return writeSSEMessageStart(w, msgID, model) },
func() error { return writeSSEServerToolUse(w, toolUseID, query, 0) },
func() error { return writeSSEToolResult(w, toolUseID, resp.Results, 1) },
func() error { return writeSSETextBlock(w, textSummary, 2) },
func() error { return writeSSEMessageEnd(w, len(textSummary)/tokenEstimateDivisor) },
} {
if err := fn(); err != nil {
slog.Warn("web search emulation: SSE write failed, stopping", "error", err)
break
}
} }
writeSSEServerToolUse(c.Writer, toolUseID, query, 0) w.Flush()
writeSSEToolResult(c.Writer, toolUseID, resp.Results, 1)
textSummary := buildTextSummary(query, resp.Results)
writeSSETextBlock(c.Writer, textSummary, 2)
writeSSEMessageEnd(c.Writer, len(textSummary)/tokenEstimateDivisor)
c.Writer.Flush()
return &ForwardResult{Model: model, Duration: time.Since(startTime), Usage: ClaudeUsage{}}, nil return &ForwardResult{Model: model, Duration: time.Since(startTime), Usage: ClaudeUsage{}}, nil
} }
...@@ -249,7 +255,7 @@ func writeSSEMessageStart(w http.ResponseWriter, msgID, model string) error { ...@@ -249,7 +255,7 @@ func writeSSEMessageStart(w http.ResponseWriter, msgID, model string) error {
return flushSSEJSON(w, "message_start", evt) return flushSSEJSON(w, "message_start", evt)
} }
func writeSSEServerToolUse(w http.ResponseWriter, toolUseID, query string, index int) { func writeSSEServerToolUse(w http.ResponseWriter, toolUseID, query string, index int) error {
start := map[string]any{ start := map[string]any{
"type": "content_block_start", "index": index, "type": "content_block_start", "index": index,
"content_block": map[string]any{ "content_block": map[string]any{
...@@ -257,11 +263,13 @@ func writeSSEServerToolUse(w http.ResponseWriter, toolUseID, query string, index ...@@ -257,11 +263,13 @@ func writeSSEServerToolUse(w http.ResponseWriter, toolUseID, query string, index
"name": toolNameWebSearch, "input": map[string]string{"query": query}, "name": toolNameWebSearch, "input": map[string]string{"query": query},
}, },
} }
_ = flushSSEJSON(w, "content_block_start", start) if err := flushSSEJSON(w, "content_block_start", start); err != nil {
_ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) return err
}
return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
} }
func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []websearch.SearchResult, index int) { func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []websearch.SearchResult, index int) error {
start := map[string]any{ start := map[string]any{
"type": "content_block_start", "index": index, "type": "content_block_start", "index": index,
"content_block": map[string]any{ "content_block": map[string]any{
...@@ -269,40 +277,48 @@ func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []webse ...@@ -269,40 +277,48 @@ func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []webse
"content": buildSearchResultBlocks(results), "content": buildSearchResultBlocks(results),
}, },
} }
_ = flushSSEJSON(w, "content_block_start", start) if err := flushSSEJSON(w, "content_block_start", start); err != nil {
_ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) return err
}
return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
} }
func writeSSETextBlock(w http.ResponseWriter, text string, index int) { func writeSSETextBlock(w http.ResponseWriter, text string, index int) error {
_ = flushSSEJSON(w, "content_block_start", map[string]any{ if err := flushSSEJSON(w, "content_block_start", map[string]any{
"type": "content_block_start", "index": index, "type": "content_block_start", "index": index,
"content_block": map[string]any{"type": "text", "text": ""}, "content_block": map[string]any{"type": "text", "text": ""},
}) }); err != nil {
_ = flushSSEJSON(w, "content_block_delta", map[string]any{ return err
}
if err := flushSSEJSON(w, "content_block_delta", map[string]any{
"type": "content_block_delta", "index": index, "type": "content_block_delta", "index": index,
"delta": map[string]string{"type": "text_delta", "text": text}, "delta": map[string]string{"type": "text_delta", "text": text},
}) }); err != nil {
_ = flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index}) return err
}
return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
} }
func writeSSEMessageEnd(w http.ResponseWriter, outputTokens int) { func writeSSEMessageEnd(w http.ResponseWriter, outputTokens int) error {
_ = flushSSEJSON(w, "message_delta", map[string]any{ if err := flushSSEJSON(w, "message_delta", map[string]any{
"type": "message_delta", "type": "message_delta",
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
"usage": map[string]int{"output_tokens": outputTokens}, "usage": map[string]int{"output_tokens": outputTokens},
}) }); err != nil {
_ = flushSSEJSON(w, "message_stop", map[string]string{"type": "message_stop"}) return err
}
return flushSSEJSON(w, "message_stop", map[string]string{"type": "message_stop"})
} }
// flushSSEJSON marshals data to JSON and writes an SSE event. Returns error on marshal failure. // flushSSEJSON marshals data to JSON and writes an SSE event.
func flushSSEJSON(w http.ResponseWriter, event string, data any) error { func flushSSEJSON(w http.ResponseWriter, event string, data any) error {
b, err := json.Marshal(data) b, err := json.Marshal(data)
if err != nil { if err != nil {
slog.Error("web search emulation: failed to marshal SSE event", return fmt.Errorf("marshal: %w", err)
"event", event, "error", err) }
return err if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil {
return fmt.Errorf("write: %w", err)
} }
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b)
if f, ok := w.(http.Flusher); ok { if f, ok := w.(http.Flusher); ok {
f.Flush() f.Flush()
} }
......
...@@ -64,12 +64,9 @@ func (s *OpsService) getAccountsLoadMapBestEffort(ctx context.Context, accounts ...@@ -64,12 +64,9 @@ func (s *OpsService) getAccountsLoadMapBestEffort(ctx context.Context, accounts
if acc.ID <= 0 { if acc.ID <= 0 {
continue continue
} }
c := acc.Concurrency lf := acc.EffectiveLoadFactor()
if c <= 0 { if prev, ok := unique[acc.ID]; !ok || lf > prev {
c = 1 unique[acc.ID] = lf
}
if prev, ok := unique[acc.ID]; !ok || c > prev {
unique[acc.ID] = c
} }
} }
......
...@@ -391,7 +391,7 @@ func (c *OpsMetricsCollector) collectConcurrencyQueueDepth(parentCtx context.Con ...@@ -391,7 +391,7 @@ func (c *OpsMetricsCollector) collectConcurrencyQueueDepth(parentCtx context.Con
} }
batch = append(batch, AccountWithConcurrency{ batch = append(batch, AccountWithConcurrency{
ID: acc.ID, ID: acc.ID,
MaxConcurrency: acc.Concurrency, MaxConcurrency: acc.EffectiveLoadFactor(),
}) })
} }
if len(batch) == 0 { if len(batch) == 0 {
......
...@@ -183,6 +183,15 @@ func TestOpsSystemLogSink_StartStopAndFlushSuccess(t *testing.T) { ...@@ -183,6 +183,15 @@ func TestOpsSystemLogSink_StartStopAndFlushSuccess(t *testing.T) {
if strings.TrimSpace(item.Message) == "" { if strings.TrimSpace(item.Message) == "" {
t.Fatalf("message should not be empty") t.Fatalf("message should not be empty")
} }
// writtenCount is incremented after BatchInsertSystemLogsFn returns,
// so poll briefly to avoid a race between the done signal and the atomic add.
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if sink.Health().WrittenCount > 0 {
break
}
time.Sleep(time.Millisecond)
}
health := sink.Health() health := sink.Health()
if health.WrittenCount == 0 { if health.WrittenCount == 0 {
t.Fatalf("written_count should be >0") t.Fatalf("written_count should be >0")
......
...@@ -113,11 +113,11 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe ...@@ -113,11 +113,11 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe
} }
func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.ByCreatedAt()).All(ctx) return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
} }
func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.ByCreatedAt()).All(ctx) return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
} }
func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) { func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
......
...@@ -140,6 +140,16 @@ func (s *SettingService) SaveWebSearchEmulationConfig(ctx context.Context, cfg * ...@@ -140,6 +140,16 @@ func (s *SettingService) SaveWebSearchEmulationConfig(ctx context.Context, cfg *
} }
s.mergeExistingAPIKeys(ctx, cfg) s.mergeExistingAPIKeys(ctx, cfg)
// After merge, validate all enabled providers have API keys
if cfg.Enabled {
for _, p := range cfg.Providers {
if p.APIKey == "" {
return infraerrors.BadRequest("MISSING_API_KEY",
fmt.Sprintf("provider %s has no API key configured", p.Type))
}
}
}
data, err := json.Marshal(cfg) data, err := json.Marshal(cfg)
if err != nil { if err != nil {
return fmt.Errorf("websearch: marshal config: %w", err) return fmt.Errorf("websearch: marshal config: %w", err)
......
...@@ -49,6 +49,7 @@ export interface Channel { ...@@ -49,6 +49,7 @@ export interface Channel {
status: string status: string
billing_model_source: string // "requested" | "upstream" billing_model_source: string // "requested" | "upstream"
restrict_models: boolean restrict_models: boolean
features_config?: Record<string, unknown>
group_ids: number[] group_ids: number[]
model_pricing: ChannelModelPricing[] model_pricing: ChannelModelPricing[]
model_mapping: Record<string, Record<string, string>> // platform → {src→dst} model_mapping: Record<string, Record<string, string>> // platform → {src→dst}
...@@ -66,6 +67,7 @@ export interface CreateChannelRequest { ...@@ -66,6 +67,7 @@ export interface CreateChannelRequest {
model_mapping?: Record<string, Record<string, string>> model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string billing_model_source?: string
restrict_models?: boolean restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[] account_stats_pricing_rules?: AccountStatsPricingRule[]
} }
...@@ -79,6 +81,7 @@ export interface UpdateChannelRequest { ...@@ -79,6 +81,7 @@ export interface UpdateChannelRequest {
model_mapping?: Record<string, Record<string, string>> model_mapping?: Record<string, Record<string, string>>
billing_model_source?: string billing_model_source?: string
restrict_models?: boolean restrict_models?: boolean
features_config?: Record<string, unknown>
apply_pricing_to_account_stats?: boolean apply_pricing_to_account_stats?: boolean
account_stats_pricing_rules?: AccountStatsPricingRule[] account_stats_pricing_rules?: AccountStatsPricingRule[]
} }
......
...@@ -429,8 +429,6 @@ export interface AdminGroup extends Group { ...@@ -429,8 +429,6 @@ export interface AdminGroup extends Group {
// MCP XML 协议注入(仅 antigravity 平台使用) // MCP XML 协议注入(仅 antigravity 平台使用)
mcp_xml_inject: boolean mcp_xml_inject: boolean
// Claude usage 模拟开关(仅 anthropic 平台使用)
simulate_claude_max_enabled: boolean
// 支持的模型系列(仅 antigravity 平台使用) // 支持的模型系列(仅 antigravity 平台使用)
supported_model_scopes?: string[] supported_model_scopes?: string[]
...@@ -523,7 +521,6 @@ export interface CreateGroupRequest { ...@@ -523,7 +521,6 @@ export interface CreateGroupRequest {
fallback_group_id?: number | null fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[] supported_model_scopes?: string[]
require_oauth_only?: boolean require_oauth_only?: boolean
require_privacy_set?: boolean require_privacy_set?: boolean
...@@ -549,7 +546,6 @@ export interface UpdateGroupRequest { ...@@ -549,7 +546,6 @@ export interface UpdateGroupRequest {
fallback_group_id?: number | null fallback_group_id?: number | null
fallback_group_id_on_invalid_request?: number | null fallback_group_id_on_invalid_request?: number | null
mcp_xml_inject?: boolean mcp_xml_inject?: boolean
simulate_claude_max_enabled?: boolean
supported_model_scopes?: string[] supported_model_scopes?: string[]
require_oauth_only?: boolean require_oauth_only?: boolean
require_privacy_set?: boolean require_privacy_set?: boolean
...@@ -691,6 +687,7 @@ export interface Account { ...@@ -691,6 +687,7 @@ export interface Account {
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry) // Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
extra?: (CodexUsageSnapshot & { extra?: (CodexUsageSnapshot & {
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }> model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
antigravity_credits_overages?: Record<string, { activated_at: string; active_until: string }>
} & Record<string, unknown>) } & Record<string, unknown>)
proxy_id: number | null proxy_id: number | null
concurrency: number concurrency: number
...@@ -752,12 +749,6 @@ export interface Account { ...@@ -752,12 +749,6 @@ export interface Account {
custom_base_url_enabled?: boolean | null custom_base_url_enabled?: boolean | null
custom_base_url?: string | null custom_base_url?: string | null
// 客户端亲和调度(仅 Anthropic/Antigravity 平台有效)
// 启用后新会话会优先调度到客户端之前使用过的账号
client_affinity_enabled?: boolean | null
affinity_client_count?: number | null
affinity_clients?: string[] | null
// API Key 账号配额限制 // API Key 账号配额限制
quota_limit?: number | null quota_limit?: number | null
quota_used?: number | null quota_used?: number | null
...@@ -1066,6 +1057,8 @@ export interface AdminUsageLog extends UsageLog { ...@@ -1066,6 +1057,8 @@ export interface AdminUsageLog extends UsageLog {
// 账号计费倍率(仅管理员可见) // 账号计费倍率(仅管理员可见)
account_rate_multiplier?: number | null account_rate_multiplier?: number | null
// 自定义定价规则计算的账号统计费用(nil 时使用 total_cost * multiplier)
account_stats_cost?: number | null
// 渠道 ID 和计费等级(仅管理员可见) // 渠道 ID 和计费等级(仅管理员可见)
channel_id?: number | null channel_id?: number | null
......
...@@ -3253,6 +3253,7 @@ const editForm = reactive({ ...@@ -3253,6 +3253,7 @@ const editForm = reactive({
fallback_group_id_on_invalid_request: null as number | null, fallback_group_id_on_invalid_request: null as number | null,
// OpenAI Messages 调度配置(仅 openai 平台使用) // OpenAI Messages 调度配置(仅 openai 平台使用)
allow_messages_dispatch: false, allow_messages_dispatch: false,
default_mapped_model: '',
opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model, opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model,
sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model, sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model,
haiku_mapped_model: editMessagesDispatchDefaults.haiku_mapped_model, haiku_mapped_model: editMessagesDispatchDefaults.haiku_mapped_model,
...@@ -3732,6 +3733,19 @@ watch( ...@@ -3732,6 +3733,19 @@ watch(
}, },
); );
watch(
() => editForm.platform,
(newVal) => {
if (!['anthropic', 'antigravity'].includes(newVal)) {
editForm.fallback_group_id_on_invalid_request = null
}
if (newVal !== 'openai') {
editForm.allow_messages_dispatch = false
editForm.default_mapped_model = ''
}
}
)
// 点击外部关闭账号搜索下拉框 // 点击外部关闭账号搜索下拉框
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
......
...@@ -495,7 +495,7 @@ const exportToExcel = async () => { ...@@ -495,7 +495,7 @@ const exportToExcel = async () => {
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000', log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000',
log.rate_multiplier?.toPrecision(4) || '1.00', (log.account_rate_multiplier ?? 1).toPrecision(4), log.rate_multiplier?.toPrecision(4) || '1.00', (log.account_rate_multiplier ?? 1).toPrecision(4),
log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000', log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms, ((log.account_stats_cost ?? log.total_cost) * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms,
log.request_id || '', log.user_agent || '', log.ip_address || '' log.request_id || '', log.user_agent || '', log.ip_address || ''
]) ])
if (rows.length) { if (rows.length) {
......
...@@ -117,6 +117,7 @@ function getPlanNameClass(groupId: number): string { ...@@ -117,6 +117,7 @@ function getPlanNameClass(groupId: number): string {
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white' return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
} }
// ==================== Plans ==================== // ==================== Plans ====================
const plansLoading = ref(false) const plansLoading = ref(false)
...@@ -133,6 +134,7 @@ const planColumns = computed((): Column[] => [ ...@@ -133,6 +134,7 @@ const planColumns = computed((): Column[] => [
{ key: 'price', label: t('payment.admin.price') }, { key: 'price', label: t('payment.admin.price') },
{ key: 'validity_days', label: t('payment.admin.validityDays') }, { key: 'validity_days', label: t('payment.admin.validityDays') },
{ key: 'for_sale', label: t('payment.admin.forSale') }, { key: 'for_sale', label: t('payment.admin.forSale') },
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
{ key: 'actions', label: t('common.actions') }, { key: 'actions', label: t('common.actions') },
]) ])
...@@ -157,6 +159,7 @@ function openPlanEdit(plan: SubscriptionPlan | null) { ...@@ -157,6 +159,7 @@ function openPlanEdit(plan: SubscriptionPlan | null) {
showPlanDialog.value = true showPlanDialog.value = true
} }
/** Quick toggle for_sale from the list */ /** Quick toggle for_sale from the list */
async function toggleForSale(plan: SubscriptionPlan) { async function toggleForSale(plan: SubscriptionPlan) {
try { try {
......
...@@ -42,6 +42,9 @@ ...@@ -42,6 +42,9 @@
<div><label class="input-label">{{ t('payment.admin.validityDays') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div> <div><label class="input-label">{{ t('payment.admin.validityDays') }} <span class="text-red-500">*</span></label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
<div><label class="input-label">{{ t('payment.admin.validityUnit') }} <span class="text-red-500">*</span></label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div> <div><label class="input-label">{{ t('payment.admin.validityUnit') }} <span class="text-red-500">*</span></label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
</div> </div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
</div>
<div> <div>
<label class="input-label">{{ t('payment.admin.features') }}</label> <label class="input-label">{{ t('payment.admin.features') }}</label>
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea> <textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
...@@ -102,7 +105,7 @@ const { t } = useI18n() ...@@ -102,7 +105,7 @@ const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const saving = ref(false) const saving = ref(false)
const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true }) const planForm = reactive({ name: '', group_id: null as number | null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
const planFeaturesText = ref('') const planFeaturesText = ref('')
const validityUnitOptions = computed(() => [ const validityUnitOptions = computed(() => [
...@@ -130,10 +133,10 @@ const selectedGroupInfo = computed(() => { ...@@ -130,10 +133,10 @@ const selectedGroupInfo = computed(() => {
watch(() => props.show, (visible) => { watch(() => props.show, (visible) => {
if (!visible) return if (!visible) return
if (props.plan) { if (props.plan) {
Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', for_sale: props.plan.for_sale }) Object.assign(planForm, { name: props.plan.name, group_id: props.plan.group_id, description: props.plan.description, price: props.plan.price, original_price: props.plan.original_price || 0, validity_days: props.plan.validity_days, validity_unit: props.plan.validity_unit || 'days', sort_order: props.plan.sort_order || 0, for_sale: props.plan.for_sale })
planFeaturesText.value = (props.plan.features || []).join('\n') planFeaturesText.value = (props.plan.features || []).join('\n')
} else { } else {
Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true }) Object.assign(planForm, { name: '', group_id: null, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', sort_order: 0, for_sale: true })
planFeaturesText.value = '' planFeaturesText.value = ''
} }
}) })
...@@ -149,6 +152,7 @@ function buildPlanPayload() { ...@@ -149,6 +152,7 @@ function buildPlanPayload() {
original_price: planForm.original_price || 0, original_price: planForm.original_price || 0,
validity_days: planForm.validity_days, validity_days: planForm.validity_days,
validity_unit: planForm.validity_unit, validity_unit: planForm.validity_unit,
sort_order: planForm.sort_order,
for_sale: planForm.for_sale, for_sale: planForm.for_sale,
features, features,
} }
......
...@@ -102,10 +102,12 @@ interface ReturnInfo { ...@@ -102,10 +102,12 @@ interface ReturnInfo {
} }
const returnInfo = ref<ReturnInfo | null>(null) const returnInfo = ref<ReturnInfo | null>(null)
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
const isSuccess = computed(() => { const isSuccess = computed(() => {
// Always prioritize actual order status from backend // Always prioritize actual order status from backend
if (order.value) { if (order.value) {
return order.value.status === 'COMPLETED' || order.value.status === 'PAID' return SUCCESS_STATUSES.has(order.value.status)
} }
// Fallback only when order not loaded // Fallback only when order not loaded
if (route.query.status === 'success') return true if (route.query.status === 'success') return true
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
<Icon name="x" size="sm" /> <Icon name="x" size="sm" />
<span>{{ t('payment.orders.cancel') }}</span> <span>{{ t('payment.orders.cancel') }}</span>
</button> </button>
<button v-if="row.status === 'COMPLETED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20"> <button v-if="canRequestRefund(row)" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
<Icon name="dollar" size="sm" /> <Icon name="dollar" size="sm" />
<span>{{ t('payment.orders.requestRefund') }}</span> <span>{{ t('payment.orders.requestRefund') }}</span>
</button> </button>
...@@ -102,6 +102,7 @@ const appStore = useAppStore() ...@@ -102,6 +102,7 @@ const appStore = useAppStore()
const loading = ref(false) const loading = ref(false)
const actionLoading = ref(false) const actionLoading = ref(false)
const orders = ref<PaymentOrder[]>([]) const orders = ref<PaymentOrder[]>([])
const refundEligibleProviders = ref<Set<string>>(new Set())
const currentFilter = ref('') const currentFilter = ref('')
const cancelTargetId = ref<number | null>(null) const cancelTargetId = ref<number | null>(null)
const refundTarget = ref<PaymentOrder | null>(null) const refundTarget = ref<PaymentOrder | null>(null)
...@@ -171,5 +172,18 @@ async function confirmRefund() { ...@@ -171,5 +172,18 @@ async function confirmRefund() {
} }
} }
onMounted(() => fetchOrders()) function canRequestRefund(order: PaymentOrder): boolean {
if (order.status !== 'COMPLETED') return false
if (!order.provider_instance_id) return false
return refundEligibleProviders.value.has(order.provider_instance_id)
}
async function loadRefundEligibility() {
try {
const res = await paymentAPI.getRefundEligibleProviders()
refundEligibleProviders.value = new Set(res.data.provider_instance_ids || [])
} catch { /* ignore — default to hiding refund button */ }
}
onMounted(() => { fetchOrders(); loadRefundEligibility() })
</script> </script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment