import cloud from "@lafjs/cloud"; import { randomBytes } from "crypto"; import { failure, reject, identifier, key, open, TABLES as T } from "@/goldMiner/config"; import { collection, authenticate, getUser, insertOnce, periodById, playerId } from "@/goldMiner/service"; import { prepareOrder, fulfillOrder, paymentInfo as nativePaymentInfo } from "@/goldMiner/payment"; import { merchantConfig, assertMerchantOrder, prepayMerchant, queryMerchant, closeMerchant, signMerchant, withPaymentTimeout } from "@/goldMiner/merchant"; const orders = () => collection("order"); const channel = "legacy_ios"; const terminal = ["closed", "paid"]; /** 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"); const user = await authenticate(body), cfg = merchantConfig(); 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 = 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, count: 1, price: order.goodsPrice, // Legacy customer gateway expects unit price in fen. goldMinerTicket: "legacy_ios" // Compatibility routing marker, not an authorization ticket. }) }; } 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 the server-to-WeChat query result, never with a notification/client body. */ export async function confirmMerchantTransaction(transaction: any, source: "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 server 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) { assertMerchantOrder(order); if (!open(order.goldMiner)) reject("PERIOD_ENDED"); const period = await periodById(order.goldMiner.periodId); if (!period?.purchaseEnabled) reject("PURCHASE_DISABLED"); const player = await collection(T.players).findOne({ _id: playerId(order.goldMiner.uid, order.goldMiner.periodId) }); if (player?.paidOrderNo || player?.entitlement) reject("ALREADY_UNLOCKED"); 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 { // Another request may have completed before this lease was acquired. order = await orders().findOne({ _id: order._id }); 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; // Recover payment confirmation, but never repeat provider creation with an old 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; } reject("ORDER_RECREATE_REQUIRED"); } 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); } } export function matchesCustomerLinkTime(time: any, order: any) { // Old activity links used creation milliseconds; newly issued links use per-order seconds. const expected = order?.iosTime ?? order?.time; return (typeof time === "string" || typeof time === "number") && expected != null && String(time) === String(expected); } /** Store the exact link time without changing creation time, payment or fulfillment state. */ export async function recordCustomerLink(outTradeNo: string, openid: string, iosTime: string) { const order = await orders().findOne({ outTradeNo }); if (!order || order.goldMiner?.paymentChannel !== channel) reject("NOT_FOUND"); if (order.openid !== openid) reject("UNAUTHORIZED"); assertMerchantOrder(order); if (typeof iosTime !== "string" || !/^\d+$/.test(iosTime) || !Number.isSafeInteger(Number(iosTime)) || Number(iosTime) <= 0) reject("INVALID_INPUT"); await orders().updateOne({ _id: order._id }, { $set: { iosTime } }); const current = await orders().findOne({ _id: order._id }); const { _id, ...snapshot } = current; // Also repair an old generic iosOrder copy that was missing the activity snapshot. const existingCopy = await collection("iosOrder").findOne({ outTradeNo }); await collection("iosOrder").updateOne({ _id: existingCopy?._id || key("goldMinerIos", outTradeNo) }, { $set: { ...snapshot, goldMiner: { ...current.goldMiner, ioLeaseUntil: 0 } } }, { upsert: true }); } /** Load the server-created order and send its payment link only to the 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"); if (body.FromUserName !== order.openid || session.propName !== order.itemid) reject("UNAUTHORIZED"); // Older sessions omitted these fields. Fill them from the order, but never // display a client amount that differs from the actual merchant charge. const count = session.count ?? order.itemCount, price = session.price ?? order.goodsPrice; const total = count * price; if (!Number.isSafeInteger(count) || count <= 0 || !Number.isSafeInteger(price) || price <= 0 || !Number.isSafeInteger(total) || total <= 0) { reject("INVALID_INPUT", "客服支付 count 和 price 必须为正整数,price 单位为分"); } if (count !== 1 || count !== order.itemCount || price !== order.goodsPrice) { reject("ORDER_SNAPSHOT_CONFLICT", "客服支付 count 或 price 与订单不一致,请使用 create_order 返回的原始 sessionFrom"); } order = await ensurePrepay(order); 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`); await recordCustomerLink(order.outTradeNo, order.openid, timestamp); const param = { name: "黄金矿工", openid: order.openid, outTradeNo: order.outTradeNo, time: timestamp, quantity: count, price, checkUrl: order.goldMiner.merchantSnapshot.checkUrl, body: { appId, timeStamp: timestamp, nonceStr: nonce, package: packageValue, signType: "RSA", paySign } }; const accessToken = cloud.shared.get("wxaccess_token"); if (typeof accessToken !== "string" || !accessToken.trim()) { reject("CUSTOMER_SERVICE_UNAVAILABLE", "当前云函数环境的 wxaccess_token 缓存为空或无效,尚未发送客服消息,请检查微信 access_token 获取及缓存流程"); } console.info("[GoldMiner customer link] sending", { outTradeNo: order.outTradeNo, linkTime: param.time, orderTime: String(order.time), signatureTime: timestamp, hasName: !!param.name, hasCheckUrl: !!param.checkUrl, }); await withPaymentTimeout(async () => { 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" }, body: JSON.stringify({ touser: order.openid, msgtype: "link", link: { title: "黄金矿工活动支付", description: "金额:" + (total / 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", } }), }).catch(() => reject("CUSTOMER_SERVICE_UNAVAILABLE", "请求微信客服消息接口失败,未取得响应,请稍后重试发送支付链接")); const httpStatus = Number.isInteger(response.status) ? response.status : "未知"; const sent = await response.json().catch(() => reject("CUSTOMER_SERVICE_UNAVAILABLE", `微信客服消息接口返回非 JSON 响应(HTTP ${httpStatus}),请稍后重试`)); if (!response.ok || sent?.errcode !== 0) { const wechatErrcode = Number.isSafeInteger(sent?.errcode) ? sent.errcode : "未提供有效错误码"; // Never log the request URL/body: they contain the token and payment signature. console.error("goldMiner customer message rejected", { outTradeNo: order.outTradeNo, httpStatus, wechatErrcode }); reject("CUSTOMER_SERVICE_UNAVAILABLE", `微信客服消息发送失败(HTTP ${httpStatus},微信 errcode=${wechatErrcode}),请根据微信错误码检查客服会话及 access_token`); } }, "CUSTOMER_SERVICE_UNAVAILABLE"); console.info("[GoldMiner customer link] sent", { outTradeNo: order.outTradeNo }); 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 { if (body.openid !== order.openid) reject("UNAUTHORIZED", "支付链接账号与订单账号不一致"); if (!matchesCustomerLinkTime(body.time, order)) { reject("UNAUTHORIZED", "支付链接 time 与该订单保存的 iosTime 不一致,请原样传递最新链接中的秒级时间"); } } 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) { // Preserve this attempt's identity; old copies without a nonce retain their original ID. const parts = ["goldMinerOrder", order.paymentAppEnv, order.goldMiner.uid, order.goldMiner.periodId]; if (order.goldMiner.orderNonce) parts.push(order.goldMiner.orderNonce); const id = key(...parts); 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" }; }