Merge pull request #2766 from DaydreamCoding/feat/user-platform-quota

feat(quota): 用户 × 平台 USD 配额
This commit is contained in:
Wesley Liddick
2026-05-26 14:13:18 +08:00
committed by GitHub
123 changed files with 14220 additions and 232 deletions
@@ -3,9 +3,20 @@ import { describe, expect, it } from "vitest";
import {
appendAuthSourceDefaultsToUpdateRequest,
buildAuthSourceDefaultsState,
normalizePlatformQuotasMap,
sanitizePlatformQuotasMap,
type UpdateSettingsRequest,
type DefaultPlatformQuotasMap,
} from "@/api/admin/settings";
/** 全 null 的 4 平台 map,用于断言归一化默认值 */
const allNullQuotas: DefaultPlatformQuotasMap = {
anthropic: { daily: null, 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 },
}
describe("admin settings auth source defaults helpers", () => {
it("builds auth source defaults state from flat settings fields", () => {
const state = buildAuthSourceDefaultsState({
@@ -31,6 +42,7 @@ describe("admin settings auth source defaults helpers", () => {
subscriptions: [{ group_id: 1, validity_days: 30 }],
grant_on_signup: false,
grant_on_first_bind: true,
platform_quotas: allNullQuotas,
});
expect(state.linuxdo).toEqual({
balance: 6,
@@ -38,6 +50,7 @@ describe("admin settings auth source defaults helpers", () => {
subscriptions: [{ group_id: 2, validity_days: 60 }],
grant_on_signup: true,
grant_on_first_bind: false,
platform_quotas: allNullQuotas,
});
expect(state.oidc).toEqual({
balance: 0,
@@ -45,6 +58,7 @@ describe("admin settings auth source defaults helpers", () => {
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: allNullQuotas,
});
expect(state.wechat).toEqual({
balance: 0,
@@ -52,6 +66,7 @@ describe("admin settings auth source defaults helpers", () => {
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: allNullQuotas,
});
});
@@ -64,6 +79,23 @@ describe("admin settings auth source defaults helpers", () => {
expect(state.wechat.grant_on_signup).toBe(false);
});
it("reads nested platform_quotas from settings into auth source state", () => {
const state = buildAuthSourceDefaultsState({
auth_source_default_email_platform_quotas: {
anthropic: { daily: 10, weekly: 50, monthly: 200 },
openai: { daily: null, weekly: null, monthly: null },
} as DefaultPlatformQuotasMap,
});
// anthropic 填写的值应被保留
expect(state.email.platform_quotas.anthropic).toEqual({ daily: 10, weekly: 50, monthly: 200 });
// openai 全 null 应被保留
expect(state.email.platform_quotas.openai).toEqual({ daily: null, weekly: null, monthly: null });
// 未出现的平台(gemini/antigravity)归一化为 null
expect(state.email.platform_quotas.gemini).toEqual({ daily: null, weekly: null, monthly: null });
expect(state.email.platform_quotas.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
});
it("appends auth source defaults back onto update payload", () => {
const payload: UpdateSettingsRequest = {
site_name: "Sub2API",
@@ -76,6 +108,7 @@ describe("admin settings auth source defaults helpers", () => {
subscriptions: [{ group_id: 3, validity_days: 7 }],
grant_on_signup: true,
grant_on_first_bind: false,
platform_quotas: {},
},
linuxdo: {
balance: 0,
@@ -83,6 +116,7 @@ describe("admin settings auth source defaults helpers", () => {
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: true,
platform_quotas: {},
},
oidc: {
balance: 4,
@@ -90,6 +124,7 @@ describe("admin settings auth source defaults helpers", () => {
subscriptions: [{ group_id: 9, validity_days: 90 }],
grant_on_signup: true,
grant_on_first_bind: true,
platform_quotas: {},
},
wechat: {
balance: 2,
@@ -97,6 +132,31 @@ describe("admin settings auth source defaults helpers", () => {
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {},
},
github: {
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {},
},
google: {
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {},
},
dingtalk: {
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {},
},
});
@@ -126,6 +186,111 @@ describe("admin settings auth source defaults helpers", () => {
auth_source_default_wechat_subscriptions: [],
auth_source_default_wechat_grant_on_signup: false,
auth_source_default_wechat_grant_on_first_bind: false,
// 嵌套 platform_quotas 字段
auth_source_default_email_platform_quotas: allNullQuotas,
auth_source_default_linuxdo_platform_quotas: allNullQuotas,
auth_source_default_oidc_platform_quotas: allNullQuotas,
auth_source_default_wechat_platform_quotas: allNullQuotas,
auth_source_default_github_platform_quotas: allNullQuotas,
auth_source_default_google_platform_quotas: allNullQuotas,
auth_source_default_dingtalk_platform_quotas: allNullQuotas,
});
});
it("appends sanitized nested platform_quotas with non-null values in update payload", () => {
const payload: UpdateSettingsRequest = {};
appendAuthSourceDefaultsToUpdateRequest(payload, {
email: {
balance: 0,
concurrency: 5,
subscriptions: [],
grant_on_signup: false,
grant_on_first_bind: false,
platform_quotas: {
anthropic: { daily: 10, weekly: 50, monthly: 200 },
openai: { daily: 0, weekly: null, monthly: null },
},
},
linuxdo: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
oidc: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
wechat: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
github: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
google: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
dingtalk: { balance: 0, concurrency: 5, subscriptions: [], grant_on_signup: false, grant_on_first_bind: false, platform_quotas: {} },
});
const emailQuotas = (payload as Record<string, unknown>)["auth_source_default_email_platform_quotas"] as DefaultPlatformQuotasMap;
expect(emailQuotas.anthropic).toEqual({ daily: 10, weekly: 50, monthly: 200 });
// 0 是合法值(不限额=0 与"不设"不同,保留)
expect(emailQuotas.openai?.daily).toBe(0);
// 缺失平台归一化为全 null
expect(emailQuotas.gemini).toEqual({ daily: null, weekly: null, monthly: null });
expect(emailQuotas.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
});
});
describe("normalizePlatformQuotasMap", () => {
it("填充缺失的平台为全 null 三档", () => {
const result = normalizePlatformQuotasMap({ anthropic: { daily: 5, weekly: null, monthly: null } });
expect(result.anthropic).toEqual({ daily: 5, weekly: null, monthly: null });
expect(result.openai).toEqual({ daily: null, weekly: null, monthly: null });
expect(result.gemini).toEqual({ daily: null, weekly: null, monthly: null });
expect(result.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
});
it("无参数时返回全 4 平台全 null", () => {
const result = normalizePlatformQuotasMap();
expect(Object.keys(result)).toHaveLength(4);
for (const v of Object.values(result)) {
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
}
});
it("非 number 类型的值归一化为 null", () => {
const result = normalizePlatformQuotasMap({
anthropic: { daily: "50" as unknown as number, weekly: undefined as unknown as number, monthly: null },
});
expect(result.anthropic).toEqual({ daily: null, weekly: null, monthly: null });
});
});
describe("sanitizePlatformQuotasMap", () => {
it("保留合法的正数和零值", () => {
const result = sanitizePlatformQuotasMap({
anthropic: { daily: 10.5, weekly: 0, monthly: null },
});
expect(result.anthropic?.daily).toBe(10.5);
expect(result.anthropic?.weekly).toBe(0);
expect(result.anthropic?.monthly).toBe(null);
});
it("空字符串(v-model.number 空输入)清洗为 null", () => {
const result = sanitizePlatformQuotasMap({
anthropic: { daily: "" as unknown as number, weekly: null, monthly: null },
});
expect(result.anthropic?.daily).toBe(null);
});
it("负数清洗为 null", () => {
const result = sanitizePlatformQuotasMap({
openai: { daily: -1, weekly: null, monthly: null },
});
expect(result.openai?.daily).toBe(null);
});
it("NaN/Infinity 清洗为 null", () => {
const result = sanitizePlatformQuotasMap({
gemini: { daily: NaN, weekly: Infinity, monthly: null },
});
expect(result.gemini?.daily).toBe(null);
expect(result.gemini?.weekly).toBe(null);
});
it("缺失平台填充为全 null", () => {
const result = sanitizePlatformQuotasMap({});
expect(Object.keys(result)).toHaveLength(4);
for (const v of Object.values(result)) {
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
}
});
});
+63
View File
@@ -16,6 +16,47 @@ export interface DefaultSubscriptionSetting {
validity_days: number;
}
// ── 平台限额类型 ──────────────────────────────────────────────────
export type PlatformType = "anthropic" | "openai" | "gemini" | "antigravity"
export type QuotaWindowType = "daily" | "weekly" | "monthly"
/** 单平台三档限额;null = 不限制,undefined = 未填(等价 null) */
export interface PlatformQuotaLimits {
daily: number | null
weekly: number | null
monthly: number | null
}
/** 全平台默认限额 map(key = PlatformType) */
export type DefaultPlatformQuotasMap = Partial<Record<PlatformType, PlatformQuotaLimits>>
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity"]
/** 归一化为全 4 平台 × 3 窗口(缺失填 null),供模板非空绑定 */
export function normalizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap {
const result: DefaultPlatformQuotasMap = {}
for (const p of PLATFORMS) {
const src = input?.[p]
result[p] = {
daily: typeof src?.daily === "number" ? src.daily : null,
weekly: typeof src?.weekly === "number" ? src.weekly : null,
monthly: typeof src?.monthly === "number" ? src.monthly : null,
}
}
return result
}
/** 提交前清洗:非有限数/负数/空字符串 → null(保留 0 = 显式禁用),返回全 4 平台嵌套 map */
export function sanitizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap {
const clean = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null)
const result: DefaultPlatformQuotasMap = {}
for (const p of PLATFORMS) {
const src = input?.[p]
result[p] = { daily: clean(src?.daily), weekly: clean(src?.weekly), monthly: clean(src?.monthly) }
}
return result
}
export type AuthSourceType =
| "email"
| "linuxdo"
@@ -31,6 +72,8 @@ export interface AuthSourceDefaultsValue {
subscriptions: DefaultSubscriptionSetting[];
grant_on_signup: boolean;
grant_on_first_bind: boolean;
// ★ 新增:平台限额覆盖(key = PlatformType)
platform_quotas: DefaultPlatformQuotasMap;
}
export type AuthSourceDefaultsState = Record<
@@ -193,6 +236,7 @@ export function buildAuthSourceDefaultsState(
raw[`auth_source_default_${source}_grant_on_signup`] === true,
grant_on_first_bind:
raw[`auth_source_default_${source}_grant_on_first_bind`] === true,
platform_quotas: normalizePlatformQuotasMap(raw[`auth_source_default_${source}_platform_quotas`] as DefaultPlatformQuotasMap | undefined),
};
return acc;
}, {} as AuthSourceDefaultsState);
@@ -220,6 +264,7 @@ export function appendAuthSourceDefaultsToUpdateRequest(
current.grant_on_signup;
target[`auth_source_default_${source}_grant_on_first_bind`] =
current.grant_on_first_bind;
target[`auth_source_default_${source}_platform_quotas`] = sanitizePlatformQuotasMap(current.platform_quotas)
}
return payload;
@@ -370,6 +415,15 @@ export interface SystemSettings {
auth_source_default_google_grant_on_signup?: boolean;
auth_source_default_google_grant_on_first_bind?: boolean;
force_email_on_third_party_signup?: boolean;
// ── 平台限额(嵌套 JSON,系统层 + 7 auth-source 层)────────────────────────────────
default_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_email_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_linuxdo_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_oidc_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_wechat_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_github_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_google_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_dingtalk_platform_quotas?: DefaultPlatformQuotasMap;
// OEM settings
site_name: string;
site_logo: string;
@@ -616,6 +670,15 @@ export interface UpdateSettingsRequest {
auth_source_default_google_grant_on_signup?: boolean;
auth_source_default_google_grant_on_first_bind?: boolean;
force_email_on_third_party_signup?: boolean;
// ── 平台限额(嵌套 JSON,系统层 + 7 auth-source 层)────────────────────────────────
default_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_email_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_linuxdo_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_oidc_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_wechat_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_github_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_google_platform_quotas?: DefaultPlatformQuotasMap;
auth_source_default_dingtalk_platform_quotas?: DefaultPlatformQuotasMap;
site_name?: string;
site_logo?: string;
site_subtitle?: string;
+76 -1
View File
@@ -297,6 +297,78 @@ export async function bindUserAuthIdentity(
return data
}
/**
* Platform quota types
*/
export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly'
export interface PlatformQuotaItem {
platform: PlatformQuotaPlatform
daily_limit_usd: number | null
weekly_limit_usd: number | null
monthly_limit_usd: number | null
daily_usage_usd: number
weekly_usage_usd: number
monthly_usage_usd: number
daily_window_start?: string | null
weekly_window_start?: string | null
monthly_window_start?: string | null
daily_window_resets_at?: string | null
weekly_window_resets_at?: string | null
monthly_window_resets_at?: string | null
}
export interface PlatformQuotaUpdateItem {
platform: PlatformQuotaPlatform
daily_limit_usd: number | null
weekly_limit_usd: number | null
monthly_limit_usd: number | null
}
export interface PlatformQuotasResponse {
platform_quotas: PlatformQuotaItem[]
}
/**
* Get user's platform quotas
*/
export async function getPlatformQuotas(id: number): Promise<PlatformQuotasResponse> {
const { data } = await apiClient.get<PlatformQuotasResponse>(
`/admin/users/${id}/platform-quotas`
)
return data
}
/**
* Replace user's platform quotas (全量替换)
*/
export async function updatePlatformQuotas(
id: number,
quotas: PlatformQuotaUpdateItem[]
): Promise<PlatformQuotasResponse> {
const { data } = await apiClient.put<PlatformQuotasResponse>(
`/admin/users/${id}/platform-quotas`,
{ quotas }
)
return data
}
/**
* Reset a single (platform, window) usage immediately
*/
export async function resetPlatformQuotaWindow(
id: number,
platform: PlatformQuotaPlatform,
window: PlatformQuotaWindow
): Promise<PlatformQuotasResponse> {
const { data } = await apiClient.post<PlatformQuotasResponse>(
`/admin/users/${id}/platform-quotas/reset`,
{ platform, window }
)
return data
}
export const usersAPI = {
list,
getById,
@@ -310,7 +382,10 @@ export const usersAPI = {
getUserUsageStats,
getUserBalanceHistory,
replaceGroup,
bindUserAuthIdentity
bindUserAuthIdentity,
getPlatformQuotas,
updatePlatformQuotas,
resetPlatformQuotaWindow,
}
export default usersAPI
+12 -2
View File
@@ -15,7 +15,8 @@ import type {
NotifyEmailEntry,
UserAuthProvider,
UserAffiliateDetail,
AffiliateTransferResponse
AffiliateTransferResponse,
PlatformQuotasResponse,
} from '@/types'
/**
@@ -185,6 +186,14 @@ export async function transferAffiliateQuota(): Promise<AffiliateTransferRespons
return data
}
/**
* 获取当前用户的平台限额 + 用量。
*/
export async function getMyPlatformQuotas(): Promise<PlatformQuotasResponse> {
const { data } = await apiClient.get<PlatformQuotasResponse>('/user/platform-quotas')
return data
}
export const userAPI = {
getProfile,
updateProfile,
@@ -199,7 +208,8 @@ export const userAPI = {
buildOAuthBindingStartURL,
startOAuthBinding,
getAffiliateDetail,
transferAffiliateQuota
transferAffiliateQuota,
getMyPlatformQuotas,
}
export default userAPI