mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 00:21:34 +00:00
feat(scheduler): per-group profit control for token account admission
Group pricing (rate multiplier, peak windows, per-user overrides) and account cost (accounts.rate_multiplier) already live side by side, but nothing stops the scheduler from handing a request to an account whose cost multiplier exceeds what the group's pricing can profitably serve. Add an opt-in per-group profit gate that filters scheduling candidates by a margin rule, while ordering, scoring, stickiness and breakers keep working unchanged among qualified accounts. Admission rule: an account qualifies iff U <= D * (1 - min_margin - safety_buffer) within a small relative epsilon, where U is accounts.rate_multiplier (0 is legal; missing/negative/NaN/Inf are conservatively rejected as invalid) and D is the requester's effective downstream multiplier (user-group override ?? group default, times the group peak factor) frozen at the request's pricing instant. - groups gain profit_control_enabled / profit_min_margin / profit_safety_buffer (migration 191); the durable auth-cache invalidation trigger additionally watches the profit and pricing columns (migration 192) so out-of-band group edits cannot leave stale auth snapshots; GetByKeyForAuth explicitly projects the new columns and the API-key auth snapshot version is bumped to force a refresh of pre-existing snapshots - request-level pricing instant: token entry points install pricingAt into ctx; the profit threshold D and the RecordUsage peak factor read the same instant, so one request never changes price mid-flight across waits/retries/failover (media and unwired paths keep the existing record-time semantics) - the gate covers token requests on openai, anthropic, gemini, grok and antigravity groups: OpenAI-family handlers via WithOpenAIRequestPricingContext (responses incl. WS bridge, chat completions, messages, embeddings, alpha search), the shared gateway via WithGatewayTokenRequestPricing (messages, chat completions, responses, gemini model actions); composite groups cannot enable it directly; image/video/models/usage/count_tokens stay ungated and an explicit image-generation intent suppresses the gate end to end - post-slot recheck: after a slot is acquired the account is re-read via SchedulerSnapshotService.GetAccount (scheduler cache, then DB; only when both fail the check fails open with WARN + metric); a vetoed account releases its slot and joins the request's exclusion set for reselection; sticky bindings are written only after the final check passes, and an over-threshold sticky account is skipped, not deleted, so it comes back once its rate recovers - sticky-session cache contract: GatewayCache.GetSessionAccountID now returns ErrStickySessionNotFound on a miss (mapped from redis.Nil in the repository implementation, mirroring ErrRefreshTokenNotFound) so the profit sticky path can distinguish "no binding yet" from a real read failure without importing the cache driver in service code - cross-group re-entry (composite parent -> member group) resolves the gate against the member group and clears a stale parent gate instead of letting a foreign threshold veto accounts - per-platform/group activity counters (installs, threshold vetoes, invalid-rate vetoes, refresh failures) for observability - admin UI: profit-control section on the five platforms' group forms with percent input, validation and platform-switch reset; group create/update/duplicate normalize and validate the config at a single choke point - cmd/profit-preview: offline what-if tool that replays the production admission semantics over an exported config/account/override/model dump, reports per-model admitted-account counts under the default and the worst-case (lowest user override) D, and surfaces probe-sync staleness as warnings without affecting admission Tests: service unit coverage for gate resolution/veto/threshold epsilon/pricing instant/suppress marker/scheduler filtering and post-slot recheck (incl. -race on the profit surface), unit-tagged handler slot-recheck and capability-mapping regressions, sqlmock and real-PostgreSQL integration regressions for the GetByKeyForAuth projection and the migration-192 trigger watch list, API contract update, and frontend specs for the five-platform form helpers.
This commit is contained in:
@@ -1006,6 +1006,18 @@ export default {
|
||||
peakMultiplier: 'Peak multiplier',
|
||||
multiplierHint: 'Applies to token billing multiplier; image tokens in token billing are also affected. 0 means peak token requests are billed at 0x.'
|
||||
},
|
||||
profitControl: {
|
||||
enable: 'Enable profit control',
|
||||
enabledHint: 'Scheduling only admits accounts whose account multiplier ≤ the request\'s effective downstream multiplier × (1 − min margin − safety buffer). Account multipliers may be maintained manually or synchronized from probes; existing ordering, stickiness and breakers keep working among qualified accounts. Image/video scheduling is not covered yet.',
|
||||
disabledHint: 'When disabled, scheduling does no profit filtering: accounts whose account multiplier exceeds the downstream multiplier can still be selected, which may produce loss-making requests.',
|
||||
minMargin: 'Min gross margin (%)',
|
||||
minMarginHint: 'Percent input, e.g. 30 means 30%; stored as a decimal on the backend',
|
||||
safetyBuffer: 'Safety buffer (%)',
|
||||
safetyBufferHint: 'Added to min margin and deducted from the downstream multiplier; defaults to 0',
|
||||
marginRangeError: 'Min gross margin must be between 0 and 100 (exclusive)',
|
||||
bufferRangeError: 'Safety buffer must be between 0 and 100 (exclusive)',
|
||||
sumTooHigh: 'Min gross margin plus safety buffer must be less than 100%, otherwise every account would be excluded'
|
||||
},
|
||||
modelsList: {
|
||||
title: 'Custom /v1/models Model List',
|
||||
hint: 'Only changes the /v1/models response. Whitelist model calls and account routing are unchanged.',
|
||||
|
||||
@@ -1003,6 +1003,18 @@ export default {
|
||||
peakMultiplier: '高峰倍率',
|
||||
multiplierHint: '作用于 token 计费倍率;token 计费的图片 token 同样适用,0 表示高峰 token 请求按 0 倍计费'
|
||||
},
|
||||
profitControl: {
|
||||
enable: '启用利润控制',
|
||||
enabledHint: '调度时仅允许"账号倍率 ≤ 请求实际下游倍率 ×(1 − 最低毛利率 − 安全缓冲)"的账号进入候选池;账号倍率可手工维护或由探测同步,既有排序、粘性与熔断在合格账号间照常工作。图片/视频调度暂不参与。',
|
||||
disabledHint: '关闭后调度不做利润过滤,账号倍率高于下游倍率的账号也会被选中,可能产生亏损请求。',
|
||||
minMargin: '最低毛利率(%)',
|
||||
minMarginHint: '百分比输入,如 30 表示 30%;后端按小数存储',
|
||||
safetyBuffer: '安全缓冲(%)',
|
||||
safetyBufferHint: '与最低毛利率相加后从下游倍率中扣除,默认 0',
|
||||
marginRangeError: '最低毛利率应在 0 到 100 之间(不含 100)',
|
||||
bufferRangeError: '安全缓冲应在 0 到 100 之间(不含 100)',
|
||||
sumTooHigh: '最低毛利率与安全缓冲之和必须小于 100%,否则将排除全部账号'
|
||||
},
|
||||
modelsList: {
|
||||
title: '自定义 /v1/models 模型列表',
|
||||
hint: '仅影响 /v1/models 展示结果,不影响白名单模型调用和账号调度。',
|
||||
|
||||
@@ -549,6 +549,10 @@ export interface Group {
|
||||
peak_start: string
|
||||
peak_end: string
|
||||
peak_rate_multiplier: number
|
||||
// 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)
|
||||
profit_control_enabled: boolean
|
||||
profit_min_margin: number
|
||||
profit_safety_buffer: number
|
||||
// Claude Code 客户端限制
|
||||
claude_code_only: boolean
|
||||
fallback_group_id: number | null
|
||||
@@ -741,6 +745,10 @@ export interface CreateGroupRequest {
|
||||
peak_start?: string
|
||||
peak_end?: string
|
||||
peak_rate_multiplier?: number
|
||||
// 分组利润控制(五个 token 平台;margin/buffer 为小数)
|
||||
profit_control_enabled?: boolean
|
||||
profit_min_margin?: number
|
||||
profit_safety_buffer?: number
|
||||
claude_code_only?: boolean
|
||||
fallback_group_id?: number | null
|
||||
fallback_group_id_on_invalid_request?: number | null
|
||||
@@ -792,6 +800,10 @@ export interface UpdateGroupRequest {
|
||||
peak_start?: string
|
||||
peak_end?: string
|
||||
peak_rate_multiplier?: number
|
||||
// 分组利润控制(五个 token 平台;margin/buffer 为小数)
|
||||
profit_control_enabled?: boolean
|
||||
profit_min_margin?: number
|
||||
profit_safety_buffer?: number
|
||||
claude_code_only?: boolean
|
||||
fallback_group_id?: number | null
|
||||
fallback_group_id_on_invalid_request?: number | null
|
||||
|
||||
@@ -1152,6 +1152,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组利润控制(五个平台 token 请求) -->
|
||||
<div v-if="isProfitControlPlatform(createForm.platform)" class="border-t pt-4">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
v-model="createForm.profit_control_enabled"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>{{ t("admin.groups.profitControl.enable") }}</span>
|
||||
</label>
|
||||
<p class="mb-3 mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
createForm.profit_control_enabled
|
||||
? t("admin.groups.profitControl.enabledHint")
|
||||
: t("admin.groups.profitControl.disabledHint")
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
v-if="createForm.profit_control_enabled"
|
||||
class="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2"
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.profitControl.minMargin") }}</label>
|
||||
<input
|
||||
v-model.number="createForm.profit_min_margin_percent"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="99.99"
|
||||
class="input"
|
||||
placeholder="0"
|
||||
:title="t('admin.groups.profitControl.minMarginHint')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.profitControl.safetyBuffer") }}</label>
|
||||
<input
|
||||
v-model.number="createForm.profit_safety_buffer_percent"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="99.99"
|
||||
class="input"
|
||||
placeholder="0"
|
||||
:title="t('admin.groups.profitControl.safetyBufferHint')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支持的模型系列(仅 antigravity 平台) -->
|
||||
<div v-if="createForm.platform === 'antigravity'" class="border-t pt-4">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
@@ -2707,6 +2757,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组利润控制(五个平台 token 请求) -->
|
||||
<div v-if="isProfitControlPlatform(editForm.platform)" class="border-t pt-4">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
v-model="editForm.profit_control_enabled"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>{{ t("admin.groups.profitControl.enable") }}</span>
|
||||
</label>
|
||||
<p class="mb-3 mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
editForm.profit_control_enabled
|
||||
? t("admin.groups.profitControl.enabledHint")
|
||||
: t("admin.groups.profitControl.disabledHint")
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
v-if="editForm.profit_control_enabled"
|
||||
class="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2"
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.profitControl.minMargin") }}</label>
|
||||
<input
|
||||
v-model.number="editForm.profit_min_margin_percent"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="99.99"
|
||||
class="input"
|
||||
placeholder="0"
|
||||
:title="t('admin.groups.profitControl.minMarginHint')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.profitControl.safetyBuffer") }}</label>
|
||||
<input
|
||||
v-model.number="editForm.profit_safety_buffer_percent"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="99.99"
|
||||
class="input"
|
||||
placeholder="0"
|
||||
:title="t('admin.groups.profitControl.safetyBufferHint')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支持的模型系列(仅 antigravity 平台) -->
|
||||
<div v-if="editForm.platform === 'antigravity'" class="border-t pt-4">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
@@ -4095,6 +4195,13 @@ import {
|
||||
} from "./groupsModelsList";
|
||||
import { createModelsListCandidatesTracker } from "./groupsModelsListCandidates";
|
||||
import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes";
|
||||
import {
|
||||
isProfitControlPlatform,
|
||||
profitPercentToDecimal,
|
||||
profitDecimalToPercent,
|
||||
validateProfitControlFormState,
|
||||
type ProfitControlFormState,
|
||||
} from "./groupsProfitControl";
|
||||
import {
|
||||
normalizeReasoningEffortForPlatform,
|
||||
reasoningEffortMappingsToAPI,
|
||||
@@ -4618,6 +4725,10 @@ const createForm = reactive({
|
||||
peak_start: "",
|
||||
peak_end: "",
|
||||
peak_rate_multiplier: 1.0,
|
||||
// 分组利润控制(五个 token 平台);界面按百分比输入,提交时转小数
|
||||
profit_control_enabled: false,
|
||||
profit_min_margin_percent: 0,
|
||||
profit_safety_buffer_percent: 0,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
@@ -4968,6 +5079,10 @@ const editForm = reactive({
|
||||
peak_start: "",
|
||||
peak_end: "",
|
||||
peak_rate_multiplier: 1.0,
|
||||
// 分组利润控制(五个 token 平台);界面按百分比输入,提交时转小数
|
||||
profit_control_enabled: false,
|
||||
profit_min_margin_percent: 0,
|
||||
profit_safety_buffer_percent: 0,
|
||||
// Claude Code 客户端限制(仅 anthropic 平台使用)
|
||||
claude_code_only: false,
|
||||
fallback_group_id: null as number | null,
|
||||
@@ -5412,6 +5527,9 @@ const closeCreateModal = () => {
|
||||
createForm.peak_start = "";
|
||||
createForm.peak_end = "";
|
||||
createForm.peak_rate_multiplier = 1.0;
|
||||
createForm.profit_control_enabled = false;
|
||||
createForm.profit_min_margin_percent = 0;
|
||||
createForm.profit_safety_buffer_percent = 0;
|
||||
createForm.claude_code_only = false;
|
||||
createForm.fallback_group_id = null;
|
||||
createForm.fallback_group_id_on_invalid_request = null;
|
||||
@@ -5459,6 +5577,19 @@ const normalizeRateMultiplier = (
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 1;
|
||||
};
|
||||
|
||||
// 利润控制表单辅助(换算与校验逻辑见 groupsProfitControl.ts,便于单测)。
|
||||
const percentToDecimal = profitPercentToDecimal;
|
||||
const decimalToPercent = profitDecimalToPercent;
|
||||
|
||||
const validateProfitControlForm = (form: ProfitControlFormState): boolean => {
|
||||
const errorKey = validateProfitControlFormState(form);
|
||||
if (errorKey) {
|
||||
appStore.showError(t(`admin.groups.profitControl.${errorKey}`));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
if (!createForm.name.trim()) {
|
||||
appStore.showError(t("admin.groups.nameRequired"));
|
||||
@@ -5471,6 +5602,9 @@ const handleCreateGroup = async () => {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!validateProfitControlForm(createForm)) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 构建请求数据,包含模型路由配置
|
||||
@@ -5506,7 +5640,17 @@ const handleCreateGroup = async () => {
|
||||
reasoning_effort_mappings: reasoningEffortMappingsToAPI(
|
||||
createForm.reasoning_effort_mappings,
|
||||
),
|
||||
// 利润控制:界面百分比转小数提交;仅五个 token 平台可启用
|
||||
profit_control_enabled:
|
||||
isProfitControlPlatform(createForm.platform) &&
|
||||
createForm.profit_control_enabled,
|
||||
profit_min_margin: percentToDecimal(createForm.profit_min_margin_percent),
|
||||
profit_safety_buffer: percentToDecimal(
|
||||
createForm.profit_safety_buffer_percent,
|
||||
),
|
||||
};
|
||||
delete (requestData as Record<string, unknown>).profit_min_margin_percent;
|
||||
delete (requestData as Record<string, unknown>).profit_safety_buffer_percent;
|
||||
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
|
||||
const emptyToNull = (v: any) => (v === "" ? null : v);
|
||||
requestData.daily_limit_usd = emptyToNull(requestData.daily_limit_usd);
|
||||
@@ -5594,6 +5738,13 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
editForm.peak_start = group.peak_start ?? "";
|
||||
editForm.peak_end = group.peak_end ?? "";
|
||||
editForm.peak_rate_multiplier = group.peak_rate_multiplier ?? 1.0;
|
||||
editForm.profit_control_enabled = group.profit_control_enabled ?? false;
|
||||
editForm.profit_min_margin_percent = decimalToPercent(
|
||||
group.profit_min_margin ?? 0,
|
||||
);
|
||||
editForm.profit_safety_buffer_percent = decimalToPercent(
|
||||
group.profit_safety_buffer ?? 0,
|
||||
);
|
||||
editForm.claude_code_only = group.claude_code_only || false;
|
||||
editForm.fallback_group_id = group.fallback_group_id;
|
||||
editForm.fallback_group_id_on_invalid_request =
|
||||
@@ -5654,6 +5805,9 @@ const closeEditModal = () => {
|
||||
editForm.peak_start = "";
|
||||
editForm.peak_end = "";
|
||||
editForm.peak_rate_multiplier = 1.0;
|
||||
editForm.profit_control_enabled = false;
|
||||
editForm.profit_min_margin_percent = 0;
|
||||
editForm.profit_safety_buffer_percent = 0;
|
||||
editForm.video_rate_independent = false;
|
||||
editForm.video_rate_multiplier = 1;
|
||||
editForm.video_price_480p = null;
|
||||
@@ -5678,6 +5832,9 @@ const handleUpdateGroup = async () => {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!validateProfitControlForm(editForm)) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
@@ -5720,7 +5877,17 @@ const handleUpdateGroup = async () => {
|
||||
reasoning_effort_mappings: reasoningEffortMappingsToAPI(
|
||||
editForm.reasoning_effort_mappings,
|
||||
),
|
||||
// 利润控制:界面百分比转小数提交;仅五个 token 平台可启用
|
||||
profit_control_enabled:
|
||||
isProfitControlPlatform(editForm.platform) &&
|
||||
editForm.profit_control_enabled,
|
||||
profit_min_margin: percentToDecimal(editForm.profit_min_margin_percent),
|
||||
profit_safety_buffer: percentToDecimal(
|
||||
editForm.profit_safety_buffer_percent,
|
||||
),
|
||||
};
|
||||
delete (payload as Record<string, unknown>).profit_min_margin_percent;
|
||||
delete (payload as Record<string, unknown>).profit_safety_buffer_percent;
|
||||
// v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制
|
||||
const emptyToNull = (v: any) => (v === "" ? null : v);
|
||||
payload.daily_limit_usd = emptyToNull(payload.daily_limit_usd);
|
||||
@@ -6068,6 +6235,11 @@ watch(
|
||||
resetMessagesDispatchFormState(createForm);
|
||||
createForm.allow_live = false;
|
||||
}
|
||||
if (!isProfitControlPlatform(newVal)) {
|
||||
createForm.profit_control_enabled = false;
|
||||
createForm.profit_min_margin_percent = 0;
|
||||
createForm.profit_safety_buffer_percent = 0;
|
||||
}
|
||||
createForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
|
||||
newVal,
|
||||
createForm.max_reasoning_effort,
|
||||
@@ -6111,6 +6283,11 @@ watch(
|
||||
resetMessagesDispatchFormState(editForm);
|
||||
editForm.allow_live = false;
|
||||
}
|
||||
if (!isProfitControlPlatform(newVal)) {
|
||||
editForm.profit_control_enabled = false;
|
||||
editForm.profit_min_margin_percent = 0;
|
||||
editForm.profit_safety_buffer_percent = 0;
|
||||
}
|
||||
editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform(
|
||||
newVal,
|
||||
editForm.max_reasoning_effort,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
profitDecimalToPercent,
|
||||
profitPercentToDecimal,
|
||||
validateProfitControlFormState,
|
||||
type ProfitControlFormState,
|
||||
} from "../groupsProfitControl";
|
||||
|
||||
const formState = (
|
||||
overrides: Partial<ProfitControlFormState> = {},
|
||||
): ProfitControlFormState => ({
|
||||
platform: "openai",
|
||||
profit_control_enabled: true,
|
||||
profit_min_margin_percent: 30,
|
||||
profit_safety_buffer_percent: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("profitPercentToDecimal", () => {
|
||||
it("converts percent input to backend decimal", () => {
|
||||
expect(profitPercentToDecimal(30)).toBe(0.3);
|
||||
expect(profitPercentToDecimal(5)).toBe(0.05);
|
||||
expect(profitPercentToDecimal(33.33)).toBe(0.3333);
|
||||
expect(profitPercentToDecimal(99.99)).toBe(0.9999);
|
||||
});
|
||||
|
||||
it("rounds to four decimal places matching decimal(10,4) storage", () => {
|
||||
expect(profitPercentToDecimal(33.333)).toBe(0.3333);
|
||||
expect(profitPercentToDecimal(0.005)).toBe(0.0001);
|
||||
});
|
||||
|
||||
it("treats empty, invalid and non-positive input as zero", () => {
|
||||
expect(profitPercentToDecimal("")).toBe(0);
|
||||
expect(profitPercentToDecimal(null)).toBe(0);
|
||||
expect(profitPercentToDecimal(undefined)).toBe(0);
|
||||
expect(profitPercentToDecimal("abc")).toBe(0);
|
||||
expect(profitPercentToDecimal(-5)).toBe(0);
|
||||
expect(profitPercentToDecimal(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("profitDecimalToPercent", () => {
|
||||
it("converts backend decimal to percent without float tail noise", () => {
|
||||
expect(profitDecimalToPercent(0.3)).toBe(30);
|
||||
expect(profitDecimalToPercent(0.05)).toBe(5);
|
||||
expect(profitDecimalToPercent(0.3333)).toBe(33.33);
|
||||
expect(profitDecimalToPercent(0.9999)).toBe(99.99);
|
||||
});
|
||||
|
||||
it("treats missing and non-positive values as zero", () => {
|
||||
expect(profitDecimalToPercent(null)).toBe(0);
|
||||
expect(profitDecimalToPercent(undefined)).toBe(0);
|
||||
expect(profitDecimalToPercent(0)).toBe(0);
|
||||
expect(profitDecimalToPercent(-0.3)).toBe(0);
|
||||
});
|
||||
|
||||
it("round-trips representative storage values", () => {
|
||||
for (const decimal of [0.05, 0.1, 0.3, 0.3333, 0.5, 0.75, 0.9999]) {
|
||||
expect(profitPercentToDecimal(profitDecimalToPercent(decimal))).toBe(
|
||||
decimal,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateProfitControlFormState", () => {
|
||||
it("passes valid enabled configurations", () => {
|
||||
expect(validateProfitControlFormState(formState())).toBeNull();
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({
|
||||
profit_min_margin_percent: 0,
|
||||
profit_safety_buffer_percent: 0,
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({
|
||||
profit_min_margin_percent: 60,
|
||||
profit_safety_buffer_percent: 39.99,
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("validates all five supported platforms and skips unsupported platforms", () => {
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({ profit_control_enabled: false, profit_min_margin_percent: 200 }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({ platform: "anthropic", profit_min_margin_percent: 200 }),
|
||||
),
|
||||
).toBe("marginRangeError");
|
||||
for (const platform of ["openai", "anthropic", "gemini", "grok", "antigravity"]) {
|
||||
expect(validateProfitControlFormState(formState({ platform }))).toBeNull();
|
||||
}
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({ platform: "composite", profit_min_margin_percent: 200 }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("treats empty inputs as zero", () => {
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({
|
||||
profit_min_margin_percent: "",
|
||||
profit_safety_buffer_percent: null,
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects out-of-range margin and buffer", () => {
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({ profit_min_margin_percent: 100 }),
|
||||
),
|
||||
).toBe("marginRangeError");
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({ profit_min_margin_percent: -1 }),
|
||||
),
|
||||
).toBe("marginRangeError");
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({ profit_safety_buffer_percent: 100 }),
|
||||
),
|
||||
).toBe("bufferRangeError");
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({ profit_safety_buffer_percent: -0.1 }),
|
||||
),
|
||||
).toBe("bufferRangeError");
|
||||
});
|
||||
|
||||
it("rejects margin plus buffer reaching 100 percent", () => {
|
||||
expect(
|
||||
validateProfitControlFormState(
|
||||
formState({
|
||||
profit_min_margin_percent: 60,
|
||||
profit_safety_buffer_percent: 40,
|
||||
}),
|
||||
),
|
||||
).toBe("sumTooHigh");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// 分组利润控制表单辅助:百分比 <-> 小数换算与提交前校验。
|
||||
// 后端按小数存 decimal(10,4)(0.30 = 30%),界面按百分比输入展示;
|
||||
// 固定 4 位小数精度,避免 0.3 * 100 = 30.000000000000004 之类的浮点尾数回显。
|
||||
|
||||
export const profitPercentToDecimal = (
|
||||
value: number | string | null | undefined,
|
||||
): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(parsed * 100) / 10000;
|
||||
};
|
||||
|
||||
export const profitDecimalToPercent = (
|
||||
value: number | null | undefined,
|
||||
): number => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(parsed * 1e6) / 1e4;
|
||||
};
|
||||
|
||||
export type ProfitControlFormState = {
|
||||
platform: string;
|
||||
profit_control_enabled: boolean;
|
||||
profit_min_margin_percent: number | string | null;
|
||||
profit_safety_buffer_percent: number | string | null;
|
||||
};
|
||||
|
||||
export const isProfitControlPlatform = (platform: string): boolean =>
|
||||
["openai", "anthropic", "gemini", "grok", "antigravity"].includes(platform);
|
||||
|
||||
// 提交前校验:margin/buffer 各自 ∈ [0,100),且相加 < 100(否则阈值 <= 0,
|
||||
// 所有可核价账号都会被排除)。返回 null 表示通过,否则返回错误信息的 i18n key
|
||||
//(相对 admin.groups.profitControl 前缀)。仅支持平台且开关开启时才校验。
|
||||
export const validateProfitControlFormState = (
|
||||
form: ProfitControlFormState,
|
||||
): string | null => {
|
||||
if (!isProfitControlPlatform(form.platform) || !form.profit_control_enabled) {
|
||||
return null;
|
||||
}
|
||||
const margin = Number(form.profit_min_margin_percent || 0);
|
||||
const buffer = Number(form.profit_safety_buffer_percent || 0);
|
||||
if (!Number.isFinite(margin) || margin < 0 || margin >= 100) {
|
||||
return "marginRangeError";
|
||||
}
|
||||
if (!Number.isFinite(buffer) || buffer < 0 || buffer >= 100) {
|
||||
return "bufferRangeError";
|
||||
}
|
||||
if (margin + buffer >= 100) {
|
||||
return "sumTooHigh";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
Reference in New Issue
Block a user