MatchMaster/server/laf-cloud/functions/goldMiner/merchant.ts

119 lines
7.2 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, 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" }; }