Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
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 === 'h5' ? 'h5' : 'native';
|
||||
|
||||
let codeUrl = null;
|
||||
let h5Url = null;
|
||||
try {
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user