Merge pull request 'Codex/beta application review' (#4) from codex/beta-application-review into master
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
+38
-25
@@ -1,29 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* Billing module — handles balance deduction (in cents), package quotas,
|
* Billing module — handles balance deduction, package quotas,
|
||||||
* transactions, and key-lease pre-authorization.
|
* transactions, and key-lease pre-authorization.
|
||||||
*
|
*
|
||||||
* Money conventions:
|
* Unit conventions:
|
||||||
* - balance: cents (分, 1/100 CNY) — stored in users.balance_cents and enterprises.balance_cents
|
* - payment_orders.amount_cents / packages.price_cents: cash amount in CNY cents.
|
||||||
* - prices: mills (厘, 1/1000 CNY) — stored in model_prices.*_mills
|
* - users.balance_cents / enterprises.balance_cents / transactions.amount_cents:
|
||||||
* - cost calculation: mills → convert to cents at deduction time (divide by 10, floor)
|
* credit units, where 100 units = 1 platform credit.
|
||||||
* - transactions: cents — amount_cents, balance_after_cents
|
* - model_prices.*_mills: CNY mills. 1 CNY = 100 credits, so 1 mill = 10 credit units.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* - Enterprise admin recharges enterprise pool → distributes to employee users
|
* - Enterprise admin recharges enterprise pool → distributes to employee users
|
||||||
* - API deductions come from users.balance_cents (per-user)
|
* - API deductions come from users.balance_cents (per-user credit balance)
|
||||||
* - Personal users recharge their own users.balance_cents directly
|
* - Personal users recharge their own users.balance_cents directly
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { pool, withTransaction } = require("./db");
|
const { pool, withTransaction } = require("./db");
|
||||||
const { calculateCostMills, getModelPrice } = require("./pricing");
|
const { calculateCostMills, getModelPrice } = require("./pricing");
|
||||||
|
|
||||||
const IMAGE_GENERATION_FLAT_COST_CENTS = 20;
|
const CREDIT_UNITS_PER_CREDIT = 100;
|
||||||
|
const CREDIT_UNITS_PER_CNY_CENT = 100;
|
||||||
|
const CREDIT_UNITS_PER_CNY_MILL = 10;
|
||||||
|
const IMAGE_GENERATION_FLAT_COST_CENTS = 20 * CREDIT_UNITS_PER_CREDIT;
|
||||||
|
|
||||||
|
function creditsToCreditUnits(credits) {
|
||||||
|
return Math.max(0, Math.round(Number(credits || 0) * CREDIT_UNITS_PER_CREDIT));
|
||||||
|
}
|
||||||
|
|
||||||
function formatCreditsFromCents(amountCents) {
|
function formatCreditsFromCents(amountCents) {
|
||||||
const value = Number(amountCents || 0) / 100;
|
const value = Number(amountCents || 0) / CREDIT_UNITS_PER_CREDIT;
|
||||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
|
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cashCentsToCreditUnits(amountCents) {
|
||||||
|
return Math.max(0, Math.round(Number(amountCents || 0) * CREDIT_UNITS_PER_CNY_CENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
function millsToCreditUnits(mills) {
|
||||||
|
return Math.max(0, Math.round(Number(mills || 0) * CREDIT_UNITS_PER_CNY_MILL));
|
||||||
|
}
|
||||||
|
|
||||||
async function recordEnterpriseCreditLedger(client, entry) {
|
async function recordEnterpriseCreditLedger(client, entry) {
|
||||||
const enterpriseId = entry?.enterpriseId || null;
|
const enterpriseId = entry?.enterpriseId || null;
|
||||||
const userId = entry?.userId || null;
|
const userId = entry?.userId || null;
|
||||||
@@ -114,10 +129,6 @@ async function getEnterpriseName(enterpriseId) {
|
|||||||
return rows[0] ? rows[0].name : null;
|
return rows[0] ? rows[0].name : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function millsToCents(mills) {
|
|
||||||
return Math.floor(mills / 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Atomic balance helpers ───────────────────────────────────────────
|
// ── Atomic balance helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
async function atomicDeductUserBalance(client, userId, amountCents) {
|
async function atomicDeductUserBalance(client, userId, amountCents) {
|
||||||
@@ -167,7 +178,7 @@ async function preauthorizeCall(userId, provider) {
|
|||||||
|
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`
|
`
|
||||||
SELECT COALESCE(CAST(ROUND(AVG(cost_estimate * 100)::numeric) AS INTEGER), 0) AS avg_cents
|
SELECT COALESCE(CAST(ROUND(AVG(cost_estimate * 10000)::numeric) AS INTEGER), 0) AS avg_cents
|
||||||
FROM api_call_logs
|
FROM api_call_logs
|
||||||
WHERE provider = $1
|
WHERE provider = $1
|
||||||
AND status = 'success'
|
AND status = 'success'
|
||||||
@@ -185,10 +196,9 @@ async function preauthorizeCall(userId, provider) {
|
|||||||
const bufferedEstimate = Math.ceil(estimatedCostCents * 1.2);
|
const bufferedEstimate = Math.ceil(estimatedCostCents * 1.2);
|
||||||
|
|
||||||
if (balanceCents < bufferedEstimate) {
|
if (balanceCents < bufferedEstimate) {
|
||||||
const credits = Math.floor(balanceCents / 100);
|
|
||||||
return {
|
return {
|
||||||
authorized: false,
|
authorized: false,
|
||||||
message: `账户积分不足,请充值 (当前 ${credits} 积分,预估需要 ${Math.ceil(bufferedEstimate / 100)} 积分)`,
|
message: `账户积分不足,请充值 (当前 ${formatCreditsFromCents(balanceCents)} 积分,预估需要 ${formatCreditsFromCents(bufferedEstimate)} 积分)`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +215,9 @@ async function deductForApiCall(userId, model, promptTokens, completionTokens) {
|
|||||||
return { success: true, costCents: 0, deductionType: "none", message: "No pricing" };
|
return { success: true, costCents: 0, deductionType: "none", message: "No pricing" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const costCents = millsToCents(costMills);
|
const costCents = millsToCreditUnits(costMills);
|
||||||
if (costCents <= 0) {
|
if (costCents <= 0) {
|
||||||
return { success: true, costCents: 0, deductionType: "none", message: "Cost below 1 cent" };
|
return { success: true, costCents: 0, deductionType: "none", message: "Cost below minimum credit unit" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const billingState = await getUserBillingState(userId);
|
const billingState = await getUserBillingState(userId);
|
||||||
@@ -408,7 +418,7 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge
|
|||||||
userId,
|
userId,
|
||||||
-amountCents,
|
-amountCents,
|
||||||
newBal,
|
newBal,
|
||||||
`API 调用扣费 ${Math.ceil(amountCents / 100)} 积分`,
|
`API 调用扣费 ${formatCreditsFromCents(amountCents)} 积分`,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -429,16 +439,15 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge
|
|||||||
|
|
||||||
if (newBalanceCents == null) {
|
if (newBalanceCents == null) {
|
||||||
const currentBalance = await getUserBalanceCents(userId);
|
const currentBalance = await getUserBalanceCents(userId);
|
||||||
const credits = Math.floor((currentBalance || 0) / 100);
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `积分不足 (当前 ${credits} 积分,需要 ${Math.ceil(amountCents / 100)} 积分)`,
|
message: `积分不足 (当前 ${formatCreditsFromCents(currentBalance || 0)} 积分,需要 ${formatCreditsFromCents(amountCents)} 积分)`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Deducted ${Math.ceil(amountCents / 100)} credits, balance: ${Math.floor(newBalanceCents / 100)} credits`,
|
message: `Deducted ${formatCreditsFromCents(amountCents)} credits, balance: ${formatCreditsFromCents(newBalanceCents)} credits`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +493,7 @@ async function settleLease(leaseId, actualCostCents) {
|
|||||||
userId,
|
userId,
|
||||||
-diffCents,
|
-diffCents,
|
||||||
newBal,
|
newBal,
|
||||||
`API 预估差额扣费 ${Math.ceil(diffCents / 100)} 积分`,
|
`API 预估差额扣费 ${formatCreditsFromCents(diffCents)} 积分`,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -503,7 +512,7 @@ async function settleLease(leaseId, actualCostCents) {
|
|||||||
userId,
|
userId,
|
||||||
refundCents,
|
refundCents,
|
||||||
newBal,
|
newBal,
|
||||||
`API 预估差额退回 ${Math.ceil(refundCents / 100)} 积分`,
|
`API 预估差额退回 ${formatCreditsFromCents(refundCents)} 积分`,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -628,7 +637,7 @@ async function distributeCredits(enterpriseId, targetUserId, amountCents, adminU
|
|||||||
targetUserId,
|
targetUserId,
|
||||||
amountCents,
|
amountCents,
|
||||||
newUserBal,
|
newUserBal,
|
||||||
`从企业池获得 ${Math.floor(amountCents / 100)} 积分`,
|
`从企业池获得 ${formatCreditsFromCents(amountCents)} 积分`,
|
||||||
adminUserId,
|
adminUserId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -761,4 +770,8 @@ module.exports = {
|
|||||||
preauthorizeCall,
|
preauthorizeCall,
|
||||||
settleLease,
|
settleLease,
|
||||||
forceSettleLease,
|
forceSettleLease,
|
||||||
|
creditsToCreditUnits,
|
||||||
|
cashCentsToCreditUnits,
|
||||||
|
millsToCreditUnits,
|
||||||
|
formatCreditsFromCents,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const { AlipaySdk } = require("alipay-sdk");
|
const { AlipaySdk } = require("alipay-sdk");
|
||||||
const { pool, withTransaction } = require("./db");
|
const { pool, withTransaction } = require("./db");
|
||||||
const { creditBalance, creditUserBalance, activatePackage } = require("./billing");
|
const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing");
|
||||||
|
|
||||||
let alipayInstance = null;
|
let alipayInstance = null;
|
||||||
|
|
||||||
@@ -130,17 +130,19 @@ async function handlePaymentSuccess(orderNo, tradeNo) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (order.type === "personal_recharge" && order.user_id) {
|
if (order.type === "personal_recharge" && order.user_id) {
|
||||||
|
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
|
||||||
await creditUserBalance(
|
await creditUserBalance(
|
||||||
order.user_id,
|
order.user_id,
|
||||||
order.amount_cents,
|
creditUnits,
|
||||||
`支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`,
|
`支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`,
|
||||||
orderNo,
|
orderNo,
|
||||||
);
|
);
|
||||||
} else if (order.type === "recharge") {
|
} else if (order.type === "recharge") {
|
||||||
|
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
|
||||||
await creditBalance(
|
await creditBalance(
|
||||||
order.enterprise_id,
|
order.enterprise_id,
|
||||||
order.amount_cents,
|
creditUnits,
|
||||||
`支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`,
|
`支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`,
|
||||||
orderNo,
|
orderNo,
|
||||||
);
|
);
|
||||||
} else if (order.type === "package" && order.package_id) {
|
} else if (order.type === "package" && order.package_id) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const _crypto = require("node:crypto");
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const WxPay = require("wechatpay-node-v3");
|
const WxPay = require("wechatpay-node-v3");
|
||||||
const { pool, withTransaction } = require("./db");
|
const { pool, withTransaction } = require("./db");
|
||||||
const { creditBalance, creditUserBalance, activatePackage } = require("./billing");
|
const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing");
|
||||||
|
|
||||||
let wxPayInstance = null;
|
let wxPayInstance = null;
|
||||||
|
|
||||||
@@ -140,17 +140,19 @@ async function handlePaymentSuccess(orderNo, transactionId) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (order.type === "personal_recharge" && order.user_id) {
|
if (order.type === "personal_recharge" && order.user_id) {
|
||||||
|
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
|
||||||
await creditUserBalance(
|
await creditUserBalance(
|
||||||
order.user_id,
|
order.user_id,
|
||||||
order.amount_cents,
|
creditUnits,
|
||||||
`微信充值 ${Math.floor(order.amount_cents / 100)} 积分`,
|
`微信充值 ${formatCreditsFromCents(creditUnits)} 积分`,
|
||||||
orderNo,
|
orderNo,
|
||||||
);
|
);
|
||||||
} else if (order.type === "recharge") {
|
} else if (order.type === "recharge") {
|
||||||
|
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
|
||||||
await creditBalance(
|
await creditBalance(
|
||||||
order.enterprise_id,
|
order.enterprise_id,
|
||||||
order.amount_cents,
|
creditUnits,
|
||||||
`微信充值 ${Math.floor(order.amount_cents / 100)} 积分`,
|
`微信充值 ${formatCreditsFromCents(creditUnits)} 积分`,
|
||||||
orderNo,
|
orderNo,
|
||||||
);
|
);
|
||||||
} else if (order.type === "package" && order.package_id) {
|
} else if (order.type === "package" && order.package_id) {
|
||||||
|
|||||||
+1
-1
@@ -176,7 +176,7 @@ async function getAverageCostCents(provider) {
|
|||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`
|
`
|
||||||
SELECT CAST(ROUND(AVG(CASE
|
SELECT CAST(ROUND(AVG(CASE
|
||||||
WHEN cost_estimate IS NOT NULL THEN cost_estimate * 100
|
WHEN cost_estimate IS NOT NULL THEN cost_estimate * 10000
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END)::numeric) AS INTEGER) AS avg_cents
|
END)::numeric) AS INTEGER) AS avg_cents
|
||||||
FROM api_call_logs
|
FROM api_call_logs
|
||||||
|
|||||||
+21
-6
@@ -6,6 +6,8 @@ const {
|
|||||||
listModelPrices,
|
listModelPrices,
|
||||||
loadPriceCache,
|
loadPriceCache,
|
||||||
creditUserBalance,
|
creditUserBalance,
|
||||||
|
creditsToCreditUnits,
|
||||||
|
formatCreditsFromCents,
|
||||||
pool,
|
pool,
|
||||||
validateUsername,
|
validateUsername,
|
||||||
validatePassword,
|
validatePassword,
|
||||||
@@ -156,14 +158,18 @@ function registerAdminRoutes(router) {
|
|||||||
|
|
||||||
router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => {
|
router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => {
|
||||||
const targetUserId = Number(req.params.id);
|
const targetUserId = Number(req.params.id);
|
||||||
const { amountCents } = req.body;
|
const { amountCredits, amountCents } = req.body;
|
||||||
if (!amountCents || amountCents <= 0) return res.status(400).json({ error: "积分必须大于 0" });
|
const creditUnits =
|
||||||
|
amountCredits !== undefined && amountCredits !== null && amountCredits !== ""
|
||||||
|
? creditsToCreditUnits(amountCredits)
|
||||||
|
: Number(amountCents);
|
||||||
|
if (!creditUnits || creditUnits <= 0) return res.status(400).json({ error: "积分必须大于 0" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newBalance = await creditUserBalance(
|
const newBalance = await creditUserBalance(
|
||||||
targetUserId,
|
targetUserId,
|
||||||
amountCents,
|
creditUnits,
|
||||||
`管理员 ${req.user.username} 发放 ${Math.floor(amountCents / 100)} 积分`,
|
`管理员 ${req.user.username} 发放 ${formatCreditsFromCents(creditUnits)} 积分`,
|
||||||
);
|
);
|
||||||
res.json({ success: true, newBalanceCents: newBalance });
|
res.json({ success: true, newBalanceCents: newBalance });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -547,6 +553,8 @@ function registerAdminRoutes(router) {
|
|||||||
name,
|
name,
|
||||||
description = "",
|
description = "",
|
||||||
priceCents,
|
priceCents,
|
||||||
|
credits,
|
||||||
|
amountCredits,
|
||||||
creditsCents = 0,
|
creditsCents = 0,
|
||||||
imageQuota = 0,
|
imageQuota = 0,
|
||||||
videoQuota = 0,
|
videoQuota = 0,
|
||||||
@@ -572,7 +580,9 @@ function registerAdminRoutes(router) {
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
Number(priceCents),
|
Number(priceCents),
|
||||||
Number(creditsCents || 0),
|
credits !== undefined || amountCredits !== undefined
|
||||||
|
? creditsToCreditUnits(credits ?? amountCredits)
|
||||||
|
: Number(creditsCents || 0),
|
||||||
Number(imageQuota || 0),
|
Number(imageQuota || 0),
|
||||||
Number(videoQuota || 0),
|
Number(videoQuota || 0),
|
||||||
Number(textQuota || 0),
|
Number(textQuota || 0),
|
||||||
@@ -599,6 +609,8 @@ function registerAdminRoutes(router) {
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
priceCents,
|
priceCents,
|
||||||
|
credits,
|
||||||
|
amountCredits,
|
||||||
creditsCents,
|
creditsCents,
|
||||||
imageQuota,
|
imageQuota,
|
||||||
videoQuota,
|
videoQuota,
|
||||||
@@ -623,7 +635,10 @@ function registerAdminRoutes(router) {
|
|||||||
updates.push(`price_cents = $${idx++}`);
|
updates.push(`price_cents = $${idx++}`);
|
||||||
params.push(Number(priceCents));
|
params.push(Number(priceCents));
|
||||||
}
|
}
|
||||||
if (creditsCents !== undefined) {
|
if (credits !== undefined || amountCredits !== undefined) {
|
||||||
|
updates.push(`credits_cents = $${idx++}`);
|
||||||
|
params.push(creditsToCreditUnits(credits ?? amountCredits));
|
||||||
|
} else if (creditsCents !== undefined) {
|
||||||
updates.push(`credits_cents = $${idx++}`);
|
updates.push(`credits_cents = $${idx++}`);
|
||||||
params.push(Number(creditsCents));
|
params.push(Number(creditsCents));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { getUserContextById, verifyToken } = require("../auth");
|
||||||
|
const { pool, withTransaction } = require("../db");
|
||||||
|
const { loadBetaInviteCodes, normalizeBetaInviteCode } = require("../betaInviteCodes");
|
||||||
|
|
||||||
|
const REVIEW_USERNAMES = new Set(["xqy1912"]);
|
||||||
|
|
||||||
|
function cleanText(value, maxLength) {
|
||||||
|
return String(value || "").trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTextArray(value, maxItems = 20, maxLength = 200) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((item) => cleanText(item, maxLength)).filter(Boolean).slice(0, maxItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value, fallback) {
|
||||||
|
if (!value || typeof value !== "string") return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonString(value, fallback) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value ?? fallback);
|
||||||
|
} catch {
|
||||||
|
return JSON.stringify(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestIp(req) {
|
||||||
|
const forwardedFor = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
|
||||||
|
return forwardedFor || req.socket?.remoteAddress || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function optionalAuth(req, _res, next) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = verifyToken(authHeader.slice(7));
|
||||||
|
const user = await getUserContextById(payload.userId);
|
||||||
|
if (user?.enabled) req.user = user;
|
||||||
|
} catch {
|
||||||
|
// Public application submission should still work without a valid session.
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canReviewBetaApplications(user) {
|
||||||
|
if (!user) return false;
|
||||||
|
const role = String(user.role || "").trim().toLowerCase();
|
||||||
|
const username = String(user.username || "").trim().toLowerCase();
|
||||||
|
return role === "admin" || REVIEW_USERNAMES.has(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBetaApplicationReviewer(req, res, next) {
|
||||||
|
if (!canReviewBetaApplications(req.user)) {
|
||||||
|
return res.status(403).json({ error: "无权审核内测申请" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureBetaApplicationSchema() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS beta_applications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
name TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
wechat TEXT,
|
||||||
|
industry TEXT,
|
||||||
|
company TEXT,
|
||||||
|
city TEXT,
|
||||||
|
ai_tools TEXT,
|
||||||
|
ai_duration TEXT,
|
||||||
|
ai_track TEXT,
|
||||||
|
ai_direction_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
weekly_usage TEXT,
|
||||||
|
feedback_willing TEXT,
|
||||||
|
want_feature_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
self_statement TEXT,
|
||||||
|
signature TEXT,
|
||||||
|
agree_rules INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
invite_code TEXT,
|
||||||
|
review_note TEXT,
|
||||||
|
reviewed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
reviewed_at TIMESTAMPTZ,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_beta_applications_status_created
|
||||||
|
ON beta_applications(status, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_beta_applications_user_created
|
||||||
|
ON beta_applications(user_id, created_at DESC);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApplicationBody(body) {
|
||||||
|
return {
|
||||||
|
name: cleanText(body?.name, 120),
|
||||||
|
phone: cleanText(body?.phone, 60),
|
||||||
|
wechat: cleanText(body?.wechat, 120),
|
||||||
|
industry: cleanText(body?.industry, 160),
|
||||||
|
company: cleanText(body?.company, 200),
|
||||||
|
city: cleanText(body?.city, 120),
|
||||||
|
aiTools: cleanText(body?.aiTools ?? body?.ai_tools, 1000),
|
||||||
|
aiDuration: cleanText(body?.aiDuration ?? body?.ai_duration, 120),
|
||||||
|
aiTrack: cleanText(body?.aiTrack ?? body?.ai_track, 160),
|
||||||
|
aiDirection: cleanTextArray(body?.aiDirection ?? body?.ai_direction),
|
||||||
|
weeklyUsage: cleanText(body?.weeklyUsage ?? body?.weekly_usage, 120),
|
||||||
|
feedbackWilling: cleanText(body?.feedbackWilling ?? body?.feedback_willing, 160),
|
||||||
|
wantFeature: cleanTextArray(body?.wantFeature ?? body?.want_feature),
|
||||||
|
selfStatement: cleanText(body?.selfStatement ?? body?.self_statement, 5000),
|
||||||
|
signature: cleanText(body?.signature, 120),
|
||||||
|
agreeRules: body?.agreeRules === true || body?.agree_rules === true || body?.agreeRules === 1 || body?.agree_rules === 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatApplication(row) {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
userId: row.user_id == null ? null : Number(row.user_id),
|
||||||
|
username: row.username || null,
|
||||||
|
name: row.name || "",
|
||||||
|
phone: row.phone || "",
|
||||||
|
wechat: row.wechat || "",
|
||||||
|
industry: row.industry || "",
|
||||||
|
company: row.company || "",
|
||||||
|
city: row.city || "",
|
||||||
|
aiTools: row.ai_tools || "",
|
||||||
|
aiDuration: row.ai_duration || "",
|
||||||
|
aiTrack: row.ai_track || "",
|
||||||
|
aiDirection: parseJson(row.ai_direction_json, []),
|
||||||
|
weeklyUsage: row.weekly_usage || "",
|
||||||
|
feedbackWilling: row.feedback_willing || "",
|
||||||
|
wantFeature: parseJson(row.want_feature_json, []),
|
||||||
|
selfStatement: row.self_statement || "",
|
||||||
|
signature: row.signature || "",
|
||||||
|
agreeRules: Boolean(row.agree_rules),
|
||||||
|
status: row.status || "pending",
|
||||||
|
inviteCode: row.invite_code || null,
|
||||||
|
reviewNote: row.review_note || null,
|
||||||
|
reviewedBy: row.reviewed_by == null ? null : Number(row.reviewed_by),
|
||||||
|
reviewerUsername: row.reviewer_username || null,
|
||||||
|
reviewedAt: row.reviewed_at || null,
|
||||||
|
ipAddress: row.ip_address || null,
|
||||||
|
userAgent: row.user_agent || null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectApplicationById(client, id) {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`
|
||||||
|
SELECT a.*, u.username, reviewer.username AS reviewer_username
|
||||||
|
FROM beta_applications a
|
||||||
|
LEFT JOIN users u ON u.id = a.user_id
|
||||||
|
LEFT JOIN users reviewer ON reviewer.id = a.reviewed_by
|
||||||
|
WHERE a.id = $1
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueNextBetaInviteCode(client) {
|
||||||
|
const codes = Array.from(loadBetaInviteCodes()).map(normalizeBetaInviteCode).filter(Boolean).sort();
|
||||||
|
for (const code of codes) {
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`
|
||||||
|
SELECT 1
|
||||||
|
FROM beta_invite_code_uses
|
||||||
|
WHERE code = $1
|
||||||
|
UNION ALL
|
||||||
|
SELECT 1
|
||||||
|
FROM beta_applications
|
||||||
|
WHERE invite_code = $1 AND status = 'approved'
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[code],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return code;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNotification(client, userId, input) {
|
||||||
|
if (!userId) return;
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO web_notifications (
|
||||||
|
user_id, type, title, description, target_type, target_id, metadata_json
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
input.type,
|
||||||
|
input.title,
|
||||||
|
input.description || null,
|
||||||
|
input.targetType || "beta_application",
|
||||||
|
input.targetId ? String(input.targetId) : null,
|
||||||
|
safeJsonString(input.metadata, {}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerBetaApplicationRoutes(router) {
|
||||||
|
router.post("/beta-applications", optionalAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureBetaApplicationSchema();
|
||||||
|
const app = normalizeApplicationBody(req.body);
|
||||||
|
if (!app.name || !app.phone || !app.wechat || !app.selfStatement || !app.signature || !app.agreeRules) {
|
||||||
|
return res.status(400).json({ error: "请填写姓名、手机号、微信、申请自述、签名并同意内测规则" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO beta_applications (
|
||||||
|
user_id, name, phone, wechat, industry, company, city,
|
||||||
|
ai_tools, ai_duration, ai_track, ai_direction_json,
|
||||||
|
weekly_usage, feedback_willing, want_feature_json,
|
||||||
|
self_statement, signature, agree_rules, ip_address, user_agent
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
|
RETURNING id, status, created_at
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
req.user?.id || null,
|
||||||
|
app.name,
|
||||||
|
app.phone,
|
||||||
|
app.wechat,
|
||||||
|
app.industry || null,
|
||||||
|
app.company || null,
|
||||||
|
app.city || null,
|
||||||
|
app.aiTools || null,
|
||||||
|
app.aiDuration || null,
|
||||||
|
app.aiTrack || null,
|
||||||
|
safeJsonString(app.aiDirection, []),
|
||||||
|
app.weeklyUsage || null,
|
||||||
|
app.feedbackWilling || null,
|
||||||
|
safeJsonString(app.wantFeature, []),
|
||||||
|
app.selfStatement,
|
||||||
|
app.signature,
|
||||||
|
app.agreeRules ? 1 : 0,
|
||||||
|
getRequestIp(req),
|
||||||
|
cleanText(req.headers["user-agent"], 1000) || null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
application: {
|
||||||
|
id: rows[0].id,
|
||||||
|
status: rows[0].status,
|
||||||
|
createdAt: rows[0].created_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[beta-applications] create failed:", err.message);
|
||||||
|
res.status(500).json({ error: "提交内测申请失败" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/admin/beta-applications", requireBetaApplicationReviewer, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureBetaApplicationSchema();
|
||||||
|
const status = cleanText(req.query.status, 32);
|
||||||
|
const params = [];
|
||||||
|
const where = [];
|
||||||
|
if (status) {
|
||||||
|
params.push(status);
|
||||||
|
where.push(`a.status = $${params.length}`);
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT a.*, u.username, reviewer.username AS reviewer_username
|
||||||
|
FROM beta_applications a
|
||||||
|
LEFT JOIN users u ON u.id = a.user_id
|
||||||
|
LEFT JOIN users reviewer ON reviewer.id = a.reviewed_by
|
||||||
|
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
||||||
|
ORDER BY
|
||||||
|
CASE a.status WHEN 'pending' THEN 0 WHEN 'approved' THEN 1 ELSE 2 END,
|
||||||
|
a.created_at DESC
|
||||||
|
LIMIT 300
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
res.json({ applications: rows.map(formatApplication) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/beta-applications] list failed:", err.message);
|
||||||
|
res.status(500).json({ error: "读取内测申请失败" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/admin/beta-applications/:id", requireBetaApplicationReviewer, async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const action = cleanText(req.body?.action, 32);
|
||||||
|
const reviewNote = cleanText(req.body?.reviewNote ?? req.body?.review_note, 1000) || null;
|
||||||
|
if (!Number.isFinite(id)) return res.status(400).json({ error: "申请 ID 不正确" });
|
||||||
|
if (action !== "approve" && action !== "reject") return res.status(400).json({ error: "审核动作不正确" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureBetaApplicationSchema();
|
||||||
|
const application = await withTransaction(async (client) => {
|
||||||
|
const current = await selectApplicationById(client, id);
|
||||||
|
if (!current) {
|
||||||
|
const err = new Error("申请不存在");
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (current.status !== "pending") {
|
||||||
|
const err = new Error("该申请已审核");
|
||||||
|
err.status = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inviteCode = null;
|
||||||
|
if (action === "approve") {
|
||||||
|
inviteCode = await issueNextBetaInviteCode(client);
|
||||||
|
if (!inviteCode) {
|
||||||
|
const err = new Error("暂无可用内测码,请先补充内测码");
|
||||||
|
err.status = 409;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await client.query(
|
||||||
|
`
|
||||||
|
UPDATE beta_applications
|
||||||
|
SET status = $1,
|
||||||
|
invite_code = $2,
|
||||||
|
review_note = $3,
|
||||||
|
reviewed_by = $4,
|
||||||
|
reviewed_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $5
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[action === "approve" ? "approved" : "rejected", inviteCode, reviewNote, req.user.id, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = rows[0];
|
||||||
|
if (updated.user_id) {
|
||||||
|
if (action === "approve") {
|
||||||
|
await createNotification(client, updated.user_id, {
|
||||||
|
type: "review_passed",
|
||||||
|
title: "内测申请已通过",
|
||||||
|
description: `您的内测申请已通过,内测码:${inviteCode}`,
|
||||||
|
targetId: updated.id,
|
||||||
|
metadata: { inviteCode },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createNotification(client, updated.user_id, {
|
||||||
|
type: "review_rejected",
|
||||||
|
title: "您未通过内测申请",
|
||||||
|
description: reviewNote || "很遗憾,您的内测申请暂未通过。",
|
||||||
|
targetId: updated.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectApplicationById(client, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ application: formatApplication(application) });
|
||||||
|
} catch (err) {
|
||||||
|
const status = Number(err.status || 500);
|
||||||
|
if (status >= 400 && status < 500) return res.status(status).json({ error: err.message });
|
||||||
|
console.error("[admin/beta-applications] review failed:", err.message);
|
||||||
|
res.status(500).json({ error: "审核内测申请失败" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerBetaApplicationRoutes, canReviewBetaApplications };
|
||||||
@@ -32,6 +32,8 @@ const {
|
|||||||
getUserEnterpriseId,
|
getUserEnterpriseId,
|
||||||
getEnterpriseName,
|
getEnterpriseName,
|
||||||
preauthorizeCall,
|
preauthorizeCall,
|
||||||
|
creditsToCreditUnits,
|
||||||
|
formatCreditsFromCents,
|
||||||
} = require("../billing");
|
} = require("../billing");
|
||||||
const wechatPay = require("../paymentWechat");
|
const wechatPay = require("../paymentWechat");
|
||||||
const alipay = require("../paymentAlipay");
|
const alipay = require("../paymentAlipay");
|
||||||
@@ -793,6 +795,8 @@ module.exports = {
|
|||||||
getUserEnterpriseId,
|
getUserEnterpriseId,
|
||||||
getEnterpriseName,
|
getEnterpriseName,
|
||||||
preauthorizeCall,
|
preauthorizeCall,
|
||||||
|
creditsToCreditUnits,
|
||||||
|
formatCreditsFromCents,
|
||||||
wechatPay,
|
wechatPay,
|
||||||
alipay,
|
alipay,
|
||||||
crypto,
|
crypto,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const {
|
|||||||
requireAuth,
|
requireAuth,
|
||||||
requireEnterpriseAdmin,
|
requireEnterpriseAdmin,
|
||||||
distributeCredits,
|
distributeCredits,
|
||||||
|
creditsToCreditUnits,
|
||||||
getEnterpriseFinancials,
|
getEnterpriseFinancials,
|
||||||
getEnterpriseName,
|
getEnterpriseName,
|
||||||
pool,
|
pool,
|
||||||
@@ -302,25 +303,33 @@ function registerEnterpriseRoutes(router) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => {
|
router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => {
|
||||||
const { userId, amountCents, distributions } = req.body;
|
const { userId, amountCredits, amountCents, distributions } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (distributions && Array.isArray(distributions)) {
|
if (distributions && Array.isArray(distributions)) {
|
||||||
for (const d of distributions) {
|
for (const d of distributions) {
|
||||||
if (!d.userId || !d.amountCents || d.amountCents <= 0) {
|
const creditUnits =
|
||||||
|
d.amountCredits !== undefined && d.amountCredits !== null && d.amountCredits !== ""
|
||||||
|
? creditsToCreditUnits(d.amountCredits)
|
||||||
|
: Number(d.amountCents);
|
||||||
|
if (!d.userId || !creditUnits || creditUnits <= 0) {
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "每条分发记录必须包含有效的 userId 和 amountCents" });
|
.json({ error: "每条分发记录必须包含有效的 userId 和 amountCredits" });
|
||||||
}
|
}
|
||||||
await distributeCredits(req.user.enterpriseId, d.userId, d.amountCents, req.user.id);
|
await distributeCredits(req.user.enterpriseId, d.userId, creditUnits, req.user.id);
|
||||||
}
|
}
|
||||||
res.json({ success: true, count: distributions.length });
|
res.json({ success: true, count: distributions.length });
|
||||||
} else if (userId && amountCents) {
|
} else if (userId && (amountCredits || amountCents)) {
|
||||||
if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" });
|
const creditUnits =
|
||||||
|
amountCredits !== undefined && amountCredits !== null && amountCredits !== ""
|
||||||
|
? creditsToCreditUnits(amountCredits)
|
||||||
|
: Number(amountCents);
|
||||||
|
if (!creditUnits || creditUnits <= 0) return res.status(400).json({ error: "分发积分必须大于0" });
|
||||||
const result = await distributeCredits(
|
const result = await distributeCredits(
|
||||||
req.user.enterpriseId,
|
req.user.enterpriseId,
|
||||||
userId,
|
userId,
|
||||||
amountCents,
|
creditUnits,
|
||||||
req.user.id,
|
req.user.id,
|
||||||
);
|
);
|
||||||
res.json({ success: true, ...result });
|
res.json({ success: true, ...result });
|
||||||
@@ -349,7 +358,7 @@ function registerEnterpriseRoutes(router) {
|
|||||||
u.username,
|
u.username,
|
||||||
u.balance_cents AS current_balance_cents,
|
u.balance_cents AS current_balance_cents,
|
||||||
COUNT(acl.id) AS total_calls,
|
COUNT(acl.id) AS total_calls,
|
||||||
COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 100)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents,
|
COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 10000)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents,
|
||||||
MAX(acl.created_at) AS last_active
|
MAX(acl.created_at) AS last_active
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success'
|
LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const { registerConversationRoutes } = require('./conversations')
|
|||||||
const { registerReportRoutes } = require('./reports')
|
const { registerReportRoutes } = require('./reports')
|
||||||
const { registerAssetRoutes } = require('./assets')
|
const { registerAssetRoutes } = require('./assets')
|
||||||
const { registerNotificationRoutes } = require('./notifications')
|
const { registerNotificationRoutes } = require('./notifications')
|
||||||
|
const { registerBetaApplicationRoutes } = require('./betaApplications')
|
||||||
const { registerDraftRoutes } = require('./drafts');
|
const { registerDraftRoutes } = require('./drafts');
|
||||||
const { registerFileExtractRoutes } = require('./fileExtract');
|
const { registerFileExtractRoutes } = require('./fileExtract');
|
||||||
const mountClientErrorRoutes = require('./clientErrors')
|
const mountClientErrorRoutes = require('./clientErrors')
|
||||||
@@ -48,6 +49,7 @@ registerConversationRoutes(router)
|
|||||||
registerReportRoutes(router)
|
registerReportRoutes(router)
|
||||||
registerAssetRoutes(router)
|
registerAssetRoutes(router)
|
||||||
registerNotificationRoutes(router)
|
registerNotificationRoutes(router)
|
||||||
|
registerBetaApplicationRoutes(router)
|
||||||
registerDraftRoutes(router)
|
registerDraftRoutes(router)
|
||||||
registerFileExtractRoutes(router)
|
registerFileExtractRoutes(router)
|
||||||
registerHealthRoutes(router)
|
registerHealthRoutes(router)
|
||||||
|
|||||||
+3
-3
@@ -136,7 +136,7 @@ function registerUserRoutes(router) {
|
|||||||
CASE
|
CASE
|
||||||
WHEN billing_refunded = 1 THEN 0
|
WHEN billing_refunded = 1 THEN 0
|
||||||
WHEN cost_cents > 0 THEN cost_cents
|
WHEN cost_cents > 0 THEN cost_cents
|
||||||
WHEN status = 'completed' AND type = 'image' THEN 20
|
WHEN status = 'completed' AND type = 'image' THEN 2000
|
||||||
WHEN status = 'completed' AND type = 'video' THEN 500
|
WHEN status = 'completed' AND type = 'video' THEN 500
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
@@ -162,7 +162,7 @@ function registerUserRoutes(router) {
|
|||||||
resolution = params.resolution || params.quality || params.ratio || null;
|
resolution = params.resolution || params.quality || params.ratio || null;
|
||||||
if (row.status === "completed") {
|
if (row.status === "completed") {
|
||||||
if (row.type === "image") {
|
if (row.type === "image") {
|
||||||
estimatedCents = 20;
|
estimatedCents = 2000;
|
||||||
} else if (row.type === "video") {
|
} else if (row.type === "video") {
|
||||||
const dur = params.duration || 5;
|
const dur = params.duration || 5;
|
||||||
const res = String(params.resolution || params.quality || "").toUpperCase();
|
const res = String(params.resolution || params.quality || "").toUpperCase();
|
||||||
@@ -209,7 +209,7 @@ function registerUserRoutes(router) {
|
|||||||
CASE
|
CASE
|
||||||
WHEN billing_refunded = 1 THEN 0
|
WHEN billing_refunded = 1 THEN 0
|
||||||
WHEN cost_cents > 0 THEN cost_cents
|
WHEN cost_cents > 0 THEN cost_cents
|
||||||
WHEN status = 'completed' AND type = 'image' THEN 20
|
WHEN status = 'completed' AND type = 'image' THEN 2000
|
||||||
WHEN status = 'completed' AND type = 'video' THEN 500
|
WHEN status = 'completed' AND type = 'video' THEN 500
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
|
|||||||
Reference in New Issue
Block a user