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
This commit is contained in:
@@ -49,6 +49,7 @@ export interface Channel {
|
||||
status: string
|
||||
billing_model_source: string // "requested" | "upstream"
|
||||
restrict_models: boolean
|
||||
features_config?: Record<string, unknown>
|
||||
group_ids: number[]
|
||||
model_pricing: ChannelModelPricing[]
|
||||
model_mapping: Record<string, Record<string, string>> // platform → {src→dst}
|
||||
@@ -66,6 +67,7 @@ export interface CreateChannelRequest {
|
||||
model_mapping?: Record<string, Record<string, string>>
|
||||
billing_model_source?: string
|
||||
restrict_models?: boolean
|
||||
features_config?: Record<string, unknown>
|
||||
apply_pricing_to_account_stats?: boolean
|
||||
account_stats_pricing_rules?: AccountStatsPricingRule[]
|
||||
}
|
||||
@@ -79,6 +81,7 @@ export interface UpdateChannelRequest {
|
||||
model_mapping?: Record<string, Record<string, string>>
|
||||
billing_model_source?: string
|
||||
restrict_models?: boolean
|
||||
features_config?: Record<string, unknown>
|
||||
apply_pricing_to_account_stats?: boolean
|
||||
account_stats_pricing_rules?: AccountStatsPricingRule[]
|
||||
}
|
||||
|
||||
@@ -429,8 +429,6 @@ export interface AdminGroup extends Group {
|
||||
|
||||
// MCP XML 协议注入(仅 antigravity 平台使用)
|
||||
mcp_xml_inject: boolean
|
||||
// Claude usage 模拟开关(仅 anthropic 平台使用)
|
||||
simulate_claude_max_enabled: boolean
|
||||
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
supported_model_scopes?: string[]
|
||||
@@ -523,7 +521,6 @@ export interface CreateGroupRequest {
|
||||
fallback_group_id?: number | null
|
||||
fallback_group_id_on_invalid_request?: number | null
|
||||
mcp_xml_inject?: boolean
|
||||
simulate_claude_max_enabled?: boolean
|
||||
supported_model_scopes?: string[]
|
||||
require_oauth_only?: boolean
|
||||
require_privacy_set?: boolean
|
||||
@@ -549,7 +546,6 @@ export interface UpdateGroupRequest {
|
||||
fallback_group_id?: number | null
|
||||
fallback_group_id_on_invalid_request?: number | null
|
||||
mcp_xml_inject?: boolean
|
||||
simulate_claude_max_enabled?: boolean
|
||||
supported_model_scopes?: string[]
|
||||
require_oauth_only?: boolean
|
||||
require_privacy_set?: boolean
|
||||
@@ -691,6 +687,7 @@ export interface Account {
|
||||
// Extra fields including Codex usage and model-level rate limits (Antigravity smart retry)
|
||||
extra?: (CodexUsageSnapshot & {
|
||||
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>)
|
||||
proxy_id: number | null
|
||||
concurrency: number
|
||||
@@ -752,12 +749,6 @@ export interface Account {
|
||||
custom_base_url_enabled?: boolean | null
|
||||
custom_base_url?: string | null
|
||||
|
||||
// 客户端亲和调度(仅 Anthropic/Antigravity 平台有效)
|
||||
// 启用后新会话会优先调度到客户端之前使用过的账号
|
||||
client_affinity_enabled?: boolean | null
|
||||
affinity_client_count?: number | null
|
||||
affinity_clients?: string[] | null
|
||||
|
||||
// API Key 账号配额限制
|
||||
quota_limit?: number | null
|
||||
quota_used?: number | null
|
||||
@@ -1066,6 +1057,8 @@ export interface AdminUsageLog extends UsageLog {
|
||||
|
||||
// 账号计费倍率(仅管理员可见)
|
||||
account_rate_multiplier?: number | null
|
||||
// 自定义定价规则计算的账号统计费用(nil 时使用 total_cost * multiplier)
|
||||
account_stats_cost?: number | null
|
||||
|
||||
// 渠道 ID 和计费等级(仅管理员可见)
|
||||
channel_id?: number | null
|
||||
|
||||
@@ -3253,6 +3253,7 @@ const editForm = reactive({
|
||||
fallback_group_id_on_invalid_request: null as number | null,
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
allow_messages_dispatch: false,
|
||||
default_mapped_model: '',
|
||||
opus_mapped_model: editMessagesDispatchDefaults.opus_mapped_model,
|
||||
sonnet_mapped_model: editMessagesDispatchDefaults.sonnet_mapped_model,
|
||||
haiku_mapped_model: editMessagesDispatchDefaults.haiku_mapped_model,
|
||||
@@ -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 target = event.target as HTMLElement;
|
||||
|
||||
@@ -495,7 +495,7 @@ const exportToExcel = async () => {
|
||||
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.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 || ''
|
||||
])
|
||||
if (rows.length) {
|
||||
|
||||
@@ -117,6 +117,7 @@ function getPlanNameClass(groupId: number): string {
|
||||
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
|
||||
}
|
||||
|
||||
|
||||
// ==================== Plans ====================
|
||||
|
||||
const plansLoading = ref(false)
|
||||
@@ -133,6 +134,7 @@ const planColumns = computed((): Column[] => [
|
||||
{ key: 'price', label: t('payment.admin.price') },
|
||||
{ key: 'validity_days', label: t('payment.admin.validityDays') },
|
||||
{ key: 'for_sale', label: t('payment.admin.forSale') },
|
||||
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
|
||||
{ key: 'actions', label: t('common.actions') },
|
||||
])
|
||||
|
||||
@@ -157,6 +159,7 @@ function openPlanEdit(plan: SubscriptionPlan | null) {
|
||||
showPlanDialog.value = true
|
||||
}
|
||||
|
||||
|
||||
/** Quick toggle for_sale from the list */
|
||||
async function toggleForSale(plan: SubscriptionPlan) {
|
||||
try {
|
||||
|
||||
@@ -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.validityUnit') }} <span class="text-red-500">*</span></label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></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>
|
||||
<label class="input-label">{{ t('payment.admin.features') }}</label>
|
||||
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
|
||||
@@ -102,7 +105,7 @@ const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
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 validityUnitOptions = computed(() => [
|
||||
@@ -130,10 +133,10 @@ const selectedGroupInfo = computed(() => {
|
||||
watch(() => props.show, (visible) => {
|
||||
if (!visible) return
|
||||
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')
|
||||
} 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 = ''
|
||||
}
|
||||
})
|
||||
@@ -149,6 +152,7 @@ function buildPlanPayload() {
|
||||
original_price: planForm.original_price || 0,
|
||||
validity_days: planForm.validity_days,
|
||||
validity_unit: planForm.validity_unit,
|
||||
sort_order: planForm.sort_order,
|
||||
for_sale: planForm.for_sale,
|
||||
features,
|
||||
}
|
||||
|
||||
@@ -102,10 +102,12 @@ interface ReturnInfo {
|
||||
}
|
||||
const returnInfo = ref<ReturnInfo | null>(null)
|
||||
|
||||
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
|
||||
|
||||
const isSuccess = computed(() => {
|
||||
// Always prioritize actual order status from backend
|
||||
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
|
||||
if (route.query.status === 'success') return true
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<Icon name="x" size="sm" />
|
||||
<span>{{ t('payment.orders.cancel') }}</span>
|
||||
</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" />
|
||||
<span>{{ t('payment.orders.requestRefund') }}</span>
|
||||
</button>
|
||||
@@ -102,6 +102,7 @@ const appStore = useAppStore()
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const orders = ref<PaymentOrder[]>([])
|
||||
const refundEligibleProviders = ref<Set<string>>(new Set())
|
||||
const currentFilter = ref('')
|
||||
const cancelTargetId = ref<number | null>(null)
|
||||
const refundTarget = ref<PaymentOrder | null>(null)
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user