server/laf-cloud/functions/goldMiner/legacyPayment.ts
2026-09-24 17:59:52 +08:00

291 lines
16 KiB
TypeScript

import cloud from "@lafjs/cloud";
import { createHmac, timingSafeEqual, randomBytes } from "crypto";
import { failure, reject, identifier, key, open } from "@/goldMiner/config";
import { collection, authenticate, getUser, insertOnce, periodById } from "@/goldMiner/service";
import { prepareOrder, fulfillOrder, paymentInfo as nativePaymentInfo } from "@/goldMiner/payment";
import { merchantConfig, assertMerchantOrder, prepayMerchant, queryMerchant, closeMerchant, signMerchant } from "@/goldMiner/merchant";
const orders = () => collection("order");
const channel = "legacy_ios";
const terminal = ["closed", "paid"];
function ticketSecret() {
const secret = process.env.GOLD_MINER_ORDER_TICKET_SECRET;
if (!secret || Buffer.byteLength(secret) < 32) reject("PAYMENT_CONFIG_UNAVAILABLE", "GOLD_MINER_ORDER_TICKET_SECRET 必须配置且长度至少为 32 字节");
return secret;
}
function ticket(order: any, purpose: "customer" | "handoff", expiresAt: number) {
const payload = Buffer.from(JSON.stringify({
order: order.outTradeNo, uid: order.goldMiner.uid,
openid: order.openid, env: order.paymentAppEnv, purpose, expiresAt
})).toString("base64url");
return payload + "." + createHmac("sha256", ticketSecret()).update(payload).digest("hex");
}
function verifyTicket(value: any, order: any, purpose: "customer" | "handoff") {
if (typeof value !== "string" || value.length > 2000) reject("UNAUTHORIZED");
const [payload, signature, extra] = value.split(".");
if (extra || !/^[a-f0-9]{64}$/.test(signature || "")) reject("UNAUTHORIZED");
const expected = createHmac("sha256", ticketSecret()).update(payload).digest();
if (!timingSafeEqual(expected, Buffer.from(signature, "hex"))) reject("UNAUTHORIZED");
let data: any;
try { data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); } catch { reject("UNAUTHORIZED"); }
if (data.order !== order.outTradeNo || data.uid !== order.goldMiner.uid || data.openid !== order.openid
|| data.env !== order.paymentAppEnv || data.purpose !== purpose || !Number.isSafeInteger(data.expiresAt)
|| data.expiresAt <= Date.now()) reject("UNAUTHORIZED");
}
/** Authenticated pre-order: payment link creation never trusts client price, count or order number. */
export async function createLegacyOrder(body: any) {
if (body.channel !== channel) reject("INVALID_INPUT");
if (process.env.GOLD_MINER_LEGACY_ENABLED !== "true") reject("PAYMENT_CHANNEL_DISABLED");
const user = await authenticate(body), cfg = merchantConfig();
ticketSecret();
const order = await prepareOrder(body, user, "ios", channel, {
merchantSnapshot: { mchid: cfg.mchid, appid: cfg.appid, notifyUrl: cfg.notifyUrl, pageUrl: cfg.pageUrl, checkUrl: cfg.checkUrl },
prepayState: "created", ioLeaseUntil: 0, nextQueryAt: 0, queryAttempts: 0,
});
assertMerchantOrder(order);
if (order.goldMiner.prepayState === "closed") reject("ORDER_CLOSED");
const expiresAt = Math.min(Date.now() + 15 * 60000, order.goldMiner.endsAt);
return {
outTradeNo: order.outTradeNo, periodId: order.goldMiner.periodId, priceFen: order.goodsPrice,
productId: order.itemid, quantity: 1, channel, expiresAt,
sessionFrom: JSON.stringify({
tpye: "ios", propName: order.itemid, outTradeNo: order.outTradeNo,
goldMinerTicket: ticket(order, "customer", expiresAt)
})
};
}
async function takeLease(order: any, dueOnly = false) {
const lease = randomBytes(16).toString("hex");
const result = await orders().updateOne({
_id: order._id, "goldMiner.confirmedAt": 0,
"goldMiner.ioLeaseUntil": { $lte: Date.now() },
...(dueOnly ? { "goldMiner.nextQueryAt": { $lte: Date.now() } } : {}),
}, { $set: { "goldMiner.ioLeaseUntil": Date.now() + 30000, "goldMiner.ioLeaseToken": lease } });
return result.modifiedCount === 1 ? lease : null;
}
async function releaseLease(order: any, lease: string) {
await orders().updateOne({ _id: order._id, "goldMiner.ioLeaseToken": lease }, { $set: { "goldMiner.ioLeaseUntil": 0 } });
}
async function patchPending(order: any, lease: string, patch: any) {
const fields = Object.fromEntries(Object.entries(patch).map(([k, v]) => ["goldMiner." + k, v]));
await orders().updateOne({ _id: order._id, "goldMiner.ioLeaseToken": lease, "goldMiner.confirmedAt": 0 }, { $set: fields });
}
/** Only call with a verified provider response/notification, never with a client request body. */
export async function confirmMerchantTransaction(transaction: any, source: "merchant_notify" | "merchant_query") {
if (typeof transaction?.out_trade_no !== "string") reject("INVALID_PAYMENT");
const order = await orders().findOne({ outTradeNo: transaction.out_trade_no });
if (!order) reject("NOT_FOUND");
const cfg = assertMerchantOrder(order);
const paidAt = Date.parse(transaction.success_time);
if (transaction.trade_state !== "SUCCESS" || transaction.trade_type !== "JSAPI"
|| transaction.mchid !== cfg.mchid || transaction.appid !== cfg.appid || transaction.payer?.openid !== order.openid
|| !Number.isSafeInteger(transaction.amount?.total) || transaction.amount.total !== order.goodsPrice
|| transaction.amount.currency !== "CNY" || typeof transaction.transaction_id !== "string" || !transaction.transaction_id
|| !Number.isFinite(paidAt) || paidAt <= 0 || paidAt > Date.now() + 300000
|| (transaction.attach && transaction.attach !== order.goldMiner.periodId)) reject("INVALID_PAYMENT");
const user = await getUser(order.goldMiner.uid);
if (!user || user.openid !== order.openid) reject("INVALID_PAYMENT");
if (order.goldMiner.transactionId && order.goldMiner.transactionId !== transaction.transaction_id) reject("PAYMENT_CONFLICT");
await orders().updateOne({ _id: order._id, "goldMiner.confirmedAt": 0 }, {
$set: {
state: 1, chargeTime: new Date(Date.now() + 8 * 3600000), "goldMiner.confirmedAt": Date.now(),
"goldMiner.transactionId": transaction.transaction_id, "goldMiner.paidAt": paidAt,
"goldMiner.confirmationSource": source, "goldMiner.prepayState": "paid",
}
});
const saved = await orders().findOne({ _id: order._id });
if (saved.goldMiner.transactionId !== transaction.transaction_id) reject("PAYMENT_CONFLICT");
return saved;
}
async function queryAndRecord(order: any) {
const transaction = await queryMerchant(order);
// Even non-success results must belong to this merchant and this exact order.
if (transaction.out_trade_no !== order.outTradeNo || transaction.mchid !== order.goldMiner.merchantSnapshot.mchid
|| transaction.appid !== order.goldMiner.merchantSnapshot.appid) reject("INVALID_PAYMENT");
if (transaction.trade_state === "SUCCESS") {
await confirmMerchantTransaction(transaction, "merchant_query");
await fulfillOrder(order.outTradeNo);
}
return transaction.trade_state;
}
/** Query failures retain the order; due time and a lease bound login/poll/timer work. */
export async function reconcileLegacyOrder(order: any) {
assertMerchantOrder(order);
if (order.goldMiner.confirmedAt) return fulfillOrder(order.outTradeNo);
if (terminal.includes(order.goldMiner.prepayState)) return;
const lease = await takeLease(order, true);
if (!lease) return;
const attempts = Math.min((order.goldMiner.queryAttempts || 0) + 1, 12);
try {
let state: string;
try { state = await queryAndRecord(order); }
catch (error: any) { if (error.errorCode !== "MERCHANT_ORDER_NOT_EXIST") throw error; state = "NOT_EXIST"; }
if (state === "SUCCESS") return;
if (state === "REFUND") {
await patchPending(order, lease, { prepayState: "closed", fulfillmentStatus: "manual_review", lastErrorCode: "PAYMENT_REFUNDED" });
return;
}
if (["CLOSED", "REVOKED", "PAYERROR"].includes(state)) {
await patchPending(order, lease, { prepayState: "closed" }); return;
}
if (Date.now() >= order.goldMiner.endsAt) {
if (state !== "NOT_EXIST") {
try { await closeMerchant(order); }
catch (error: any) {
// A payment can win the race with close; verify the result through a fresh signed query.
if (!["MERCHANT_ORDERPAID", "MERCHANT_ORDERCLOSED"].includes(error.errorCode)) throw error;
if (await queryAndRecord(order) === "SUCCESS") return;
if (error.errorCode !== "MERCHANT_ORDERCLOSED") throw error;
}
}
await patchPending(order, lease, { prepayState: "closed" }); return;
}
await patchPending(order, lease, { nextQueryAt: Date.now() + Math.min(300000, 5000 * 2 ** attempts), queryAttempts: attempts, lastQueryError: null });
} catch (error: any) {
await patchPending(order, lease, {
nextQueryAt: Date.now() + Math.min(300000, 5000 * 2 ** attempts), queryAttempts: attempts,
lastQueryError: error.errorCode || "MERCHANT_UNAVAILABLE"
});
throw error;
} finally { await releaseLease(order, lease); }
}
export async function recoverLegacyOrders(uid: string) {
const pending = await orders().find({
"goldMiner.uid": uid, "goldMiner.paymentChannel": channel,
"goldMiner.confirmedAt": 0, "goldMiner.prepayState": { $nin: terminal },
"goldMiner.nextQueryAt": { $lte: Date.now() }, "goldMiner.ioLeaseUntil": { $lte: Date.now() },
}).sort({ "goldMiner.nextQueryAt": 1 }).limit(2).toArray();
await Promise.all(pending.map(async (order: any) => {
try { await reconcileLegacyOrder(order); } catch { console.error("goldMiner merchant login query pending", order._id); }
}));
}
export async function paymentInfo(body: any, order: any) {
if (order.goldMiner?.paymentChannel !== channel) return nativePaymentInfo(body, order);
try {
const user = await authenticate(body);
if (order.goldMiner.uid !== body.uid || order.openid !== user.openid) reject("UNAUTHORIZED");
await reconcileLegacyOrder(order);
const fresh = await orders().findOne({ _id: order._id });
if (fresh.goldMiner.fulfillmentStatus === "manual_review") reject("MANUAL_REVIEW");
if (!fresh.goldMiner.confirmedAt && fresh.goldMiner.prepayState === "closed") reject("ORDER_CLOSED");
return nativePaymentInfo(body, fresh);
} catch (error) { return failure(error); }
}
async function ensurePrepay(order: any) {
if (process.env.GOLD_MINER_LEGACY_ENABLED !== "true") reject("PAYMENT_CHANNEL_DISABLED");
assertMerchantOrder(order);
if (!open(order.goldMiner)) reject("PERIOD_ENDED");
const period = await periodById(order.goldMiner.periodId);
if (!period?.purchaseEnabled) reject("PURCHASE_DISABLED");
if (order.goldMiner.confirmedAt) reject("ALREADY_UNLOCKED");
if (order.goldMiner.prepayState === "closed") reject("ORDER_CLOSED");
if (order.goldMiner.prepayId && order.goldMiner.prepayExpiresAt > Date.now() + 30000) return order;
const lease = await takeLease(order);
if (!lease) reject("RETRYABLE");
try {
// A previous timeout may have hidden a successful prepay/payment. Never mint a second order number.
if (order.goldMiner.prepayState !== "created") {
try {
const state = await queryAndRecord(order);
if (state === "SUCCESS") reject("ALREADY_UNLOCKED");
if (state !== "NOTPAY") reject("ORDER_CLOSED");
} catch (error: any) { if (error.errorCode !== "MERCHANT_ORDER_NOT_EXIST") throw error; }
}
await patchPending(order, lease, { prepayState: "creating" });
const data = await prepayMerchant(order);
if (typeof data.prepay_id !== "string" || !data.prepay_id) reject("INVALID_PAYMENT");
await patchPending(order, lease, {
prepayState: "ready", prepayId: data.prepay_id,
prepayExpiresAt: Math.min(Date.now() + 110 * 60000, order.goldMiner.endsAt)
});
const fresh = await orders().findOne({ _id: order._id });
if (fresh.goldMiner.confirmedAt) reject("ALREADY_UNLOCKED");
if (!open(fresh.goldMiner)) reject("PERIOD_ENDED");
return fresh;
} finally { await releaseLease(order, lease); }
}
async function projectIosOrder(order: any) {
// Compatibility copy is not authoritative. Never overwrite the payment state in order.
const current = await orders().findOne({ _id: order._id });
return insertOnce("iosOrder", {
...current, _id: key("goldMinerIos", order.outTradeNo),
goldMiner: { ...current.goldMiner, ioLeaseUntil: 0 }
});
}
/** An authenticated pre-order ticket authorizes sending a link only to its owning openid. */
export async function sendCustomerPayment(body: any, session: any) {
if (!identifier(session.outTradeNo)) reject("INVALID_INPUT");
let order = await orders().findOne({ outTradeNo: session.outTradeNo });
if (!order || order.goldMiner?.paymentChannel !== channel) reject("NOT_FOUND");
verifyTicket(session.goldMinerTicket, order, "customer");
if (body.FromUserName !== order.openid || session.propName !== order.itemid) reject("UNAUTHORIZED");
order = await ensurePrepay(order);
await projectIosOrder(order);
// Stable per-prepay ticket fits the old page's opaque `time` parameter; the game login token is never exposed.
const handoff = ticket(order, "handoff", order.goldMiner.prepayExpiresAt + 24 * 3600000);
const timestamp = String(Math.floor(Date.now() / 1000)), nonce = randomBytes(16).toString("hex");
const appId = order.goldMiner.merchantSnapshot.appid, packageValue = "prepay_id=" + order.goldMiner.prepayId;
const paySign = await signMerchant(`${appId}\n${timestamp}\n${nonce}\n${packageValue}\n`);
const param = {
name: "黄金矿工", openid: order.openid, outTradeNo: order.outTradeNo, time: handoff,
quantity: 1, price: order.goodsPrice, checkUrl: order.goldMiner.merchantSnapshot.checkUrl, body: { appId, timeStamp: timestamp, nonceStr: nonce, package: packageValue, signType: "RSA", paySign }
};
const accessToken = cloud.shared.get("wxaccess_token");
if (!accessToken) reject("CUSTOMER_SERVICE_UNAVAILABLE");
const response = await fetch("https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=" + encodeURIComponent(accessToken), {
method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(5000),
body: JSON.stringify({
touser: order.openid, msgtype: "link", link: {
title: "黄金矿工活动支付", description: "金额:" + (order.goodsPrice / 100).toFixed(2) + "元",
url: order.goldMiner.merchantSnapshot.pageUrl + "#data=" + encodeURIComponent(JSON.stringify(param)),
thumb_url: "https://static-host-j3k323ol-colorblock-oss.sealoshzh.site/icon.jpg",
}
}),
});
const sent = await response.json();
if (!response.ok || sent.errcode !== 0) reject("CUSTOMER_SERVICE_UNAVAILABLE");
return { code: 1, data: { outTradeNo: order.outTradeNo }, msg: "支付链接已发送" };
}
export async function associateLegacyOrder(body: any, existing: any, compatibility: any) {
const order = existing || compatibility;
if (!order) reject("NOT_FOUND");
assertMerchantOrder(order);
if (body.uid && body.token) {
const user = await authenticate(body);
if (user._id !== order.goldMiner.uid || user.openid !== order.openid) reject("UNAUTHORIZED");
} else {
verifyTicket(body.time, order, "handoff");
if (body.openid !== order.openid) reject("UNAUTHORIZED");
}
if (existing && compatibility && (existing.goldMiner.uid !== compatibility.goldMiner?.uid
|| existing.goldMiner.periodId !== compatibility.goldMiner?.periodId || existing.goodsPrice !== compatibility.goodsPrice
|| existing.openid !== compatibility.openid || existing.itemid !== compatibility.itemid
|| existing.goldMiner.configHash !== compatibility.goldMiner?.configHash)) reject("ORDER_SNAPSHOT_CONFLICT");
if (!order.goldMiner.confirmedAt && (!open(order.goldMiner) || order.goldMiner.prepayState === "closed")) reject("ORDER_CLOSED");
if (!existing) {
// Copies created by this module have a deterministic canonical ID and complete immutable snapshot.
const id = key("goldMinerOrder", order.paymentAppEnv, order.goldMiner.uid, order.goldMiner.periodId);
if (order.outTradeNo !== (order.paymentAppEnv === "test" ? "wct_" : "wcx_") + id.slice(0, 24)) reject("ORDER_SNAPSHOT_CONFLICT");
const restored = await insertOnce("order", { ...order, _id: id, goldMiner: { ...order.goldMiner, ioLeaseUntil: 0 } });
if (restored.goldMiner.uid !== order.goldMiner.uid || restored.goldMiner.periodId !== order.goldMiner.periodId
|| restored.goldMiner.paymentChannel !== channel || restored.goodsPrice !== order.goodsPrice
|| restored.openid !== order.openid || restored.itemid !== order.itemid
|| restored.goldMiner.configHash !== order.goldMiner.configHash) reject("ORDER_SNAPSHOT_CONFLICT");
}
if (compatibility) await collection("iosOrder").deleteOne({ _id: compatibility._id, outTradeNo: order.outTradeNo });
return true;
}
export default async function () { return { code: 0, msg: "internal module" }; }