mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #2766 from DaydreamCoding/feat/user-platform-quota
feat(quota): 用户 × 平台 USD 配额
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user