Files
omniai-server/src/enterpriseVideoBilling.js

302 lines
9.2 KiB
JavaScript

"use strict";
const ENTERPRISE_VIDEO_ALLOWED_MODELS = new Set([
"happyhorse-1.0",
"happyhorse-1.0-t2v",
"happyhorse-1.0-i2v",
"happyhorse-1.0-r2v",
"wan2.7-i2v",
"wan2.2-animate-mix",
"wan2.2-s2v",
"kling-3.0-dashscope",
"kling-v3-omni-dashscope",
"kling/kling-v3-omni-video-generation",
"vidu-q3-turbo",
"vidu-q3-turbo-t2v",
"vidu-q3-turbo-i2v",
"pixverse-c1",
"pixverse-c1-t2v",
"pixverse-c1-i2v",
]);
const CREDITS_PER_CNY = 100;
const CREDIT_UNITS_PER_CREDIT = 100;
const ENTERPRISE_VIDEO_RESOLUTIONS = ["720P", "1080P"];
const ENTERPRISE_VIDEO_DEFAULT_RESOLUTION = "1080P";
const ENTERPRISE_VIDEO_PRICING_RULES = [
{
id: "happyhorse",
modelIncludes: ["happyhorse"],
rates: { "720P": 0.72, "1080P": 1.28 },
},
{
id: "wanxiang-i2v",
modelIncludes: ["wan2.7-i2v", "wanxiang"],
rates: { "720P": 0.6, "1080P": 1 },
},
{
id: "wan-animate-s2v",
modelIncludes: ["animate-mix", "s2v"],
rates: { "720P": 0.6, "1080P": 1 },
},
{
id: "kling-muted-reference",
modelIncludes: ["kling"],
when: { muted: true, hasReferenceVideo: true },
rates: { "720P": 0.9, "1080P": 1.2 },
},
{
id: "kling-muted",
modelIncludes: ["kling"],
when: { muted: true, hasReferenceVideo: false },
rates: { "720P": 0.6, "1080P": 0.8 },
},
{
id: "kling-default",
modelIncludes: ["kling"],
rates: { "720P": 0.9, "1080P": 1.2 },
},
{
id: "vidu",
modelIncludes: ["vidu"],
rates: { "720P": 0.6, "1080P": 1 },
},
{
id: "pixverse",
modelIncludes: ["pixverse"],
rates: { "720P": 0.6, "1080P": 1 },
},
];
function normalizeModel(value) {
return String(value || "").trim().toLowerCase();
}
function normalizeEnterpriseVideoResolution(value) {
return String(value || "").trim().toUpperCase() === "720P" ? "720P" : "1080P";
}
function normalizeEnterpriseVideoDuration(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 1;
return Math.max(1, Math.ceil(numeric));
}
function enterpriseVideoPricingRuleMatches(rule, input, model) {
if (!rule.modelIncludes.some((pattern) => model.includes(pattern))) return false;
if (!rule.when) return true;
if (Object.prototype.hasOwnProperty.call(rule.when, "muted") && Boolean(input.muted) !== rule.when.muted) {
return false;
}
if (
Object.prototype.hasOwnProperty.call(rule.when, "hasReferenceVideo") &&
Boolean(input.hasReferenceVideo) !== rule.when.hasReferenceVideo
) {
return false;
}
return true;
}
function isEnterpriseVideoBillingUser(user) {
return Boolean(user?.enterpriseId);
}
function isEnterpriseVideoModelAllowed(providerConfig, model) {
const requestedModel = normalizeModel(model || providerConfig?.requestedModel || providerConfig?.model);
const resolvedModel = normalizeModel(providerConfig?.model);
const protocol = String(providerConfig?.protocol || "").toLowerCase();
if (ENTERPRISE_VIDEO_ALLOWED_MODELS.has(requestedModel)) return true;
if (ENTERPRISE_VIDEO_ALLOWED_MODELS.has(resolvedModel)) return true;
if (protocol.startsWith("happyhorse-")) return true;
if (protocol === "wan-i2v") return true;
if (protocol === "wan-animate-mix") return true;
if (protocol === "wan-s2v") return true;
if (protocol === "kling-dashscope") return true;
if (protocol === "vidu") return true;
if (protocol === "pixverse") return true;
return false;
}
function assertEnterpriseVideoModelAllowed(providerConfig, model) {
if (isEnterpriseVideoModelAllowed(providerConfig, model)) return;
const error = new Error("该企业账号不可使用当前视频模型");
error.status = 403;
error.code = "ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED";
throw error;
}
function getEnterpriseVideoCreditRate(input) {
const resolution = normalizeEnterpriseVideoResolution(input.resolution || input.quality);
const model = normalizeModel(input.model || input.requestedModel);
const rule = ENTERPRISE_VIDEO_PRICING_RULES.find((candidate) =>
enterpriseVideoPricingRuleMatches(candidate, input, model),
);
if (rule) return rule.rates[resolution] ?? rule.rates[ENTERPRISE_VIDEO_DEFAULT_RESOLUTION];
const error = new Error(`Unsupported enterprise video model: ${input.model || input.requestedModel}`);
error.status = 403;
error.code = "ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED";
throw error;
}
function getEnterpriseVideoPricingConfig() {
return {
currency: "CNY",
creditsPerCny: CREDITS_PER_CNY,
billingUnit: "per_second",
defaultResolution: ENTERPRISE_VIDEO_DEFAULT_RESOLUTION,
resolutions: [...ENTERPRISE_VIDEO_RESOLUTIONS],
rules: ENTERPRISE_VIDEO_PRICING_RULES.map((rule) => ({
id: rule.id,
modelIncludes: [...rule.modelIncludes],
when: rule.when ? { ...rule.when } : undefined,
rates: { ...rule.rates },
})),
};
}
function calculateEnterpriseVideoCredits(input) {
const duration = normalizeEnterpriseVideoDuration(input.durationSeconds || input.duration);
return Number((getEnterpriseVideoCreditRate(input) * duration * CREDITS_PER_CNY).toFixed(2));
}
function calculateEnterpriseVideoCost(input) {
const resolution = normalizeEnterpriseVideoResolution(input.resolution || input.quality);
const durationSeconds = normalizeEnterpriseVideoDuration(input.durationSeconds || input.duration);
const rateCreditsPerSecond = getEnterpriseVideoCreditRate({
...input,
resolution,
durationSeconds,
});
const rateCentsPerSecond = Math.round(rateCreditsPerSecond * CREDITS_PER_CNY * CREDIT_UNITS_PER_CREDIT);
return {
resolution,
durationSeconds,
rateCentsPerSecond,
amountCents: rateCentsPerSecond * durationSeconds,
};
}
function prepareEnterpriseVideoBilling({ user, providerConfig, params }) {
if (!isEnterpriseVideoBillingUser(user)) return null;
assertEnterpriseVideoModelAllowed(providerConfig, params.requestedModel || params.model);
const hasReferenceVideo = Boolean(params.hasReferenceVideo);
const muted = Boolean(params.muted);
const pricing = calculateEnterpriseVideoCost({
model: params.model,
requestedModel: params.requestedModel,
resolution: params.resolution || params.quality,
duration: params.duration,
muted,
hasReferenceVideo,
});
return {
enterpriseId: user.enterpriseId,
userId: user.id,
model: params.model,
requestedModel: params.requestedModel,
muted,
hasReferenceVideo,
...pricing,
};
}
async function reserveEnterpriseVideoCredits(client, billing) {
if (!billing || billing.amountCents <= 0) return null;
const { rows: balanceRows } = await client.query(
"UPDATE enterprises SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND enabled = 1 AND balance_cents >= $1 RETURNING balance_cents",
[billing.amountCents, billing.enterpriseId],
);
if (balanceRows.length === 0) {
const error = new Error(
`企业积分不足,预计需要 ${Number((billing.amountCents / 100).toFixed(2))} 积分`,
);
error.status = 402;
error.code = "INSUFFICIENT_ENTERPRISE_BALANCE";
throw error;
}
const {
rows: [ledger],
} = await client.query(
`
INSERT INTO credit_ledger (
enterprise_id,
user_id,
task_id,
model,
task_type,
resolution,
duration_seconds,
rate_cents_per_second,
amount_cents,
status
)
VALUES ($1, $2, $3, $4, 'video', $5, $6, $7, $8, 'reserved')
RETURNING id
`,
[
billing.enterpriseId,
billing.userId,
billing.taskId || null,
billing.requestedModel || billing.model,
billing.resolution,
billing.durationSeconds,
billing.rateCentsPerSecond,
billing.amountCents,
],
);
return {
...billing,
creditLedgerId: ledger.id,
enterpriseBalanceCents: Number(balanceRows[0].balance_cents || 0),
};
}
async function markEnterpriseVideoCreditsAccepted(clientOrPool, creditLedgerId) {
if (!creditLedgerId) return false;
const { rowCount } = await clientOrPool.query(
"UPDATE credit_ledger SET status = 'charged', updated_at = NOW() WHERE id = $1 AND status = 'reserved'",
[creditLedgerId],
);
return rowCount > 0;
}
async function refundEnterpriseVideoCredits(clientOrPool, billing, reason) {
if (!billing || !billing.creditLedgerId) return false;
const { rowCount } = await clientOrPool.query(
"UPDATE credit_ledger SET status = 'refunded', refund_reason = $1, updated_at = NOW() WHERE id = $2 AND status = 'reserved'",
[reason || null, billing.creditLedgerId],
);
if (rowCount > 0 && billing.amountCents > 0 && billing.enterpriseId) {
await clientOrPool.query(
"UPDATE enterprises SET balance_cents = balance_cents + $1, updated_at = NOW() WHERE id = $2",
[billing.amountCents, billing.enterpriseId],
);
}
return rowCount > 0;
}
module.exports = {
ENTERPRISE_VIDEO_ALLOWED_MODELS,
assertEnterpriseVideoModelAllowed,
calculateEnterpriseVideoCost,
calculateEnterpriseVideoCredits,
getEnterpriseVideoPricingConfig,
getEnterpriseVideoCreditRate,
isEnterpriseVideoBillingUser,
isEnterpriseVideoModelAllowed,
markEnterpriseVideoCreditsAccepted,
normalizeEnterpriseVideoDuration,
normalizeEnterpriseVideoResolution,
prepareEnterpriseVideoBilling,
refundEnterpriseVideoCredits,
reserveEnterpriseVideoCredits,
};