110 lines
6.1 KiB
TypeScript
110 lines
6.1 KiB
TypeScript
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" }; }
|