feat(usage): add support for access token and user ID in usage scripts

Add optional accessToken and userId fields to usage query scripts,
enabling queries to authenticated endpoints like /api/user/self.

Changes:
- Add accessToken and userId fields to UsageScript type (frontend & backend)
- Extend script engine to support {{accessToken}} and {{userId}} placeholders
- Update NewAPI preset template to use /api/user/self endpoint
- Add UI inputs for access token and user ID in UsageScriptModal
- Pass new parameters through service layer to script executor

This allows users to query usage data from endpoints that require
login credentials, providing more accurate balance information for
services like NewAPI/OneAPI platforms.
This commit is contained in:
Jason
2025-11-04 11:30:14 +08:00
parent ccb011fba1
commit 49c2855b10
5 changed files with 86 additions and 27 deletions

View File

@@ -63,6 +63,14 @@ pub struct UsageScript {
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
/// 访问令牌(用于需要登录的接口)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "accessToken")]
pub access_token: Option<String>,
/// 用户ID用于需要用户标识的接口
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "userId")]
pub user_id: Option<String>,
}
/// 用量数据

View File

@@ -717,7 +717,7 @@ impl ProviderService {
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
let (provider, script_code, timeout) = {
let (provider, script_code, timeout, access_token, user_id) = {
let config = state.config.read().map_err(AppError::from)?;
let manager = config
.get_manager(&app_type)
@@ -727,7 +727,7 @@ impl ProviderService {
.get(provider_id)
.cloned()
.ok_or_else(|| AppError::ProviderNotFound(provider_id.to_string()))?;
let (script_code, timeout) = {
let (script_code, timeout, access_token, user_id) = {
let usage_script = provider
.meta
.as_ref()
@@ -749,15 +749,26 @@ impl ProviderService {
(
usage_script.code.clone(),
usage_script.timeout.unwrap_or(10),
usage_script.access_token.clone(),
usage_script.user_id.clone(),
)
};
(provider, script_code, timeout)
(provider, script_code, timeout, access_token, user_id)
};
let (api_key, base_url) = Self::extract_credentials(&provider, &app_type)?;
match usage_script::execute_usage_script(&script_code, &api_key, &base_url, timeout).await {
match usage_script::execute_usage_script(
&script_code,
&api_key,
&base_url,
timeout,
access_token.as_deref(),
user_id.as_deref(),
)
.await
{
Ok(data) => {
let usage_list: Vec<UsageData> = if data.is_array() {
serde_json::from_value(data)

View File

@@ -12,12 +12,22 @@ pub async fn execute_usage_script(
api_key: &str,
base_url: &str,
timeout_secs: u64,
access_token: Option<&str>,
user_id: Option<&str>,
) -> Result<Value, AppError> {
// 1. 替换变量
let replaced = script_code
let mut replaced = script_code
.replace("{{apiKey}}", api_key)
.replace("{{baseUrl}}", base_url);
// 替换 accessToken 和 userId
if let Some(token) = access_token {
replaced = replaced.replace("{{accessToken}}", token);
}
if let Some(uid) = user_id {
replaced = replaced.replace("{{userId}}", uid);
}
// 2. 在独立作用域中提取 request 配置(确保 Runtime/Context 在 await 前释放)
let request_config = {
let runtime =

View File

@@ -47,37 +47,28 @@ const PRESET_TEMPLATES: Record<string, string> = {
NewAPI: `({
request: {
url: "{{baseUrl}}/api/usage/token",
url: "{{baseUrl}}/api/user/self",
method: "GET",
headers: {
Authorization: "Bearer {{apiKey}}",
"Content-Type": "application/json",
"Authorization": "Bearer {{accessToken}}",
"New-Api-User": "{{userId}}"
},
},
extractor: function (response) {
if (response.code) {
if (response.data.unlimited_quota) {
return {
planName: response.data.name,
total: -1,
used: response.data.total_used / 500000,
unit: "USD",
};
}
if (response.success && response.data) {
return {
isValid: true,
planName: response.data.name,
total: response.data.total_granted / 500000,
used: response.data.total_used / 500000,
remaining: response.data.total_available / 500000,
planName: response.data.group || "默认套餐",
remaining: response.data.quota / 500000,
used: response.data.used_quota / 500000,
total: (response.data.quota + response.data.used_quota) / 500000,
unit: "USD",
};
}
if (response.error) {
return {
isValid: false,
invalidMessage: response.error.message,
};
}
return {
isValid: false,
invalidMessage: response.message || "查询失败"
};
},
})`,
};
@@ -275,6 +266,43 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</label>
</div>
{/* 高级配置Access Token 和 User ID */}
<div className="space-y-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
🔑
</p>
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
Access Token访
</span>
<input
type="text"
value={script.accessToken || ''}
onChange={(e) => setScript({...script, accessToken: e.target.value})}
placeholder="从浏览器开发者工具获取..."
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-gray-600 dark:text-gray-400">
User ID
</span>
<input
type="text"
value={script.userId || ''}
onChange={(e) => setScript({...script, userId: e.target.value})}
placeholder="例如240"
className="mt-1 w-full px-3 py-2 border border-border-default dark:border-border-default rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm"
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400">
💡 使{`{{accessToken}}`} {`{{userId}}`}
</p>
</div>
{/* 脚本说明 */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<h4 className="font-medium mb-2">

View File

@@ -43,6 +43,8 @@ export interface UsageScript {
language: "javascript"; // 脚本语言
code: string; // 脚本代码JSON 格式配置)
timeout?: number; // 超时时间(秒,默认 10
accessToken?: string; // 访问令牌(用于需要登录的接口)
userId?: string; // 用户ID用于需要用户标识的接口
}
// 单个套餐用量数据