Files
memind/billing-recharge.mjs
T
john fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 08:06:12 +08:00

375 lines
12 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,
orderPurpose: row.order_purpose ?? 'recharge',
orderPurposeRef: row.order_purpose_ref ?? null,
};
}
export function createRechargeService(
pool,
{ userAuth, wechatPay, templateCatalogService = null, 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: '订单状态不可支付' };
}
if (locked.orderPurpose === 'template_purchase') {
if (!templateCatalogService?.fulfillWechatPurchaseLocked) {
await conn.rollback();
return { ok: false, message: '模板购买服务未启用' };
}
const templateResult = await templateCatalogService.fulfillWechatPurchaseLocked({
conn,
order: locked,
transaction,
now,
});
if (!templateResult.ok) {
await conn.rollback();
return templateResult;
}
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: await userAuth.getUserById(locked.userId),
balanceCents: templateResult.balanceCents,
grantedSkills: templateResult.grantedSkills,
skillName: templateResult.skillName,
};
}
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,
};
}