From 03690ee354f0648d9257ac17f8ab9c53da924cd1 Mon Sep 17 00:00:00 2001 From: John Date: Tue, 16 Jun 2026 03:10:05 -0700 Subject: [PATCH] =?UTF-8?q?Fix=20duplicate=20session=20billing=20and=20ali?= =?UTF-8?q?gn=20pricing=20with=20DeepSeek=20=C3=973.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialize billSessionUsage with row locks, expose rates on recharge, add compensation script and admin usage views. Co-authored-by: Cursor --- .env.example | 12 ++- billing-recharge.mjs | 8 +- billing.mjs | 24 ++++- billing.test.mjs | 43 +++++++- db.mjs | 6 ++ schema.sql | 1 + scripts/compensate-user-billing.mjs | 153 ++++++++++++++++++++++++++ server.mjs | 21 +++- src/api/client.ts | 26 ++++- src/components/RechargeModal.tsx | 7 ++ src/index.css | 30 ++++++ src/types.ts | 15 +++ src/utils/billing.ts | 17 +++ user-auth.mjs | 161 ++++++++++++++++++++++------ 14 files changed, 472 insertions(+), 52 deletions(-) create mode 100644 scripts/compensate-user-billing.mjs create mode 100644 src/utils/billing.ts diff --git a/.env.example b/.env.example index 6b0a786..6e30f03 100644 --- a/.env.example +++ b/.env.example @@ -49,18 +49,20 @@ H5_ADMIN_PASSWORD=change-me-admin H5_ACCESS_PASSWORD=change-me # 计费(Phase 2,金额单位均为人民币分) -# 默认按 Token 单价扣费(人民币结算);勿开启美元成本换算除非明确需要 +# 默认:DeepSeek V4 Flash 公开价 × 3(随 H5_USD_CNY_RATE 换算);可用下方变量覆盖 # H5_USE_BACKEND_COST=0 -# H5_BILL_INPUT_CENTS_PER_1K=2 # 输入 Token:2 分/1k(¥0.02/1k) -# H5_BILL_OUTPUT_CENTS_PER_1K=6 # 输出 Token:6 分/1k(¥0.06/1k) +# H5_USD_CNY_RATE=7.2 +# 汇率 7.2 时默认约:输入 0.302 分/1k(¥0.00302/1k)、输出 0.605 分/1k(¥0.00605/1k) +# H5_BILL_INPUT_CENTS_PER_1K=0.302 +# H5_BILL_OUTPUT_CENTS_PER_1K=0.605 # H5_MIN_BILL_CENTS=1 # 仅调试:H5_USE_BACKEND_COST=1 + H5_USD_CNY_RATE=7.2(上游 USD × 汇率 → 人民币分) # 用户自助充值(微信支付) # H5_RECHARGE_TIERS_CENTS=500,1000,3000,5000,10000,20000 # H5_MIN_RECHARGE_CENTS=500 -# H5_RECHARGE_ORDER_TTL_MS=900000 -# H5_RECHARGE_MAX_PENDING=3 +# H5_RECHARGE_ORDER_TTL_MS=180000 +# H5_RECHARGE_MAX_PENDING=5 # H5_RECHARGE_DAILY_LIMIT_CENTS=200000 # H5_WECHAT_PAY_ENABLED=1 # H5_WECHAT_APP_ID=wx... diff --git a/billing-recharge.mjs b/billing-recharge.mjs index ad38956..82c09dd 100644 --- a/billing-recharge.mjs +++ b/billing-recharge.mjs @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import { loadBillingConfig } from './billing.mjs'; export function loadRechargeConfig() { const tiers = (process.env.H5_RECHARGE_TIERS_CENTS ?? '500,1000,3000,5000,10000,20000') @@ -8,8 +9,8 @@ export function loadRechargeConfig() { return { tiersCents: tiers.length ? tiers : [500, 1000, 3000, 5000, 10000, 20000], minRechargeCents: Number(process.env.H5_MIN_RECHARGE_CENTS ?? 500), - orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 15 * 60 * 1000), - maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 3), + orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 3 * 60 * 1000), + maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 5), dailyLimitCents: Number(process.env.H5_RECHARGE_DAILY_LIMIT_CENTS ?? 200_000), }; } @@ -104,11 +105,14 @@ export function createRechargeService(pool, { userAuth, wechatPay, config = load const getBillingConfig = async (userId) => { const user = await userAuth.getUserById(userId); + const billing = loadBillingConfig(); return { wechatEnabled: Boolean(wechatPay?.enabled), tiersCents: config.tiersCents, minRechargeCents: config.minRechargeCents, balanceCents: user ? Number(user.balance_cents ?? 0) : 0, + inputCentsPer1k: billing.inputCentsPer1k, + outputCentsPer1k: billing.outputCentsPer1k, }; }; diff --git a/billing.mjs b/billing.mjs index 1a0572f..6763734 100644 --- a/billing.mjs +++ b/billing.mjs @@ -1,11 +1,29 @@ +// DeepSeek V4 Flash 公开价($/1M tokens,cache-miss 输入);见 api-docs.deepseek.com +export const DEEPSEEK_FLASH_INPUT_USD_PER_1M = 0.14; +export const DEEPSEEK_FLASH_OUTPUT_USD_PER_1M = 0.28; +export const DEEPSEEK_BILLING_MARKUP = 3; + +function deepSeekFlashCentsPer1k(usdPer1M, usdCnyRate) { + return (usdPer1M / 1000) * usdCnyRate * 100 * DEEPSEEK_BILLING_MARKUP; +} + export function loadBillingConfig() { // 默认按人民币分(CNY cents)计费;仅当 H5_USE_BACKEND_COST=1 时才用上游 USD 成本换算。 const useBackendCost = process.env.H5_USE_BACKEND_COST === '1'; + const usdCnyRate = Number(process.env.H5_USD_CNY_RATE ?? 7.2); + const defaultInputCentsPer1k = deepSeekFlashCentsPer1k( + DEEPSEEK_FLASH_INPUT_USD_PER_1M, + usdCnyRate, + ); + const defaultOutputCentsPer1k = deepSeekFlashCentsPer1k( + DEEPSEEK_FLASH_OUTPUT_USD_PER_1M, + usdCnyRate, + ); return { useBackendCost, - usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2), - inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? 2), - outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6), + usdCnyRate, + inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? defaultInputCentsPer1k), + outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? defaultOutputCentsPer1k), minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1), }; } diff --git a/billing.test.mjs b/billing.test.mjs index 5279949..f3d65bc 100644 --- a/billing.test.mjs +++ b/billing.test.mjs @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { computeDeltaCostCents, normalizeTokenState } from './billing.mjs'; +import { + computeDeltaCostCents, + loadBillingConfig, + normalizeTokenState, +} from './billing.mjs'; const config = { useBackendCost: false, @@ -10,6 +14,43 @@ const config = { minBillCents: 1, }; +test('loadBillingConfig defaults to DeepSeek Flash × 3 at USD/CNY 7.2', () => { + const savedInput = process.env.H5_BILL_INPUT_CENTS_PER_1K; + const savedOutput = process.env.H5_BILL_OUTPUT_CENTS_PER_1K; + const savedRate = process.env.H5_USD_CNY_RATE; + delete process.env.H5_BILL_INPUT_CENTS_PER_1K; + delete process.env.H5_BILL_OUTPUT_CENTS_PER_1K; + process.env.H5_USD_CNY_RATE = '7.2'; + try { + const billing = loadBillingConfig(); + assert.ok(Math.abs(billing.inputCentsPer1k - 0.3024) < 1e-9); + assert.ok(Math.abs(billing.outputCentsPer1k - 0.6048) < 1e-9); + } finally { + if (savedInput == null) delete process.env.H5_BILL_INPUT_CENTS_PER_1K; + else process.env.H5_BILL_INPUT_CENTS_PER_1K = savedInput; + if (savedOutput == null) delete process.env.H5_BILL_OUTPUT_CENTS_PER_1K; + else process.env.H5_BILL_OUTPUT_CENTS_PER_1K = savedOutput; + if (savedRate == null) delete process.env.H5_USD_CNY_RATE; + else process.env.H5_USD_CNY_RATE = savedRate; + } +}); + +test('computeDeltaCostCents bills DeepSeek Flash × 3 defaults with min charge', () => { + const billing = { + useBackendCost: false, + usdCnyRate: 7.2, + inputCentsPer1k: 0.3024, + outputCentsPer1k: 0.6048, + minBillCents: 1, + }; + const previous = { lastInputTokens: 0, lastOutputTokens: 0 }; + const current = normalizeTokenState({ + accumulatedInputTokens: 1000, + accumulatedOutputTokens: 500, + }); + assert.equal(computeDeltaCostCents(previous, current, billing), 1); +}); + test('normalizeTokenState reads camelCase fields', () => { const state = normalizeTokenState({ inputTokens: 10, diff --git a/db.mjs b/db.mjs index 0c11c60..184c240 100644 --- a/db.mjs +++ b/db.mjs @@ -255,6 +255,12 @@ export async function migrateSchema(pool) { `ALTER TABLE h5_payment_orders MODIFY pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native'`, ); + + if (!(await indexExists(pool, 'h5_usage_records', 'idx_h5_usage_created'))) { + await pool.query( + `ALTER TABLE h5_usage_records ADD KEY idx_h5_usage_created (created_at)`, + ); + } } export async function initSchema(pool) { diff --git a/schema.sql b/schema.sql index 3b31f95..72d5a0d 100644 --- a/schema.sql +++ b/schema.sql @@ -354,6 +354,7 @@ CREATE TABLE IF NOT EXISTS h5_usage_records ( balance_after_cents BIGINT NOT NULL, created_at BIGINT NOT NULL, KEY idx_h5_usage_user_time (user_id, created_at), + KEY idx_h5_usage_created (created_at), CONSTRAINT fk_h5_usage_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/scripts/compensate-user-billing.mjs b/scripts/compensate-user-billing.mjs new file mode 100644 index 0000000..41775dc --- /dev/null +++ b/scripts/compensate-user-billing.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * One-off billing compensation for duplicate charges / rate overcharge. + * + * Usage: + * node scripts/compensate-user-billing.mjs wx_mk4zzaps + * node scripts/compensate-user-billing.mjs wx_mk4zzaps --apply + * node scripts/compensate-user-billing.mjs wx_mk4zzaps --apply --include-rate-adjustment + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import mysql from 'mysql2/promise'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '.env')); + +const username = process.argv[2]; +const apply = process.argv.includes('--apply'); +const includeRateAdjustment = process.argv.includes('--include-rate-adjustment'); + +if (!username) { + console.error('Usage: node scripts/compensate-user-billing.mjs [--apply] [--include-rate-adjustment]'); + process.exit(1); +} + +if (!process.env.DATABASE_URL) { + console.error('DATABASE_URL is not configured'); + process.exit(1); +} + +/** wx_mk4zzaps duplicate Finish billing (2026-06-16 session 20260616_600). */ +const DUPLICATE_COST_CENTS = 1735; +const DUPLICATE_TOKENS = 838567; +/** Unique bills at 2/6 分/1k vs documented DeepSeek ×3 default (0.302/0.605 分/1k). */ +const RATE_OVERCHARGE_CENTS = 1467; + +async function main() { + const pool = mysql.createPool(process.env.DATABASE_URL); + try { + const [users] = await pool.query( + `SELECT u.id, u.username, u.display_name, u.status, + COALESCE(w.balance_cents, 0) AS balance_cents, + COALESCE(w.tokens_used, 0) AS tokens_used + FROM h5_users u + LEFT JOIN h5_user_wallets w ON w.user_id = u.id + WHERE u.username = ? + LIMIT 1`, + [username], + ); + const user = users[0]; + if (!user) { + console.error(`User not found: ${username}`); + process.exit(1); + } + + const amountCents = DUPLICATE_COST_CENTS + (includeRateAdjustment ? RATE_OVERCHARGE_CENTS : 0); + const tokenAdjust = -DUPLICATE_TOKENS; + const note = includeRateAdjustment + ? `补偿:重复扣费 ¥${(DUPLICATE_COST_CENTS / 100).toFixed(2)} + 费率差额 ¥${(RATE_OVERCHARGE_CENTS / 100).toFixed(2)}` + : `补偿:重复扣费 ¥${(DUPLICATE_COST_CENTS / 100).toFixed(2)}(session 20260616_600)`; + + console.log('User:', user.username, user.display_name ?? '', `(${user.id})`); + console.log('Current status:', user.status); + console.log('Current balance:', `¥${(Number(user.balance_cents) / 100).toFixed(2)}`); + console.log('Current tokens_used:', Number(user.tokens_used).toLocaleString()); + console.log(''); + console.log('Compensation plan:'); + console.log(' balance +', `¥${(amountCents / 100).toFixed(2)}`, `(${amountCents} cents)`); + if (tokenAdjust !== 0) { + console.log(' tokens_used', tokenAdjust.toLocaleString(), '(remove duplicate token count)'); + } + console.log(' note:', note); + console.log(' reactivate account if suspended:', user.status === 'suspended' ? 'yes' : 'no'); + + if (!apply) { + console.log(''); + console.log('Dry run only. Re-run with --apply to execute.'); + return; + } + + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) + VALUES (?, ?, 0, ?) + ON DUPLICATE KEY UPDATE + balance_cents = balance_cents + VALUES(balance_cents), + tokens_used = GREATEST(CAST(tokens_used AS SIGNED) + ?, 0), + updated_at = VALUES(updated_at)`, + [user.id, amountCents, now, tokenAdjust], + ); + await conn.query( + `INSERT INTO h5_billing_ledger + (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'adjust', ?, ?, ?, NULL, ?)`, + [user.id, amountCents, tokenAdjust, note, now], + ); + if (user.status === 'suspended') { + await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [ + now, + user.id, + ]); + } + await conn.commit(); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + + const [updatedRows] = await pool.query( + `SELECT u.status, COALESCE(w.balance_cents, 0) AS balance_cents, + COALESCE(w.tokens_used, 0) AS tokens_used + FROM h5_users u + LEFT JOIN h5_user_wallets w ON w.user_id = u.id + WHERE u.id = ? + LIMIT 1`, + [user.id], + ); + const updated = updatedRows[0]; + console.log(''); + console.log('Applied.'); + console.log('New status:', updated.status); + console.log('New balance:', `¥${(Number(updated.balance_cents) / 100).toFixed(2)}`); + console.log('New tokens_used:', Number(updated.tokens_used).toLocaleString()); + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server.mjs b/server.mjs index cf4beeb..a84fa4f 100644 --- a/server.mjs +++ b/server.mjs @@ -804,9 +804,16 @@ adminApi.post('/users/:userId/recharge', requireAdmin, async (req, res) => { res.json({ user: result.user }); }); +adminApi.get('/usage/summary', requireAdmin, async (req, res) => { + const userId = typeof req.query.userId === 'string' ? req.query.userId : null; + const summary = await userAuth.getUsageSummary({ userId }); + if (userId && !summary) return res.status(404).json({ message: '用户不存在' }); + res.json({ summary }); +}); + adminApi.get('/usage', requireAdmin, async (req, res) => { const userId = typeof req.query.userId === 'string' ? req.query.userId : null; - const limit = Number(req.query.limit ?? 50); + const limit = Number(req.query.limit ?? 20); const records = await userAuth.listUsageRecords({ userId, limit }); res.json({ records }); }); @@ -1055,12 +1062,22 @@ adminApi.post('/plaza/posts/:id/review', requireAdmin, async (req, res) => { } }); +app.get('/auth/usage/summary', async (req, res) => { + await userAuthReady; + if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + const summary = await userAuth.getUsageSummary({ userId: me.id }); + res.json({ summary }); +}); + app.get('/auth/usage', async (req, res) => { await userAuthReady; if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); const me = await userAuth.getMe(userToken(req)); if (!me) return res.status(401).json({ message: '未登录' }); - const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 }); + const limit = Number(req.query.limit ?? 20); + const records = await userAuth.listUsageRecords({ userId: me.id, limit }); res.json({ records }); }); diff --git a/src/api/client.ts b/src/api/client.ts index c1020ad..eb340ab 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -41,6 +41,7 @@ import type { SessionEvent, SessionListResponse, UsageRecord, + UsageSummary, BalanceUpdate, } from '../types'; @@ -299,8 +300,14 @@ export async function getMe(): Promise<{ return portalFetch('/auth/me'); } -export async function getMyUsage(): Promise { - const result = await portalFetch<{ records: UsageRecord[] }>('/auth/usage'); +export async function getMyUsageSummary(): Promise { + const result = await portalFetch<{ summary: UsageSummary }>('/auth/usage/summary'); + return result.summary; +} + +export async function getMyUsage(limit = 20): Promise { + const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : ''; + const result = await portalFetch<{ records: UsageRecord[] }>(`/auth/usage${query}`); return result.records ?? []; } @@ -1010,8 +1017,19 @@ export async function getAdminDashboardSummary(): Promise return result.summary; } -export async function listAdminUsage(userId?: string): Promise { - const query = userId ? `?userId=${encodeURIComponent(userId)}` : ''; +export async function getAdminUsageSummary(userId?: string): Promise { + const params = new URLSearchParams(); + if (userId) params.set('userId', userId); + const query = params.toString() ? `?${params.toString()}` : ''; + const result = await portalFetch<{ summary: UsageSummary }>(`/admin-api/usage/summary${query}`); + return result.summary; +} + +export async function listAdminUsage(userId?: string, limit = 20): Promise { + const params = new URLSearchParams(); + if (userId) params.set('userId', userId); + if (limit) params.set('limit', String(limit)); + const query = params.toString() ? `?${params.toString()}` : ''; const result = await portalFetch<{ records: UsageRecord[] }>(`/admin-api/usage${query}`); return result.records ?? []; } diff --git a/src/components/RechargeModal.tsx b/src/components/RechargeModal.tsx index a489056..23d006f 100644 --- a/src/components/RechargeModal.tsx +++ b/src/components/RechargeModal.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import QRCode from 'qrcode'; import { createRechargeOrder, getBillingConfig, getRechargeOrder } from '../api/client'; import type { BillingConfig, RechargeOrder } from '../types'; +import { formatBillingRates } from '../utils/billing'; import { invokeWechatJsapiPay, isWeChatBrowser } from '../utils/wechatPay'; function formatYuan(cents: number) { @@ -215,6 +216,12 @@ export function RechargeModal({

最低充值 {formatYuan(config?.minRechargeCents ?? 500)},用于 AI 对话按量扣费

+ {config && ( +

+ 当前费率:{formatBillingRates(config.inputCentsPer1k, config.outputCentsPer1k)} + 。Agent 模式含工具与记忆,实际消耗通常高于普通聊天。 +

+ )}
{tiers.map((tier) => (