MatchMaster/server/laf-cloud/functions/goldMiner/merchant.ts
2026-09-30 22:30:22 +08:00

110 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T>(operation: () => Promise<T>, errorCode: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
new Promise<never>((_, 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" }; }