b0f5d6a51c
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>
339 lines
10 KiB
JavaScript
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,
|
|
};
|
|
}
|