119 lines
7.2 KiB
TypeScript
119 lines
7.2 KiB
TypeScript
import cloud from "@lafjs/cloud";
|
||
import { createSign, createVerify, createDecipheriv, randomBytes } from "crypto";
|
||
import { reject, validatePeriodId } from "@/goldMiner/config";
|
||
|
||
export function merchantConfig() {
|
||
const env = process.env.PAYMENT_APP_ENV || "production";
|
||
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,
|
||
pageUrl: process.env.GOLD_MINER_PAY_PAGE_URL, checkUrl: process.env.GOLD_MINER_CHECK_IOS_URL,
|
||
};
|
||
if (!["production", "test"].includes(env)) reject("PAYMENT_CONFIG_UNAVAILABLE", "PAYMENT_APP_ENV 必须为 production 或 test");
|
||
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} 配置`);
|
||
}
|
||
for (const name of ["GOLD_MINER_MERCHANT_NOTIFY_URL", "GOLD_MINER_PAY_PAGE_URL", "GOLD_MINER_CHECK_IOS_URL"]) {
|
||
let url: URL;
|
||
try { url = new URL(process.env[name]); }
|
||
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_CHECK_IOS_URL" && url.pathname !== "/wx/checkIos") reject("PAYMENT_CONFIG_UNAVAILABLE", "GOLD_MINER_CHECK_IOS_URL 的路径必须为 /wx/checkIos");
|
||
}
|
||
let keys: any;
|
||
try { keys = JSON.parse(process.env.GOLD_MINER_WECHATPAY_PUBLIC_KEYS || "{}"); }
|
||
catch { reject("PAYMENT_CONFIG_UNAVAILABLE", "GOLD_MINER_WECHATPAY_PUBLIC_KEYS 必须为合法的 JSON 公钥映射对象"); }
|
||
if (!keys || typeof keys !== "object" || Array.isArray(keys) || !Object.keys(keys).length
|
||
|| Object.values(keys).some(value => typeof value !== "string" || !value.trim())) reject("PAYMENT_CONFIG_UNAVAILABLE", "GOLD_MINER_WECHATPAY_PUBLIC_KEYS 必须为非空对象,每个公钥标识对应非空公钥字符串");
|
||
if (Buffer.byteLength(process.env.GOLD_MINER_WECHATPAY_API_V3_KEY || "") !== 32) reject("PAYMENT_CONFIG_UNAVAILABLE", "GOLD_MINER_WECHATPAY_API_V3_KEY 必须为 32 字节");
|
||
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");
|
||
}
|
||
|
||
/** Verify the original bytes, never a JSON re-serialization. Supports key rotation by serial. */
|
||
export function verifyMerchant(raw: string, headers: any) {
|
||
const header = (name: string) => headers?.get ? headers.get(name) : headers?.[name];
|
||
const stamp = header("wechatpay-timestamp"), nonce = header("wechatpay-nonce");
|
||
const serial = header("wechatpay-serial"), signature = header("wechatpay-signature");
|
||
if (typeof stamp !== "string" || !/^\d+$/.test(stamp) || Math.abs(Date.now() / 1000 - Number(stamp)) > 300
|
||
|| typeof nonce !== "string" || !nonce || typeof serial !== "string"
|
||
|| typeof signature !== "string" || !/^[A-Za-z0-9+/]+=*$/.test(signature)) reject("INVALID_PAYMENT_SIGNATURE");
|
||
let keys: any;
|
||
try { keys = JSON.parse(process.env.GOLD_MINER_WECHATPAY_PUBLIC_KEYS || "{}"); }
|
||
catch { reject("PAYMENT_CONFIG_UNAVAILABLE", "GOLD_MINER_WECHATPAY_PUBLIC_KEYS 必须为合法的 JSON 公钥映射对象"); }
|
||
if (!Object.prototype.hasOwnProperty.call(keys, serial) || typeof keys[serial] !== "string") reject("INVALID_PAYMENT_SIGNATURE");
|
||
if (!createVerify("RSA-SHA256").update(`${stamp}\n${nonce}\n${raw}\n`).verify(keys[serial], signature, "base64")) reject("INVALID_PAYMENT_SIGNATURE");
|
||
}
|
||
|
||
export function notificationTransaction(raw: string, headers: any) {
|
||
verifyMerchant(raw, headers);
|
||
const notice = JSON.parse(raw);
|
||
const r = notice.resource;
|
||
const secret = process.env.GOLD_MINER_WECHATPAY_API_V3_KEY;
|
||
if (notice.event_type !== "TRANSACTION.SUCCESS" || r?.algorithm !== "AEAD_AES_256_GCM"
|
||
|| r.original_type !== "transaction" || !secret || Buffer.byteLength(secret) !== 32
|
||
|| typeof r.nonce !== "string" || typeof r.ciphertext !== "string") reject("INVALID_PAYMENT");
|
||
const encrypted = Buffer.from(r.ciphertext, "base64");
|
||
if (encrypted.length <= 16) reject("INVALID_PAYMENT");
|
||
const decipher = createDecipheriv("aes-256-gcm", Buffer.from(secret), Buffer.from(r.nonce));
|
||
decipher.setAuthTag(encrypted.subarray(-16));
|
||
decipher.setAAD(Buffer.from(r.associated_data || ""));
|
||
return JSON.parse(Buffer.concat([decipher.update(encrypted.subarray(0, -16)), decipher.final()]).toString("utf8"));
|
||
}
|
||
|
||
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`);
|
||
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 } : {}), signal: AbortSignal.timeout(5000),
|
||
});
|
||
const text = await result.text();
|
||
verifyMerchant(text, result.headers);
|
||
const data = text ? JSON.parse(text) : {};
|
||
if (!result.ok) reject("MERCHANT_" + String(data.code || result.status));
|
||
return data;
|
||
}
|
||
|
||
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" }; }
|