import cloud from "@lafjs/cloud"; import { createHmac, timingSafeEqual, randomBytes } from "crypto"; import { failure, reject, identifier, key, open } from "@/goldMiner/config"; import { collection, authenticate, getUser, insertOnce, periodById } from "@/goldMiner/service"; import { prepareOrder, fulfillOrder, paymentInfo as nativePaymentInfo } from "@/goldMiner/payment"; import { merchantConfig, assertMerchantOrder, prepayMerchant, queryMerchant, closeMerchant, signMerchant } from "@/goldMiner/merchant"; const orders = () => collection("order"); const channel = "legacy_ios"; const terminal = ["closed", "paid"]; function ticketSecret() { const secret = process.env.GOLD_MINER_ORDER_TICKET_SECRET; if (!secret || Buffer.byteLength(secret) < 32) reject("PAYMENT_CONFIG_UNAVAILABLE", "GOLD_MINER_ORDER_TICKET_SECRET 必须配置且长度至少为 32 字节"); return secret; } function ticket(order: any, purpose: "customer" | "handoff", expiresAt: number) { const payload = Buffer.from(JSON.stringify({ order: order.outTradeNo, uid: order.goldMiner.uid, openid: order.openid, env: order.paymentAppEnv, purpose, expiresAt })).toString("base64url"); return payload + "." + createHmac("sha256", ticketSecret()).update(payload).digest("hex"); } function verifyTicket(value: any, order: any, purpose: "customer" | "handoff") { if (typeof value !== "string" || value.length > 2000) reject("UNAUTHORIZED"); const [payload, signature, extra] = value.split("."); if (extra || !/^[a-f0-9]{64}$/.test(signature || "")) reject("UNAUTHORIZED"); const expected = createHmac("sha256", ticketSecret()).update(payload).digest(); if (!timingSafeEqual(expected, Buffer.from(signature, "hex"))) reject("UNAUTHORIZED"); let data: any; try { data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); } catch { reject("UNAUTHORIZED"); } if (data.order !== order.outTradeNo || data.uid !== order.goldMiner.uid || data.openid !== order.openid || data.env !== order.paymentAppEnv || data.purpose !== purpose || !Number.isSafeInteger(data.expiresAt) || data.expiresAt <= Date.now()) reject("UNAUTHORIZED"); } /** Authenticated pre-order: payment link creation never trusts client price, count or order number. */ export async function createLegacyOrder(body: any) { if (body.channel !== channel) reject("INVALID_INPUT"); if (process.env.GOLD_MINER_LEGACY_ENABLED !== "true") reject("PAYMENT_CHANNEL_DISABLED"); const user = await authenticate(body), cfg = merchantConfig(); ticketSecret(); const order = await prepareOrder(body, user, "ios", channel, { merchantSnapshot: { mchid: cfg.mchid, appid: cfg.appid, notifyUrl: cfg.notifyUrl, pageUrl: cfg.pageUrl, checkUrl: cfg.checkUrl }, prepayState: "created", ioLeaseUntil: 0, nextQueryAt: 0, queryAttempts: 0, }); assertMerchantOrder(order); if (order.goldMiner.prepayState === "closed") reject("ORDER_CLOSED"); const expiresAt = Math.min(Date.now() + 15 * 60000, order.goldMiner.endsAt); return { outTradeNo: order.outTradeNo, periodId: order.goldMiner.periodId, priceFen: order.goodsPrice, productId: order.itemid, quantity: 1, channel, expiresAt, sessionFrom: JSON.stringify({ tpye: "ios", propName: order.itemid, outTradeNo: order.outTradeNo, goldMinerTicket: ticket(order, "customer", expiresAt) }) }; } async function takeLease(order: any, dueOnly = false) { const lease = randomBytes(16).toString("hex"); const result = await orders().updateOne({ _id: order._id, "goldMiner.confirmedAt": 0, "goldMiner.ioLeaseUntil": { $lte: Date.now() }, ...(dueOnly ? { "goldMiner.nextQueryAt": { $lte: Date.now() } } : {}), }, { $set: { "goldMiner.ioLeaseUntil": Date.now() + 30000, "goldMiner.ioLeaseToken": lease } }); return result.modifiedCount === 1 ? lease : null; } async function releaseLease(order: any, lease: string) { await orders().updateOne({ _id: order._id, "goldMiner.ioLeaseToken": lease }, { $set: { "goldMiner.ioLeaseUntil": 0 } }); } async function patchPending(order: any, lease: string, patch: any) { const fields = Object.fromEntries(Object.entries(patch).map(([k, v]) => ["goldMiner." + k, v])); await orders().updateOne({ _id: order._id, "goldMiner.ioLeaseToken": lease, "goldMiner.confirmedAt": 0 }, { $set: fields }); } /** Only call with a verified provider response/notification, never with a client request body. */ export async function confirmMerchantTransaction(transaction: any, source: "merchant_notify" | "merchant_query") { if (typeof transaction?.out_trade_no !== "string") reject("INVALID_PAYMENT"); const order = await orders().findOne({ outTradeNo: transaction.out_trade_no }); if (!order) reject("NOT_FOUND"); const cfg = assertMerchantOrder(order); const paidAt = Date.parse(transaction.success_time); if (transaction.trade_state !== "SUCCESS" || transaction.trade_type !== "JSAPI" || transaction.mchid !== cfg.mchid || transaction.appid !== cfg.appid || transaction.payer?.openid !== order.openid || !Number.isSafeInteger(transaction.amount?.total) || transaction.amount.total !== order.goodsPrice || transaction.amount.currency !== "CNY" || typeof transaction.transaction_id !== "string" || !transaction.transaction_id || !Number.isFinite(paidAt) || paidAt <= 0 || paidAt > Date.now() + 300000 || (transaction.attach && transaction.attach !== order.goldMiner.periodId)) reject("INVALID_PAYMENT"); const user = await getUser(order.goldMiner.uid); if (!user || user.openid !== order.openid) reject("INVALID_PAYMENT"); if (order.goldMiner.transactionId && order.goldMiner.transactionId !== transaction.transaction_id) reject("PAYMENT_CONFLICT"); await orders().updateOne({ _id: order._id, "goldMiner.confirmedAt": 0 }, { $set: { state: 1, chargeTime: new Date(Date.now() + 8 * 3600000), "goldMiner.confirmedAt": Date.now(), "goldMiner.transactionId": transaction.transaction_id, "goldMiner.paidAt": paidAt, "goldMiner.confirmationSource": source, "goldMiner.prepayState": "paid", } }); const saved = await orders().findOne({ _id: order._id }); if (saved.goldMiner.transactionId !== transaction.transaction_id) reject("PAYMENT_CONFLICT"); return saved; } async function queryAndRecord(order: any) { const transaction = await queryMerchant(order); // Even non-success results must belong to this merchant and this exact order. if (transaction.out_trade_no !== order.outTradeNo || transaction.mchid !== order.goldMiner.merchantSnapshot.mchid || transaction.appid !== order.goldMiner.merchantSnapshot.appid) reject("INVALID_PAYMENT"); if (transaction.trade_state === "SUCCESS") { await confirmMerchantTransaction(transaction, "merchant_query"); await fulfillOrder(order.outTradeNo); } return transaction.trade_state; } /** Query failures retain the order; due time and a lease bound login/poll/timer work. */ export async function reconcileLegacyOrder(order: any) { assertMerchantOrder(order); if (order.goldMiner.confirmedAt) return fulfillOrder(order.outTradeNo); if (terminal.includes(order.goldMiner.prepayState)) return; const lease = await takeLease(order, true); if (!lease) return; const attempts = Math.min((order.goldMiner.queryAttempts || 0) + 1, 12); try { let state: string; try { state = await queryAndRecord(order); } catch (error: any) { if (error.errorCode !== "MERCHANT_ORDER_NOT_EXIST") throw error; state = "NOT_EXIST"; } if (state === "SUCCESS") return; if (state === "REFUND") { await patchPending(order, lease, { prepayState: "closed", fulfillmentStatus: "manual_review", lastErrorCode: "PAYMENT_REFUNDED" }); return; } if (["CLOSED", "REVOKED", "PAYERROR"].includes(state)) { await patchPending(order, lease, { prepayState: "closed" }); return; } if (Date.now() >= order.goldMiner.endsAt) { if (state !== "NOT_EXIST") { try { await closeMerchant(order); } catch (error: any) { // A payment can win the race with close; verify the result through a fresh signed query. if (!["MERCHANT_ORDERPAID", "MERCHANT_ORDERCLOSED"].includes(error.errorCode)) throw error; if (await queryAndRecord(order) === "SUCCESS") return; if (error.errorCode !== "MERCHANT_ORDERCLOSED") throw error; } } await patchPending(order, lease, { prepayState: "closed" }); return; } await patchPending(order, lease, { nextQueryAt: Date.now() + Math.min(300000, 5000 * 2 ** attempts), queryAttempts: attempts, lastQueryError: null }); } catch (error: any) { await patchPending(order, lease, { nextQueryAt: Date.now() + Math.min(300000, 5000 * 2 ** attempts), queryAttempts: attempts, lastQueryError: error.errorCode || "MERCHANT_UNAVAILABLE" }); throw error; } finally { await releaseLease(order, lease); } } export async function recoverLegacyOrders(uid: string) { const pending = await orders().find({ "goldMiner.uid": uid, "goldMiner.paymentChannel": channel, "goldMiner.confirmedAt": 0, "goldMiner.prepayState": { $nin: terminal }, "goldMiner.nextQueryAt": { $lte: Date.now() }, "goldMiner.ioLeaseUntil": { $lte: Date.now() }, }).sort({ "goldMiner.nextQueryAt": 1 }).limit(2).toArray(); await Promise.all(pending.map(async (order: any) => { try { await reconcileLegacyOrder(order); } catch { console.error("goldMiner merchant login query pending", order._id); } })); } export async function paymentInfo(body: any, order: any) { if (order.goldMiner?.paymentChannel !== channel) return nativePaymentInfo(body, order); try { const user = await authenticate(body); if (order.goldMiner.uid !== body.uid || order.openid !== user.openid) reject("UNAUTHORIZED"); await reconcileLegacyOrder(order); const fresh = await orders().findOne({ _id: order._id }); if (fresh.goldMiner.fulfillmentStatus === "manual_review") reject("MANUAL_REVIEW"); if (!fresh.goldMiner.confirmedAt && fresh.goldMiner.prepayState === "closed") reject("ORDER_CLOSED"); return nativePaymentInfo(body, fresh); } catch (error) { return failure(error); } } async function ensurePrepay(order: any) { if (process.env.GOLD_MINER_LEGACY_ENABLED !== "true") reject("PAYMENT_CHANNEL_DISABLED"); assertMerchantOrder(order); if (!open(order.goldMiner)) reject("PERIOD_ENDED"); const period = await periodById(order.goldMiner.periodId); if (!period?.purchaseEnabled) reject("PURCHASE_DISABLED"); if (order.goldMiner.confirmedAt) reject("ALREADY_UNLOCKED"); if (order.goldMiner.prepayState === "closed") reject("ORDER_CLOSED"); if (order.goldMiner.prepayId && order.goldMiner.prepayExpiresAt > Date.now() + 30000) return order; const lease = await takeLease(order); if (!lease) reject("RETRYABLE"); try { // A previous timeout may have hidden a successful prepay/payment. Never mint a second order number. if (order.goldMiner.prepayState !== "created") { try { const state = await queryAndRecord(order); if (state === "SUCCESS") reject("ALREADY_UNLOCKED"); if (state !== "NOTPAY") reject("ORDER_CLOSED"); } catch (error: any) { if (error.errorCode !== "MERCHANT_ORDER_NOT_EXIST") throw error; } } await patchPending(order, lease, { prepayState: "creating" }); const data = await prepayMerchant(order); if (typeof data.prepay_id !== "string" || !data.prepay_id) reject("INVALID_PAYMENT"); await patchPending(order, lease, { prepayState: "ready", prepayId: data.prepay_id, prepayExpiresAt: Math.min(Date.now() + 110 * 60000, order.goldMiner.endsAt) }); const fresh = await orders().findOne({ _id: order._id }); if (fresh.goldMiner.confirmedAt) reject("ALREADY_UNLOCKED"); if (!open(fresh.goldMiner)) reject("PERIOD_ENDED"); return fresh; } finally { await releaseLease(order, lease); } } async function projectIosOrder(order: any) { // Compatibility copy is not authoritative. Never overwrite the payment state in order. const current = await orders().findOne({ _id: order._id }); return insertOnce("iosOrder", { ...current, _id: key("goldMinerIos", order.outTradeNo), goldMiner: { ...current.goldMiner, ioLeaseUntil: 0 } }); } /** An authenticated pre-order ticket authorizes sending a link only to its owning openid. */ export async function sendCustomerPayment(body: any, session: any) { if (!identifier(session.outTradeNo)) reject("INVALID_INPUT"); let order = await orders().findOne({ outTradeNo: session.outTradeNo }); if (!order || order.goldMiner?.paymentChannel !== channel) reject("NOT_FOUND"); verifyTicket(session.goldMinerTicket, order, "customer"); if (body.FromUserName !== order.openid || session.propName !== order.itemid) reject("UNAUTHORIZED"); order = await ensurePrepay(order); await projectIosOrder(order); // Stable per-prepay ticket fits the old page's opaque `time` parameter; the game login token is never exposed. const handoff = ticket(order, "handoff", order.goldMiner.prepayExpiresAt + 24 * 3600000); const timestamp = String(Math.floor(Date.now() / 1000)), nonce = randomBytes(16).toString("hex"); const appId = order.goldMiner.merchantSnapshot.appid, packageValue = "prepay_id=" + order.goldMiner.prepayId; const paySign = await signMerchant(`${appId}\n${timestamp}\n${nonce}\n${packageValue}\n`); const param = { name: "黄金矿工", openid: order.openid, outTradeNo: order.outTradeNo, time: handoff, quantity: 1, price: order.goodsPrice, checkUrl: order.goldMiner.merchantSnapshot.checkUrl, body: { appId, timeStamp: timestamp, nonceStr: nonce, package: packageValue, signType: "RSA", paySign } }; const accessToken = cloud.shared.get("wxaccess_token"); if (!accessToken) reject("CUSTOMER_SERVICE_UNAVAILABLE"); const response = await fetch("https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=" + encodeURIComponent(accessToken), { method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(5000), body: JSON.stringify({ touser: order.openid, msgtype: "link", link: { title: "黄金矿工活动支付", description: "金额:" + (order.goodsPrice / 100).toFixed(2) + "元", url: order.goldMiner.merchantSnapshot.pageUrl + "#data=" + encodeURIComponent(JSON.stringify(param)), thumb_url: "https://static-host-j3k323ol-colorblock-oss.sealoshzh.site/icon.jpg", } }), }); const sent = await response.json(); if (!response.ok || sent.errcode !== 0) reject("CUSTOMER_SERVICE_UNAVAILABLE"); return { code: 1, data: { outTradeNo: order.outTradeNo }, msg: "支付链接已发送" }; } export async function associateLegacyOrder(body: any, existing: any, compatibility: any) { const order = existing || compatibility; if (!order) reject("NOT_FOUND"); assertMerchantOrder(order); if (body.uid && body.token) { const user = await authenticate(body); if (user._id !== order.goldMiner.uid || user.openid !== order.openid) reject("UNAUTHORIZED"); } else { verifyTicket(body.time, order, "handoff"); if (body.openid !== order.openid) reject("UNAUTHORIZED"); } if (existing && compatibility && (existing.goldMiner.uid !== compatibility.goldMiner?.uid || existing.goldMiner.periodId !== compatibility.goldMiner?.periodId || existing.goodsPrice !== compatibility.goodsPrice || existing.openid !== compatibility.openid || existing.itemid !== compatibility.itemid || existing.goldMiner.configHash !== compatibility.goldMiner?.configHash)) reject("ORDER_SNAPSHOT_CONFLICT"); if (!order.goldMiner.confirmedAt && (!open(order.goldMiner) || order.goldMiner.prepayState === "closed")) reject("ORDER_CLOSED"); if (!existing) { // Copies created by this module have a deterministic canonical ID and complete immutable snapshot. const id = key("goldMinerOrder", order.paymentAppEnv, order.goldMiner.uid, order.goldMiner.periodId); if (order.outTradeNo !== (order.paymentAppEnv === "test" ? "wct_" : "wcx_") + id.slice(0, 24)) reject("ORDER_SNAPSHOT_CONFLICT"); const restored = await insertOnce("order", { ...order, _id: id, goldMiner: { ...order.goldMiner, ioLeaseUntil: 0 } }); if (restored.goldMiner.uid !== order.goldMiner.uid || restored.goldMiner.periodId !== order.goldMiner.periodId || restored.goldMiner.paymentChannel !== channel || restored.goodsPrice !== order.goodsPrice || restored.openid !== order.openid || restored.itemid !== order.itemid || restored.goldMiner.configHash !== order.goldMiner.configHash) reject("ORDER_SNAPSHOT_CONFLICT"); } if (compatibility) await collection("iosOrder").deleteOne({ _id: compatibility._id, outTradeNo: order.outTradeNo }); return true; } export default async function () { return { code: 0, msg: "internal module" }; }