mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 00:27:30 +00:00
Merge pull request #2766 from DaydreamCoding/feat/user-platform-quota
feat(quota): 用户 × 平台 USD 配额
This commit is contained in:
@@ -163,6 +163,16 @@ vi.mock("vue-i18n", async () => {
|
||||
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
|
||||
"admin.settings.site.uploadImage": "上传图片",
|
||||
"admin.settings.site.remove": "移除",
|
||||
"admin.settings.platformQuota.platform": "平台",
|
||||
"admin.settings.platformQuota.daily": "日限额 (USD)",
|
||||
"admin.settings.platformQuota.weekly": "周限额 (USD)",
|
||||
"admin.settings.platformQuota.monthly": "月限额 (USD, 30天滚动)",
|
||||
"admin.settings.platformQuota.placeholder": "不限",
|
||||
"admin.settings.defaults.defaultPlatformQuotas": "默认平台限额(注册时分配)",
|
||||
"admin.settings.defaults.defaultPlatformQuotasHint": "新用户注册时自动写入平台限额记录;已有用户不受影响。留空 = 该平台该窗口不限制。",
|
||||
"admin.settings.defaults.platformQuotaNotice": "月限额为 30 天滚动窗口,非自然月",
|
||||
"admin.settings.authSourceDefaults.platformQuotasOverride": "平台限额覆盖",
|
||||
"admin.settings.authSourceDefaults.platformQuotasOverrideHint": "留空的字段继承「系统默认平台限额」;填 0 表示禁止该窗口使用。",
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
@@ -403,6 +413,13 @@ const baseSettingsResponse = {
|
||||
subscription_expiry_notify_enabled: true,
|
||||
account_quota_notify_enabled: false,
|
||||
account_quota_notify_emails: [],
|
||||
// 平台限额嵌套字段(新后端契约)
|
||||
default_platform_quotas: {
|
||||
anthropic: { daily: null, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: 12.5, monthly: null },
|
||||
gemini: { daily: null, weekly: null, monthly: 200 },
|
||||
antigravity: { daily: null, weekly: null, monthly: null },
|
||||
},
|
||||
};
|
||||
|
||||
function mountView() {
|
||||
@@ -984,3 +1001,157 @@ describe("admin SettingsView wechat connect controls", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin SettingsView platform quota matrix", () => {
|
||||
beforeEach(() => {
|
||||
getSettings.mockReset();
|
||||
updateSettings.mockReset();
|
||||
getWebSearchEmulationConfig.mockReset();
|
||||
updateWebSearchEmulationConfig.mockReset();
|
||||
getAdminApiKey.mockReset();
|
||||
getOverloadCooldownSettings.mockReset();
|
||||
getRateLimit429CooldownSettings.mockReset();
|
||||
updateRateLimit429CooldownSettings.mockReset();
|
||||
getStreamTimeoutSettings.mockReset();
|
||||
getRectifierSettings.mockReset();
|
||||
getBetaPolicySettings.mockReset();
|
||||
getGroups.mockReset();
|
||||
listProxies.mockReset();
|
||||
getProviders.mockReset();
|
||||
updateProvider.mockReset();
|
||||
createProvider.mockReset();
|
||||
deleteProvider.mockReset();
|
||||
fetchPublicSettings.mockReset();
|
||||
adminSettingsFetch.mockReset();
|
||||
showError.mockReset();
|
||||
showSuccess.mockReset();
|
||||
localeRef.value = "zh-CN";
|
||||
|
||||
getSettings.mockResolvedValue({ ...baseSettingsResponse });
|
||||
updateSettings.mockImplementation(async (payload) => ({
|
||||
...baseSettingsResponse,
|
||||
...payload,
|
||||
}));
|
||||
getWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] });
|
||||
updateWebSearchEmulationConfig.mockResolvedValue({ enabled: false, providers: [] });
|
||||
getAdminApiKey.mockResolvedValue({ exists: false, masked_key: "" });
|
||||
getOverloadCooldownSettings.mockResolvedValue({});
|
||||
getRateLimit429CooldownSettings.mockResolvedValue({});
|
||||
updateRateLimit429CooldownSettings.mockResolvedValue({});
|
||||
getStreamTimeoutSettings.mockResolvedValue({});
|
||||
getRectifierSettings.mockResolvedValue({});
|
||||
getBetaPolicySettings.mockResolvedValue({});
|
||||
getGroups.mockResolvedValue([]);
|
||||
listProxies.mockResolvedValue({ items: [] });
|
||||
getProviders.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 4 平台行", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
expect(getSettings).toHaveBeenCalled();
|
||||
|
||||
const html = wrapper.html();
|
||||
// 表格行的平台字段:font-mono 渲染纯英文 platform key
|
||||
expect(html).toContain("anthropic");
|
||||
expect(html).toContain("openai");
|
||||
expect(html).toContain("gemini");
|
||||
expect(html).toContain("antigravity");
|
||||
});
|
||||
|
||||
it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 4 平台)", async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
expect(updateSettings).toHaveBeenCalled();
|
||||
const lastCallArgs = updateSettings.mock.calls.at(-1);
|
||||
expect(lastCallArgs).toBeDefined();
|
||||
const payload = lastCallArgs![0] as Record<string, unknown>;
|
||||
|
||||
// 应携带嵌套对象,而非扁平字段
|
||||
expect(payload).toHaveProperty("default_platform_quotas");
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, unknown>;
|
||||
const platforms = ["anthropic", "openai", "gemini", "antigravity"];
|
||||
for (const p of platforms) {
|
||||
expect(quotas).toHaveProperty(p);
|
||||
const pq = quotas[p] as Record<string, unknown>;
|
||||
expect(pq).toHaveProperty("daily");
|
||||
expect(pq).toHaveProperty("weekly");
|
||||
expect(pq).toHaveProperty("monthly");
|
||||
}
|
||||
|
||||
// 不应存在旧扁平字段
|
||||
expect(payload).not.toHaveProperty("default_platform_quota_anthropic_daily");
|
||||
expect(payload).not.toHaveProperty("default_platform_quota_openai_weekly");
|
||||
});
|
||||
|
||||
it("加载后 form.default_platform_quotas 含全 4 平台,从嵌套 JSON 正确读取数值", async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
default_platform_quotas: {
|
||||
anthropic: { daily: 5, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: 12.5, monthly: null },
|
||||
// gemini / antigravity 缺失 → 应被归一化为全 null
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
const payload = updateSettings.mock.calls.at(-1)![0] as Record<string, unknown>;
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, Record<string, unknown>>;
|
||||
|
||||
expect(quotas["anthropic"]?.["daily"]).toBe(5);
|
||||
expect(quotas["openai"]?.["weekly"]).toBe(12.5);
|
||||
// 缺失平台应补全为 null
|
||||
expect(quotas["gemini"]).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
expect(quotas["antigravity"]).toEqual({ daily: null, weekly: null, monthly: null });
|
||||
});
|
||||
|
||||
it("空输入(v-model.number 产出 \"\")在提交时清洗为 null 而非空字符串", async () => {
|
||||
// 模拟后端返回带有 anthropic daily 值的配额
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...baseSettingsResponse,
|
||||
default_platform_quotas: {
|
||||
anthropic: { daily: 10, weekly: null, monthly: null },
|
||||
openai: { daily: null, weekly: null, monthly: null },
|
||||
gemini: { daily: null, weekly: null, monthly: null },
|
||||
antigravity: { daily: null, weekly: null, monthly: null },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
await openUsersTab(wrapper);
|
||||
|
||||
// 找到 anthropic daily 输入框并清空(模拟用户删除值)
|
||||
const inputs = wrapper.findAll('input[type="number"]');
|
||||
const anthropicDailyInput = inputs.find((i) => {
|
||||
const parent = i.element.closest("tr");
|
||||
return parent?.textContent?.includes("anthropic");
|
||||
});
|
||||
|
||||
if (anthropicDailyInput) {
|
||||
// 设置为空字符串,模拟 v-model.number 在清空时产出 ""
|
||||
await anthropicDailyInput.setValue("");
|
||||
}
|
||||
|
||||
await wrapper.find("form").trigger("submit.prevent");
|
||||
await flushPromises();
|
||||
|
||||
const payload = updateSettings.mock.calls.at(-1)![0] as Record<string, unknown>;
|
||||
const quotas = payload["default_platform_quotas"] as Record<string, Record<string, unknown>>;
|
||||
// 不管输入是什么,提交值应为 null(而非 "" 或 NaN)
|
||||
expect(quotas["anthropic"]?.["daily"]).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user