import cloud from "@lafjs/cloud"; import { createSign, randomBytes } from "crypto"; import { ActivityError, reject, validatePeriodId } from "@/goldMiner/config"; // Laf's sandbox may expose fetch without AbortSignal. Bound the wait, including // reading the response body; a timeout does not mean the provider cancelled it. export async function withPaymentTimeout(operation: () => Promise, errorCode: string): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ new Promise((_, fail) => { timer = setTimeout(() => fail(new ActivityError(errorCode, "微信接口响应超时,请查询订单状态后重试")), 5000); }), Promise.resolve().then(operation), ]); } finally { if (timer !== undefined) clearTimeout(timer); } } export function merchantConfig() { const env = process.env.PAYMENT_APP_ENV || "production"; if (!["production", "test"].includes(env)) reject("PAYMENT_CONFIG_UNAVAILABLE", "PAYMENT_APP_ENV 必须为 production 或 test"); // Reuse the existing payment application's origin; never send test transfers to production by default. const appUrl = (path: string) => { const name = env === "test" ? "TEST_WX_PAY_NOTIFY_URL" : "WX_PAY_NOTIFY_URL"; const base = process.env[name]?.trim() || (env === "production" ? "https://q6rvwvtnga.sealoshzh.site" : ""); let url: URL; try { url = new URL(base); } catch { reject("PAYMENT_CONFIG_UNAVAILABLE", `${name} 缺失或无效,无法确定当前环境的支付接口地址`); } if (url.protocol !== "https:" || url.username || url.password) reject("PAYMENT_CONFIG_UNAVAILABLE", `${name} 必须是无账号密码的 HTTPS 地址`); return new URL(path, url.origin).href; }; const cfg = { env, mchid: process.env.WX_MCH_ID, appid: process.env.WX_MINIGAME_APP_ID, serial: process.env.WX_MCH_CERT_SERIAL_NO, bucket: process.env.WX_PAY_PRIVATE_KEY_BUCKET, notifyUrl: process.env.GOLD_MINER_MERCHANT_NOTIFY_URL?.trim() || appUrl("/goldMiner/merchantNotify"), pageUrl: process.env.GOLD_MINER_PAY_PAGE_URL?.trim() || "https://pay.nika4games.com/order3.html", checkUrl: process.env.GOLD_MINER_CHECK_IOS_URL?.trim() || appUrl("/wx/checkIos"), }; for (const name of ["WX_MCH_ID", "WX_MINIGAME_APP_ID", "WX_MCH_CERT_SERIAL_NO", "WX_PAY_PRIVATE_KEY_BUCKET"]) { if (!process.env[name]) reject("PAYMENT_CONFIG_UNAVAILABLE", `服务端缺少 ${name} 配置`); } const urls = { GOLD_MINER_MERCHANT_NOTIFY_URL: cfg.notifyUrl, GOLD_MINER_PAY_PAGE_URL: cfg.pageUrl, GOLD_MINER_CHECK_IOS_URL: cfg.checkUrl }; for (const [name, value] of Object.entries(urls)) { let url: URL; try { url = new URL(value); } catch { reject("PAYMENT_CONFIG_UNAVAILABLE", `${name} 必须为有效的 HTTPS 地址`); } if (url.protocol !== "https:" || url.username || url.password || url.hash) reject("PAYMENT_CONFIG_UNAVAILABLE", `${name} 必须使用 HTTPS,且不能包含账号、密码或 URL 片段`); if (name === "GOLD_MINER_MERCHANT_NOTIFY_URL" && url.search) reject("PAYMENT_CONFIG_UNAVAILABLE", "GOLD_MINER_MERCHANT_NOTIFY_URL 不能包含查询参数"); if (name === "GOLD_MINER_CHECK_IOS_URL" && url.pathname !== "/wx/checkIos") reject("PAYMENT_CONFIG_UNAVAILABLE", "GOLD_MINER_CHECK_IOS_URL 的路径必须为 /wx/checkIos"); } return cfg; } export async function signMerchant(message: string) { const cfg = merchantConfig(); const file = await cloud.storage.bucket(cfg.bucket).readFile("apiclient_key.pem"); const privateKey = await file.Body.transformToString(); return createSign("RSA-SHA256").update(message).sign(privateKey, "base64"); } export async function merchantRequest(method: "GET" | "POST", path: string, body?: any) { const cfg = merchantConfig(); const raw = body === undefined ? "" : JSON.stringify(body); const nonce = randomBytes(16).toString("hex"), stamp = String(Math.floor(Date.now() / 1000)); const signature = await signMerchant(`${method}\n${path}\n${stamp}\n${nonce}\n${raw}\n`); return withPaymentTimeout(async () => { const result = await fetch("https://api.mch.weixin.qq.com" + path, { method, headers: { Authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${cfg.mchid}",nonce_str="${nonce}",timestamp="${stamp}",serial_no="${cfg.serial}",signature="${signature}"`, Accept: "application/json", "Content-Type": "application/json", }, ...(method === "POST" ? { body: raw } : {}), redirect: "error", }); const text = await result.text(); const data = text ? JSON.parse(text) : {}; if (!result.ok) reject("MERCHANT_" + String(data.code || result.status)); return data; }, "MERCHANT_TIMEOUT"); } export function assertMerchantOrder(order: any) { const cfg = merchantConfig(), g = order?.goldMiner; if (g?.paymentChannel !== "legacy_ios" || g.schemaVersion !== 1 || order.paymentAppEnv !== cfg.env || g.merchantSnapshot?.mchid !== cfg.mchid || g.merchantSnapshot?.appid !== cfg.appid) reject("ORDER_ENVIRONMENT_MISMATCH"); validatePeriodId(g.periodId); return cfg; } export async function queryMerchant(order: any) { const cfg = assertMerchantOrder(order); return merchantRequest("GET", `/v3/pay/transactions/out-trade-no/${encodeURIComponent(order.outTradeNo)}?mchid=${encodeURIComponent(cfg.mchid)}`); } export async function closeMerchant(order: any) { const cfg = assertMerchantOrder(order); return merchantRequest("POST", `/v3/pay/transactions/out-trade-no/${encodeURIComponent(order.outTradeNo)}/close`, { mchid: cfg.mchid }); } export async function prepayMerchant(order: any) { const cfg = assertMerchantOrder(order); return merchantRequest("POST", "/v3/pay/transactions/jsapi", { appid: cfg.appid, mchid: cfg.mchid, description: "黄金矿工活动资格", out_trade_no: order.outTradeNo, notify_url: order.goldMiner.merchantSnapshot.notifyUrl, time_expire: new Date(order.goldMiner.endsAt).toISOString(), amount: { total: order.goodsPrice, currency: "CNY" }, payer: { openid: order.openid }, attach: order.goldMiner.periodId, }); } export default async function () { return { code: 0, msg: "internal module" }; }