Files
memind/billing-recharge.mjs
T
Your Name b0f5d6a51c Extract memind_adm admin server, add local dev tooling, and remove image-generation.
Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 16:39:39 -07:00

339 lines
10 KiB
JavaScript

import crypto from 'node:crypto';
export function loadRechargeConfig() {
const tiers = (process.env.H5_RECHARGE_TIERS_CENTS ?? '500,1000,3000,5000,10000,20000')
.split(',')
.map((value) => Number(value.trim()))
.filter((value) => Number.isFinite(value) && value > 0);
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),
dailyLimitCents: Number(process.env.H5_RECHARGE_DAILY_LIMIT_CENTS ?? 200_000),
};
}
export function isAllowedRechargeAmount(amountCents, config = loadRechargeConfig()) {
return config.tiersCents.includes(Number(amountCents));
}
export function buildInsufficientBalancePayload(balanceCents, config = loadRechargeConfig()) {
return {
code: 'INSUFFICIENT_BALANCE',
message: '余额不足,请充值后继续使用',
balanceCents: Number(balanceCents ?? 0),
minRechargeCents: config.minRechargeCents,
suggestedTiers: config.tiersCents,
};
}
function createOutTradeNo() {
const stamp = Date.now().toString(36).toUpperCase();
const rand = crypto.randomBytes(4).toString('hex').toUpperCase();
return `TK${stamp}${rand}`.slice(0, 32);
}
function mapOrderRow(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
amountCents: Number(row.amount_cents),
channel: row.channel,
status: row.status,
payMode: row.pay_mode,
outTradeNo: row.out_trade_no,
providerTxn: row.provider_txn,
paidAt: row.paid_at == null ? null : Number(row.paid_at),
expireAt: Number(row.expire_at),
createdAt: Number(row.created_at),
codeUrl: row.code_url ?? null,
h5Url: row.h5_url ?? null,
};
}
export function createRechargeService(pool, { userAuth, wechatPay, config = loadRechargeConfig() } = {}) {
const expireStaleOrders = async (userId) => {
const now = Date.now();
await pool.query(
`UPDATE h5_payment_orders
SET status = 'expired', updated_at = ?
WHERE user_id = ? AND status = 'pending' AND expire_at <= ?`,
[now, userId, now],
);
};
const countPendingOrders = async (userId) => {
const now = Date.now();
const [rows] = await pool.query(
`SELECT COUNT(*) AS total
FROM h5_payment_orders
WHERE user_id = ? AND status = 'pending' AND expire_at > ?`,
[userId, now],
);
return Number(rows[0]?.total ?? 0);
};
const sumPaidToday = async (userId) => {
const start = new Date();
start.setHours(0, 0, 0, 0);
const [rows] = await pool.query(
`SELECT COALESCE(SUM(amount_cents), 0) AS total
FROM h5_payment_orders
WHERE user_id = ? AND status = 'paid' AND paid_at >= ?`,
[userId, start.getTime()],
);
return Number(rows[0]?.total ?? 0);
};
const getOrderById = async (orderId) => {
const [rows] = await pool.query(`SELECT * FROM h5_payment_orders WHERE id = ? LIMIT 1`, [
orderId,
]);
return mapOrderRow(rows[0]);
};
const getOrderByOutTradeNo = async (outTradeNo) => {
const [rows] = await pool.query(
`SELECT * FROM h5_payment_orders WHERE out_trade_no = ? LIMIT 1`,
[outTradeNo],
);
return mapOrderRow(rows[0]);
};
const getBillingConfig = async (userId) => {
const user = await userAuth.getUserById(userId);
return {
wechatEnabled: Boolean(wechatPay?.enabled),
tiersCents: config.tiersCents,
minRechargeCents: config.minRechargeCents,
balanceCents: user ? Number(user.balance_cents ?? 0) : 0,
};
};
const createOrder = async ({ userId, amountCents, payScene, clientIp }) => {
const amount = Number(amountCents);
if (!isAllowedRechargeAmount(amount, config)) {
return { ok: false, message: '充值金额不在允许档位内' };
}
if (!wechatPay?.enabled) {
return { ok: false, message: '微信支付尚未配置,请联系管理员' };
}
const user = await userAuth.getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
if (user.status === 'disabled') return { ok: false, message: '账户已禁用' };
await expireStaleOrders(userId);
const pending = await countPendingOrders(userId);
if (pending >= config.maxPendingOrders) {
return { ok: false, message: '待支付订单过多,请先完成或等待过期后再试' };
}
const paidToday = await sumPaidToday(userId);
if (paidToday + amount > config.dailyLimitCents) {
return { ok: false, message: '已超过今日充值上限,请明日再试' };
}
const now = Date.now();
const orderId = crypto.randomUUID();
const outTradeNo = createOutTradeNo();
const expireAt = now + config.orderTtlMs;
const description = `TKMind账户充值¥${(amount / 100).toFixed(2)}`;
const mode =
payScene === 'jsapi' ? 'jsapi' : payScene === 'h5' ? 'h5' : 'native';
let codeUrl = null;
let h5Url = null;
let jsapiParams = null;
try {
if (mode === 'jsapi') {
const appId = wechatPay.appId;
if (!appId) {
return { ok: false, message: '微信支付 AppID 未配置' };
}
const openid = await userAuth.getWechatOpenidForUser(userId, appId);
if (!openid) {
return {
ok: false,
message: '请先用微信登录并绑定账号后再充值',
};
}
const result = await wechatPay.createJsapiOrder({
outTradeNo,
description,
amountCents: amount,
clientIp,
openid,
});
jsapiParams = result.jsapiParams;
} else if (mode === 'h5') {
const result = await wechatPay.createH5Order({
outTradeNo,
description,
amountCents: amount,
clientIp,
});
h5Url = result.h5Url;
} else {
const result = await wechatPay.createNativeOrder({
outTradeNo,
description,
amountCents: amount,
clientIp,
});
codeUrl = result.codeUrl;
}
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : '创建支付订单失败',
};
}
await pool.query(
`INSERT INTO h5_payment_orders
(id, user_id, amount_cents, channel, status, pay_mode, out_trade_no,
code_url, h5_url, expire_at, client_ip, created_at, updated_at)
VALUES (?, ?, ?, 'wechat', 'pending', ?, ?, ?, ?, ?, ?, ?, ?)`,
[
orderId,
userId,
amount,
mode,
outTradeNo,
codeUrl,
h5Url,
expireAt,
clientIp ?? null,
now,
now,
],
);
return {
ok: true,
order: {
id: orderId,
amountCents: amount,
status: 'pending',
payMode: mode,
expireAt,
codeUrl,
h5Url,
jsapiParams,
},
};
};
const fulfillOrder = async (order, transaction) => {
if (!order || order.status === 'paid') {
return { ok: true, alreadyPaid: true };
}
if (order.status !== 'pending') {
return { ok: false, message: '订单状态不可支付' };
}
const tradeState =
transaction?.trade_state ??
(transaction?.result_code === 'SUCCESS' ? 'SUCCESS' : transaction?.result_code) ??
transaction?.trade_state_desc;
if (tradeState && tradeState !== 'SUCCESS') {
return { ok: false, message: `支付未成功: ${tradeState}` };
}
const paidAmount = Number(
transaction?.amount?.total ??
transaction?.amount?.payer_total ??
transaction?.total_fee ??
0,
);
if (paidAmount !== order.amountCents) {
return { ok: false, message: '支付金额与订单不一致' };
}
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT * FROM h5_payment_orders WHERE id = ? FOR UPDATE`,
[order.id],
);
const locked = mapOrderRow(rows[0]);
if (!locked) {
await conn.rollback();
return { ok: false, message: '订单不存在' };
}
if (locked.status === 'paid') {
await conn.commit();
return { ok: true, alreadyPaid: true };
}
if (locked.status !== 'pending') {
await conn.rollback();
return { ok: false, message: '订单状态不可支付' };
}
const recharge = await userAuth.recharge(locked.userId, locked.amountCents, null, '用户自助充值', {
paymentOrderId: locked.id,
conn,
});
if (!recharge.ok) {
await conn.rollback();
return recharge;
}
await conn.query(
`UPDATE h5_payment_orders
SET status = 'paid', provider_txn = ?, paid_at = ?, updated_at = ?
WHERE id = ?`,
[transaction.transaction_id ?? transaction.out_trade_no ?? null, now, now, locked.id],
);
await conn.commit();
return { ok: true, user: recharge.user, balanceCents: recharge.user.balanceCents };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
};
const handleWechatNotify = async ({ headers, body }) => {
const { transaction } = wechatPay.verifyNotify({ headers, body });
const outTradeNo = transaction?.out_trade_no;
if (!outTradeNo) {
throw new Error('回调缺少商户订单号');
}
const order = await getOrderByOutTradeNo(outTradeNo);
if (!order) {
throw new Error('订单不存在');
}
return fulfillOrder(order, transaction);
};
const getOrderForUser = async (userId, orderId) => {
const order = await getOrderById(orderId);
if (!order || order.userId !== userId) return null;
if (order.status === 'pending' && order.expireAt <= Date.now()) {
await pool.query(
`UPDATE h5_payment_orders SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'pending'`,
[Date.now(), orderId],
);
return { ...order, status: 'expired' };
}
return order;
};
return {
config,
getBillingConfig,
createOrder,
getOrderForUser,
handleWechatNotify,
fulfillOrder,
getOrderByOutTradeNo,
};
}