- Introduced new fields for billing mode and expression in the Pricing model. - Implemented dynamic pricing breakdown component to display tiered billing details. - Updated various components to support and render tiered billing information. - Enhanced pricing calculation logic to accommodate dynamic pricing scenarios. - Added tests for new billing expression functionalities and UI components.
444 lines
15 KiB
JavaScript
444 lines
15 KiB
JavaScript
export const SOURCE_PARAM = 'param';
|
|
export const SOURCE_HEADER = 'header';
|
|
export const SOURCE_TIME = 'time';
|
|
|
|
export const MATCH_EQ = 'eq';
|
|
export const MATCH_CONTAINS = 'contains';
|
|
export const MATCH_GT = 'gt';
|
|
export const MATCH_GTE = 'gte';
|
|
export const MATCH_LT = 'lt';
|
|
export const MATCH_LTE = 'lte';
|
|
export const MATCH_EXISTS = 'exists';
|
|
export const MATCH_RANGE = 'range';
|
|
|
|
export const TIME_FUNCS = ['hour', 'minute', 'weekday', 'month', 'day'];
|
|
|
|
export const COMMON_TIMEZONES = [
|
|
{ value: 'Asia/Shanghai', label: 'UTC+8 北京 (Asia/Shanghai)' },
|
|
{ value: 'UTC', label: 'UTC' },
|
|
{ value: 'America/New_York', label: 'UTC-5 纽约 (America/New_York)' },
|
|
{ value: 'America/Los_Angeles', label: 'UTC-8 洛杉矶 (America/Los_Angeles)' },
|
|
{ value: 'America/Chicago', label: 'UTC-6 芝加哥 (America/Chicago)' },
|
|
{ value: 'Europe/London', label: 'UTC+0 伦敦 (Europe/London)' },
|
|
{ value: 'Europe/Berlin', label: 'UTC+1 柏林 (Europe/Berlin)' },
|
|
{ value: 'Asia/Tokyo', label: 'UTC+9 东京 (Asia/Tokyo)' },
|
|
{ value: 'Asia/Singapore', label: 'UTC+8 新加坡 (Asia/Singapore)' },
|
|
{ value: 'Asia/Seoul', label: 'UTC+9 首尔 (Asia/Seoul)' },
|
|
{ value: 'Australia/Sydney', label: 'UTC+10 悉尼 (Australia/Sydney)' },
|
|
];
|
|
|
|
export const NUMERIC_LITERAL_REGEX =
|
|
/^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Condition creators (no multiplier — multiplier lives on the group)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function createEmptyCondition() {
|
|
return { source: SOURCE_PARAM, path: '', mode: MATCH_EQ, value: '' };
|
|
}
|
|
|
|
export function createEmptyTimeCondition() {
|
|
return {
|
|
source: SOURCE_TIME,
|
|
timeFunc: 'hour',
|
|
timezone: 'Asia/Shanghai',
|
|
mode: MATCH_GTE,
|
|
value: '',
|
|
rangeStart: '',
|
|
rangeEnd: '',
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Group creators
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function createEmptyRuleGroup() {
|
|
return { conditions: [createEmptyCondition()], multiplier: '' };
|
|
}
|
|
|
|
export function createEmptyTimeRuleGroup() {
|
|
return { conditions: [createEmptyTimeCondition()], multiplier: '' };
|
|
}
|
|
|
|
// Kept for backward compat with old preset format
|
|
export function createEmptyRequestRule() {
|
|
return { source: SOURCE_PARAM, path: '', mode: MATCH_EQ, value: '', multiplier: '' };
|
|
}
|
|
|
|
export function createEmptyTimeRule() {
|
|
return {
|
|
source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai',
|
|
mode: MATCH_GTE, value: '', rangeStart: '', rangeEnd: '', multiplier: '',
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Match options
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function getRequestRuleMatchOptions(source, t) {
|
|
if (source === SOURCE_TIME) {
|
|
return [
|
|
{ value: MATCH_EQ, label: t('等于') },
|
|
{ value: MATCH_GTE, label: t('大于等于') },
|
|
{ value: MATCH_LT, label: t('小于') },
|
|
{ value: MATCH_RANGE, label: t('跨夜范围') },
|
|
];
|
|
}
|
|
const base = [
|
|
{ value: MATCH_EQ, label: t('等于') },
|
|
{ value: MATCH_CONTAINS, label: t('包含') },
|
|
{ value: MATCH_EXISTS, label: t('存在') },
|
|
];
|
|
if (source === SOURCE_HEADER) {
|
|
return base;
|
|
}
|
|
return [
|
|
...base,
|
|
{ value: MATCH_GT, label: t('大于') },
|
|
{ value: MATCH_GTE, label: t('大于等于') },
|
|
{ value: MATCH_LT, label: t('小于') },
|
|
{ value: MATCH_LTE, label: t('小于等于') },
|
|
];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Normalize a single condition
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function normalizeCondition(cond) {
|
|
const source = cond?.source === SOURCE_TIME
|
|
? SOURCE_TIME
|
|
: cond?.source === SOURCE_HEADER
|
|
? SOURCE_HEADER
|
|
: SOURCE_PARAM;
|
|
|
|
if (source === SOURCE_TIME) {
|
|
const timeFunc = TIME_FUNCS.includes(cond?.timeFunc) ? cond.timeFunc : 'hour';
|
|
const options = getRequestRuleMatchOptions(SOURCE_TIME, (v) => v);
|
|
const mode = options.some((item) => item.value === cond?.mode) ? cond.mode : MATCH_GTE;
|
|
return {
|
|
source: SOURCE_TIME,
|
|
timeFunc,
|
|
timezone: cond?.timezone || 'Asia/Shanghai',
|
|
mode,
|
|
value: cond?.value == null ? '' : String(cond.value),
|
|
rangeStart: cond?.rangeStart == null ? '' : String(cond.rangeStart),
|
|
rangeEnd: cond?.rangeEnd == null ? '' : String(cond.rangeEnd),
|
|
};
|
|
}
|
|
|
|
const options = getRequestRuleMatchOptions(source, (v) => v);
|
|
const mode = options.some((item) => item.value === cond?.mode) ? cond.mode : MATCH_EQ;
|
|
return {
|
|
source,
|
|
path: cond?.path || '',
|
|
mode,
|
|
value: cond?.value == null ? '' : String(cond.value),
|
|
};
|
|
}
|
|
|
|
// Legacy compat wrapper
|
|
export function normalizeRequestRule(rule) {
|
|
const base = normalizeCondition(rule);
|
|
return { ...base, multiplier: rule?.multiplier == null ? '' : String(rule.multiplier) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function splitTopLevelMultiply(expr) {
|
|
const parts = [];
|
|
let start = 0;
|
|
let depth = 0;
|
|
for (let index = 0; index < expr.length; index += 1) {
|
|
const char = expr[index];
|
|
if (char === '(') depth += 1;
|
|
if (char === ')') depth -= 1;
|
|
if (depth === 0 && expr.slice(index, index + 3) === ' * ') {
|
|
parts.push(expr.slice(start, index).trim());
|
|
start = index + 3;
|
|
index += 2;
|
|
}
|
|
}
|
|
parts.push(expr.slice(start).trim());
|
|
return parts.filter(Boolean);
|
|
}
|
|
|
|
function splitTopLevelAnd(expr) {
|
|
const parts = [];
|
|
let start = 0;
|
|
let depth = 0;
|
|
for (let i = 0; i < expr.length; i += 1) {
|
|
const c = expr[i];
|
|
if (c === '(') depth += 1;
|
|
if (c === ')') depth -= 1;
|
|
if (depth === 0 && expr.slice(i, i + 4) === ' && ') {
|
|
parts.push(expr.slice(start, i).trim());
|
|
start = i + 4;
|
|
i += 3;
|
|
}
|
|
}
|
|
parts.push(expr.slice(start).trim());
|
|
return parts.filter(Boolean);
|
|
}
|
|
|
|
function parseExprLiteral(raw) {
|
|
const text = raw.trim();
|
|
if (text === 'true' || text === 'false') return text;
|
|
if (NUMERIC_LITERAL_REGEX.test(text)) return text;
|
|
try { return JSON.parse(text); } catch { return null; }
|
|
}
|
|
|
|
function buildExprLiteral(mode, value) {
|
|
const text = String(value || '').trim();
|
|
if (mode === MATCH_CONTAINS) return JSON.stringify(text);
|
|
if (text === 'true' || text === 'false') return text;
|
|
if (NUMERIC_LITERAL_REGEX.test(text)) return text;
|
|
return JSON.stringify(text);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build a single condition expression string (no ? mult : 1 wrapper)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildTimeConditionExpr(cond) {
|
|
const normalized = normalizeCondition(cond);
|
|
const { timeFunc, timezone, mode } = normalized;
|
|
const tz = JSON.stringify(timezone);
|
|
const fn = `${timeFunc}(${tz})`;
|
|
|
|
if (mode === MATCH_RANGE) {
|
|
const s = normalized.rangeStart.trim();
|
|
const e = normalized.rangeEnd.trim();
|
|
if (!NUMERIC_LITERAL_REGEX.test(s) || !NUMERIC_LITERAL_REGEX.test(e)) return '';
|
|
return `${fn} >= ${s} || ${fn} < ${e}`;
|
|
}
|
|
const v = normalized.value.trim();
|
|
if (!NUMERIC_LITERAL_REGEX.test(v)) return '';
|
|
const opMap = { [MATCH_EQ]: '==', [MATCH_GTE]: '>=', [MATCH_LT]: '<' };
|
|
return `${fn} ${opMap[mode] || '=='} ${v}`;
|
|
}
|
|
|
|
function buildRequestConditionExpr(cond) {
|
|
if (cond?.source === SOURCE_TIME) return buildTimeConditionExpr(cond);
|
|
const normalized = normalizeCondition(cond);
|
|
const path = normalized.path.trim();
|
|
if (!path) return '';
|
|
|
|
const sourceExpr = normalized.source === SOURCE_HEADER
|
|
? `header(${JSON.stringify(path)})`
|
|
: `param(${JSON.stringify(path)})`;
|
|
|
|
switch (normalized.mode) {
|
|
case MATCH_EXISTS:
|
|
return normalized.source === SOURCE_HEADER
|
|
? `${sourceExpr} != ""`
|
|
: `${sourceExpr} != nil`;
|
|
case MATCH_CONTAINS:
|
|
return normalized.source === SOURCE_HEADER
|
|
? `has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`
|
|
: `${sourceExpr} != nil && has(${sourceExpr}, ${buildExprLiteral(normalized.mode, normalized.value)})`;
|
|
case MATCH_GT: case MATCH_GTE: case MATCH_LT: case MATCH_LTE: {
|
|
const opMap = { [MATCH_GT]: '>', [MATCH_GTE]: '>=', [MATCH_LT]: '<', [MATCH_LTE]: '<=' };
|
|
if (!NUMERIC_LITERAL_REGEX.test(String(normalized.value).trim())) return '';
|
|
return `${sourceExpr} != nil && ${sourceExpr} ${opMap[normalized.mode]} ${String(normalized.value).trim()}`;
|
|
}
|
|
case MATCH_EQ:
|
|
default:
|
|
return `${sourceExpr} == ${buildExprLiteral(normalized.mode, normalized.value)}`;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build a group factor: (cond1 && cond2 ? mult : 1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildRuleGroupFactor(group) {
|
|
const multiplier = (group.multiplier || '').trim();
|
|
if (!NUMERIC_LITERAL_REGEX.test(multiplier)) return '';
|
|
const condExprs = (group.conditions || [])
|
|
.map(buildRequestConditionExpr)
|
|
.filter(Boolean);
|
|
if (condExprs.length === 0) return '';
|
|
|
|
const combined = condExprs.length === 1
|
|
? condExprs[0]
|
|
: condExprs.map((e) => (e.includes(' || ') ? `(${e})` : e)).join(' && ');
|
|
return `(${combined} ? ${multiplier} : 1)`;
|
|
}
|
|
|
|
export function buildRequestRuleExpr(groups) {
|
|
return (groups || []).map(buildRuleGroupFactor).filter(Boolean).join(' * ');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parse a single condition from an expression fragment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function tryParseTimeCondition(expr) {
|
|
// Range: hour("tz") >= s || hour("tz") < e
|
|
let m = expr.match(
|
|
/^(hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)$/,
|
|
);
|
|
if (m) {
|
|
return {
|
|
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
|
|
mode: MATCH_RANGE, value: '', rangeStart: m[3], rangeEnd: m[4],
|
|
};
|
|
}
|
|
// Wrapped range: (hour("tz") >= s || hour("tz") < e)
|
|
m = expr.match(
|
|
/^\((hour|minute|weekday|month|day)\("([^"]+)"\) >= ([\d.eE+-]+) \|\| \1\("\2"\) < ([\d.eE+-]+)\)$/,
|
|
);
|
|
if (m) {
|
|
return {
|
|
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
|
|
mode: MATCH_RANGE, value: '', rangeStart: m[3], rangeEnd: m[4],
|
|
};
|
|
}
|
|
// Simple: hour("tz") op value
|
|
m = expr.match(
|
|
/^(hour|minute|weekday|month|day)\("([^"]+)"\) (==|>=|<) ([\d.eE+-]+)$/,
|
|
);
|
|
if (m) {
|
|
const opMap = { '==': MATCH_EQ, '>=': MATCH_GTE, '<': MATCH_LT };
|
|
return {
|
|
source: SOURCE_TIME, timeFunc: m[1], timezone: m[2],
|
|
mode: opMap[m[3]] || MATCH_EQ, value: m[4], rangeStart: '', rangeEnd: '',
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function tryParseRequestCondition(expr) {
|
|
const tc = tryParseTimeCondition(expr);
|
|
if (tc) return tc;
|
|
|
|
let m = expr.match(/^header\("([^"]+)"\) != ""$/);
|
|
if (m) return { source: SOURCE_HEADER, path: m[1], mode: MATCH_EXISTS, value: '' };
|
|
|
|
m = expr.match(/^param\("([^"]+)"\) != nil$/);
|
|
if (m) return { source: SOURCE_PARAM, path: m[1], mode: MATCH_EXISTS, value: '' };
|
|
|
|
m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/);
|
|
if (m) return { source: SOURCE_HEADER, path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[2]) };
|
|
|
|
m = expr.match(/^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/);
|
|
if (m && m[1] === m[2]) return { source: SOURCE_PARAM, path: m[1], mode: MATCH_CONTAINS, value: JSON.parse(m[3]) };
|
|
|
|
m = expr.match(/^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/);
|
|
if (m && m[1] === m[2]) {
|
|
const opMap = { '>': MATCH_GT, '>=': MATCH_GTE, '<': MATCH_LT, '<=': MATCH_LTE };
|
|
return { source: SOURCE_PARAM, path: m[1], mode: opMap[m[3]], value: m[4] };
|
|
}
|
|
|
|
m = expr.match(/^(param|header)\("([^"]+)"\) == (.+)$/);
|
|
if (m) {
|
|
const parsedValue = parseExprLiteral(m[3]);
|
|
if (parsedValue === null) return null;
|
|
return { source: m[1], path: m[2], mode: MATCH_EQ, value: String(parsedValue) };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parse a group factor: (cond1 && cond2 ? mult : 1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function tryParseRuleGroupFactor(part) {
|
|
// Must be wrapped in ( ... ? mult : 1)
|
|
const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s);
|
|
if (!m) return null;
|
|
|
|
const conditionStr = m[1];
|
|
const multiplier = m[2];
|
|
|
|
const andParts = splitTopLevelAnd(conditionStr);
|
|
const conditions = [];
|
|
for (const ap of andParts) {
|
|
const cond = tryParseRequestCondition(ap.trim());
|
|
if (!cond) return null;
|
|
conditions.push(normalizeCondition(cond));
|
|
}
|
|
if (conditions.length === 0) return null;
|
|
return { conditions, multiplier };
|
|
}
|
|
|
|
export function tryParseRequestRuleExpr(expr) {
|
|
const trimmed = (expr || '').trim();
|
|
if (!trimmed) return [];
|
|
|
|
const parts = splitTopLevelMultiply(trimmed);
|
|
const groups = [];
|
|
for (const part of parts) {
|
|
const group = tryParseRuleGroupFactor(part);
|
|
if (!group) return null;
|
|
groups.push(group);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Combine / split billing expr and request rules
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function hasFullOuterParens(expr) {
|
|
if (!expr.startsWith('(') || !expr.endsWith(')')) return false;
|
|
let depth = 0;
|
|
for (let i = 0; i < expr.length; i += 1) {
|
|
if (expr[i] === '(') depth += 1;
|
|
if (expr[i] === ')') depth -= 1;
|
|
if (depth === 0 && i < expr.length - 1) return false;
|
|
}
|
|
return depth === 0;
|
|
}
|
|
|
|
export function unwrapOuterParens(expr) {
|
|
let current = (expr || '').trim();
|
|
while (hasFullOuterParens(current)) {
|
|
current = current.slice(1, -1).trim();
|
|
}
|
|
return current;
|
|
}
|
|
|
|
export function combineBillingExpr(baseExpr, requestRuleExpr) {
|
|
const base = (baseExpr || '').trim();
|
|
const rules = (requestRuleExpr || '').trim();
|
|
if (!base) return '';
|
|
if (!rules) return base;
|
|
return `(${base}) * ${rules}`;
|
|
}
|
|
|
|
export function splitBillingExprAndRequestRules(expr) {
|
|
const trimmed = (expr || '').trim();
|
|
if (!trimmed) return { billingExpr: '', requestRuleExpr: '' };
|
|
|
|
const parts = splitTopLevelMultiply(trimmed);
|
|
if (parts.length <= 1) return { billingExpr: trimmed, requestRuleExpr: '' };
|
|
|
|
const ruleParts = [];
|
|
const baseParts = [];
|
|
|
|
parts.forEach((part) => {
|
|
if (tryParseRequestRuleExpr(part) !== null && tryParseRequestRuleExpr(part).length > 0) {
|
|
ruleParts.push(part);
|
|
} else {
|
|
baseParts.push(part);
|
|
}
|
|
});
|
|
|
|
if (ruleParts.length === 0 || baseParts.length !== 1) {
|
|
return { billingExpr: trimmed, requestRuleExpr: '' };
|
|
}
|
|
|
|
return {
|
|
billingExpr: unwrapOuterParens(baseParts[0]),
|
|
requestRuleExpr: ruleParts.join(' * '),
|
|
};
|
|
}
|