From 855fdfc4ff49d1a7796bf743f3756c9dc91a1d1b Mon Sep 17 00:00:00 2001 From: stringadmin Date: Mon, 8 Jun 2026 15:24:18 +0800 Subject: [PATCH 1/2] feat: add beta application review APIs --- src/routes/betaApplications.js | 388 +++++++++++++++++++++++++++++++++ src/routes/index.js | 2 + 2 files changed, 390 insertions(+) create mode 100644 src/routes/betaApplications.js diff --git a/src/routes/betaApplications.js b/src/routes/betaApplications.js new file mode 100644 index 0000000..0268674 --- /dev/null +++ b/src/routes/betaApplications.js @@ -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 }; diff --git a/src/routes/index.js b/src/routes/index.js index 7889472..ca12bd9 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -17,6 +17,7 @@ const { registerConversationRoutes } = require('./conversations') const { registerReportRoutes } = require('./reports') const { registerAssetRoutes } = require('./assets') const { registerNotificationRoutes } = require('./notifications') +const { registerBetaApplicationRoutes } = require('./betaApplications') const { registerDraftRoutes } = require('./drafts'); const { registerFileExtractRoutes } = require('./fileExtract'); const mountClientErrorRoutes = require('./clientErrors') @@ -48,6 +49,7 @@ registerConversationRoutes(router) registerReportRoutes(router) registerAssetRoutes(router) registerNotificationRoutes(router) +registerBetaApplicationRoutes(router) registerDraftRoutes(router) registerFileExtractRoutes(router) registerHealthRoutes(router) -- 2.52.0 From fdd408d06bcdf612612f0157cb2f8e1e52ac6b8d Mon Sep 17 00:00:00 2001 From: stringadmin Date: Mon, 8 Jun 2026 15:46:28 +0800 Subject: [PATCH 2/2] Convert billing to 1-to-100 credits --- src/billing.js | 63 ++++++++++++++++++++++++---------------- src/paymentAlipay.js | 12 ++++---- src/paymentWechat.js | 12 ++++---- src/pricing.js | 2 +- src/routes/admin.js | 27 +++++++++++++---- src/routes/context.js | 4 +++ src/routes/enterprise.js | 25 +++++++++++----- src/routes/user.js | 6 ++-- 8 files changed, 98 insertions(+), 53 deletions(-) diff --git a/src/billing.js b/src/billing.js index 37c5a90..7e6f0c9 100644 --- a/src/billing.js +++ b/src/billing.js @@ -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. * - * Money conventions: - * - balance: cents (分, 1/100 CNY) — stored in users.balance_cents and enterprises.balance_cents - * - prices: mills (厘, 1/1000 CNY) — stored in model_prices.*_mills - * - cost calculation: mills → convert to cents at deduction time (divide by 10, floor) - * - transactions: cents — amount_cents, balance_after_cents + * Unit conventions: + * - payment_orders.amount_cents / packages.price_cents: cash amount in CNY cents. + * - users.balance_cents / enterprises.balance_cents / transactions.amount_cents: + * credit units, where 100 units = 1 platform credit. + * - model_prices.*_mills: CNY mills. 1 CNY = 100 credits, so 1 mill = 10 credit units. * * Flow: * - 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 */ const { pool, withTransaction } = require("./db"); 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) { - 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))); } +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) { const enterpriseId = entry?.enterpriseId || null; const userId = entry?.userId || null; @@ -114,10 +129,6 @@ async function getEnterpriseName(enterpriseId) { return rows[0] ? rows[0].name : null; } -function millsToCents(mills) { - return Math.floor(mills / 10); -} - // ── Atomic balance helpers ─────────────────────────────────────────── async function atomicDeductUserBalance(client, userId, amountCents) { @@ -167,7 +178,7 @@ async function preauthorizeCall(userId, provider) { 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 WHERE provider = $1 AND status = 'success' @@ -185,10 +196,9 @@ async function preauthorizeCall(userId, provider) { const bufferedEstimate = Math.ceil(estimatedCostCents * 1.2); if (balanceCents < bufferedEstimate) { - const credits = Math.floor(balanceCents / 100); return { 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" }; } - const costCents = millsToCents(costMills); + const costCents = millsToCreditUnits(costMills); 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); @@ -408,7 +418,7 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge userId, -amountCents, newBal, - `API 调用扣费 ${Math.ceil(amountCents / 100)} 积分`, + `API 调用扣费 ${formatCreditsFromCents(amountCents)} 积分`, ], ); @@ -429,16 +439,15 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge if (newBalanceCents == null) { const currentBalance = await getUserBalanceCents(userId); - const credits = Math.floor((currentBalance || 0) / 100); return { success: false, - message: `积分不足 (当前 ${credits} 积分,需要 ${Math.ceil(amountCents / 100)} 积分)`, + message: `积分不足 (当前 ${formatCreditsFromCents(currentBalance || 0)} 积分,需要 ${formatCreditsFromCents(amountCents)} 积分)`, }; } return { 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, -diffCents, newBal, - `API 预估差额扣费 ${Math.ceil(diffCents / 100)} 积分`, + `API 预估差额扣费 ${formatCreditsFromCents(diffCents)} 积分`, ], ); } @@ -503,7 +512,7 @@ async function settleLease(leaseId, actualCostCents) { userId, refundCents, newBal, - `API 预估差额退回 ${Math.ceil(refundCents / 100)} 积分`, + `API 预估差额退回 ${formatCreditsFromCents(refundCents)} 积分`, ], ); } @@ -628,7 +637,7 @@ async function distributeCredits(enterpriseId, targetUserId, amountCents, adminU targetUserId, amountCents, newUserBal, - `从企业池获得 ${Math.floor(amountCents / 100)} 积分`, + `从企业池获得 ${formatCreditsFromCents(amountCents)} 积分`, adminUserId, ], ); @@ -761,4 +770,8 @@ module.exports = { preauthorizeCall, settleLease, forceSettleLease, + creditsToCreditUnits, + cashCentsToCreditUnits, + millsToCreditUnits, + formatCreditsFromCents, }; diff --git a/src/paymentAlipay.js b/src/paymentAlipay.js index 29c71d7..349fa5c 100644 --- a/src/paymentAlipay.js +++ b/src/paymentAlipay.js @@ -1,7 +1,7 @@ const fs = require("node:fs"); const { AlipaySdk } = require("alipay-sdk"); const { pool, withTransaction } = require("./db"); -const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); +const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing"); let alipayInstance = null; @@ -130,17 +130,19 @@ async function handlePaymentSuccess(orderNo, tradeNo) { ); if (order.type === "personal_recharge" && order.user_id) { + const creditUnits = cashCentsToCreditUnits(order.amount_cents); await creditUserBalance( order.user_id, - order.amount_cents, - `支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, + creditUnits, + `支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`, orderNo, ); } else if (order.type === "recharge") { + const creditUnits = cashCentsToCreditUnits(order.amount_cents); await creditBalance( order.enterprise_id, - order.amount_cents, - `支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, + creditUnits, + `支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`, orderNo, ); } else if (order.type === "package" && order.package_id) { diff --git a/src/paymentWechat.js b/src/paymentWechat.js index 05374d2..8173224 100644 --- a/src/paymentWechat.js +++ b/src/paymentWechat.js @@ -2,7 +2,7 @@ const _crypto = require("node:crypto"); const fs = require("node:fs"); const WxPay = require("wechatpay-node-v3"); const { pool, withTransaction } = require("./db"); -const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); +const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing"); let wxPayInstance = null; @@ -140,17 +140,19 @@ async function handlePaymentSuccess(orderNo, transactionId) { ); if (order.type === "personal_recharge" && order.user_id) { + const creditUnits = cashCentsToCreditUnits(order.amount_cents); await creditUserBalance( order.user_id, - order.amount_cents, - `微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, + creditUnits, + `微信充值 ${formatCreditsFromCents(creditUnits)} 积分`, orderNo, ); } else if (order.type === "recharge") { + const creditUnits = cashCentsToCreditUnits(order.amount_cents); await creditBalance( order.enterprise_id, - order.amount_cents, - `微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, + creditUnits, + `微信充值 ${formatCreditsFromCents(creditUnits)} 积分`, orderNo, ); } else if (order.type === "package" && order.package_id) { diff --git a/src/pricing.js b/src/pricing.js index 607fe83..e3bf12d 100644 --- a/src/pricing.js +++ b/src/pricing.js @@ -176,7 +176,7 @@ async function getAverageCostCents(provider) { const { rows } = await pool.query( ` 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 END)::numeric) AS INTEGER) AS avg_cents FROM api_call_logs diff --git a/src/routes/admin.js b/src/routes/admin.js index 05f41b4..99a419b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -6,6 +6,8 @@ const { listModelPrices, loadPriceCache, creditUserBalance, + creditsToCreditUnits, + formatCreditsFromCents, pool, validateUsername, validatePassword, @@ -156,14 +158,18 @@ function registerAdminRoutes(router) { router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => { const targetUserId = Number(req.params.id); - const { amountCents } = req.body; - if (!amountCents || amountCents <= 0) return res.status(400).json({ error: "积分必须大于 0" }); + const { amountCredits, amountCents } = req.body; + const creditUnits = + amountCredits !== undefined && amountCredits !== null && amountCredits !== "" + ? creditsToCreditUnits(amountCredits) + : Number(amountCents); + if (!creditUnits || creditUnits <= 0) return res.status(400).json({ error: "积分必须大于 0" }); try { const newBalance = await creditUserBalance( targetUserId, - amountCents, - `管理员 ${req.user.username} 发放 ${Math.floor(amountCents / 100)} 积分`, + creditUnits, + `管理员 ${req.user.username} 发放 ${formatCreditsFromCents(creditUnits)} 积分`, ); res.json({ success: true, newBalanceCents: newBalance }); } catch (err) { @@ -547,6 +553,8 @@ function registerAdminRoutes(router) { name, description = "", priceCents, + credits, + amountCredits, creditsCents = 0, imageQuota = 0, videoQuota = 0, @@ -572,7 +580,9 @@ function registerAdminRoutes(router) { name, description, Number(priceCents), - Number(creditsCents || 0), + credits !== undefined || amountCredits !== undefined + ? creditsToCreditUnits(credits ?? amountCredits) + : Number(creditsCents || 0), Number(imageQuota || 0), Number(videoQuota || 0), Number(textQuota || 0), @@ -599,6 +609,8 @@ function registerAdminRoutes(router) { name, description, priceCents, + credits, + amountCredits, creditsCents, imageQuota, videoQuota, @@ -623,7 +635,10 @@ function registerAdminRoutes(router) { updates.push(`price_cents = $${idx++}`); 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++}`); params.push(Number(creditsCents)); } diff --git a/src/routes/context.js b/src/routes/context.js index 2a98229..a15138c 100644 --- a/src/routes/context.js +++ b/src/routes/context.js @@ -32,6 +32,8 @@ const { getUserEnterpriseId, getEnterpriseName, preauthorizeCall, + creditsToCreditUnits, + formatCreditsFromCents, } = require("../billing"); const wechatPay = require("../paymentWechat"); const alipay = require("../paymentAlipay"); @@ -793,6 +795,8 @@ module.exports = { getUserEnterpriseId, getEnterpriseName, preauthorizeCall, + creditsToCreditUnits, + formatCreditsFromCents, wechatPay, alipay, crypto, diff --git a/src/routes/enterprise.js b/src/routes/enterprise.js index 88dd914..c5d152f 100644 --- a/src/routes/enterprise.js +++ b/src/routes/enterprise.js @@ -2,6 +2,7 @@ const { requireAuth, requireEnterpriseAdmin, distributeCredits, + creditsToCreditUnits, getEnterpriseFinancials, getEnterpriseName, pool, @@ -302,25 +303,33 @@ function registerEnterpriseRoutes(router) { }); router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => { - const { userId, amountCents, distributions } = req.body; + const { userId, amountCredits, amountCents, distributions } = req.body; try { if (distributions && Array.isArray(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 .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 }); - } else if (userId && amountCents) { - if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" }); + } else if (userId && (amountCredits || amountCents)) { + 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( req.user.enterpriseId, userId, - amountCents, + creditUnits, req.user.id, ); res.json({ success: true, ...result }); @@ -349,7 +358,7 @@ function registerEnterpriseRoutes(router) { u.username, u.balance_cents AS current_balance_cents, 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 FROM users u LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success' diff --git a/src/routes/user.js b/src/routes/user.js index 70c36f1..5e0c24e 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -136,7 +136,7 @@ function registerUserRoutes(router) { CASE WHEN billing_refunded = 1 THEN 0 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 ELSE 0 END @@ -162,7 +162,7 @@ function registerUserRoutes(router) { resolution = params.resolution || params.quality || params.ratio || null; if (row.status === "completed") { if (row.type === "image") { - estimatedCents = 20; + estimatedCents = 2000; } else if (row.type === "video") { const dur = params.duration || 5; const res = String(params.resolution || params.quality || "").toUpperCase(); @@ -209,7 +209,7 @@ function registerUserRoutes(router) { CASE WHEN billing_refunded = 1 THEN 0 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 ELSE 0 END -- 2.52.0