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

314 lines
19 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 { randomBytes } from "crypto";
import { failure, reject, identifier, key, open, TABLES as T } from "@/goldMiner/config";
import { collection, authenticate, getUser, insertOnce, periodById, playerId } from "@/goldMiner/service";
import { prepareOrder, fulfillOrder, paymentInfo as nativePaymentInfo } from "@/goldMiner/payment";
import { merchantConfig, assertMerchantOrder, prepayMerchant, queryMerchant, closeMerchant, signMerchant, withPaymentTimeout } from "@/goldMiner/merchant";
const orders = () => collection("order");
const channel = "legacy_ios";
const terminal = ["closed", "paid"];
/** 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");
const user = await authenticate(body), cfg = merchantConfig();
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 = 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,
count: 1, price: order.goodsPrice, // Legacy customer gateway expects unit price in fen.
goldMinerTicket: "legacy_ios" // Compatibility routing marker, not an authorization ticket.
})
};
}
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 the server-to-WeChat query result, never with a notification/client body. */
export async function confirmMerchantTransaction(transaction: any, source: "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 server 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) {
assertMerchantOrder(order);
if (!open(order.goldMiner)) reject("PERIOD_ENDED");
const period = await periodById(order.goldMiner.periodId);
if (!period?.purchaseEnabled) reject("PURCHASE_DISABLED");
const player = await collection(T.players).findOne({ _id: playerId(order.goldMiner.uid, order.goldMiner.periodId) });
if (player?.paidOrderNo || player?.entitlement) reject("ALREADY_UNLOCKED");
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 {
// Another request may have completed before this lease was acquired.
order = await orders().findOne({ _id: order._id });
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;
// Recover payment confirmation, but never repeat provider creation with an old 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; }
reject("ORDER_RECREATE_REQUIRED");
}
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); }
}
export function matchesCustomerLinkTime(time: any, order: any) {
// Old activity links used creation milliseconds; newly issued links use per-order seconds.
const expected = order?.iosTime ?? order?.time;
return (typeof time === "string" || typeof time === "number")
&& expected != null && String(time) === String(expected);
}
/** Store the exact link time without changing creation time, payment or fulfillment state. */
export async function recordCustomerLink(outTradeNo: string, openid: string, iosTime: string) {
const order = await orders().findOne({ outTradeNo });
if (!order || order.goldMiner?.paymentChannel !== channel) reject("NOT_FOUND");
if (order.openid !== openid) reject("UNAUTHORIZED");
assertMerchantOrder(order);
if (typeof iosTime !== "string" || !/^\d+$/.test(iosTime) || !Number.isSafeInteger(Number(iosTime)) || Number(iosTime) <= 0) reject("INVALID_INPUT");
await orders().updateOne({ _id: order._id }, { $set: { iosTime } });
const current = await orders().findOne({ _id: order._id });
const { _id, ...snapshot } = current;
// Also repair an old generic iosOrder copy that was missing the activity snapshot.
const existingCopy = await collection("iosOrder").findOne({ outTradeNo });
await collection("iosOrder").updateOne({ _id: existingCopy?._id || key("goldMinerIos", outTradeNo) }, {
$set: { ...snapshot, goldMiner: { ...current.goldMiner, ioLeaseUntil: 0 } }
}, { upsert: true });
}
/** Load the server-created order and send its payment link only to the 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");
if (body.FromUserName !== order.openid || session.propName !== order.itemid) reject("UNAUTHORIZED");
// Older sessions omitted these fields. Fill them from the order, but never
// display a client amount that differs from the actual merchant charge.
const count = session.count ?? order.itemCount, price = session.price ?? order.goodsPrice;
const total = count * price;
if (!Number.isSafeInteger(count) || count <= 0 || !Number.isSafeInteger(price) || price <= 0
|| !Number.isSafeInteger(total) || total <= 0) {
reject("INVALID_INPUT", "客服支付 count 和 price 必须为正整数,price 单位为分");
}
if (count !== 1 || count !== order.itemCount || price !== order.goodsPrice) {
reject("ORDER_SNAPSHOT_CONFLICT", "客服支付 count 或 price 与订单不一致,请使用 create_order 返回的原始 sessionFrom");
}
order = await ensurePrepay(order);
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`);
await recordCustomerLink(order.outTradeNo, order.openid, timestamp);
const param = {
name: "黄金矿工", openid: order.openid, outTradeNo: order.outTradeNo, time: timestamp,
quantity: count, price, checkUrl: order.goldMiner.merchantSnapshot.checkUrl, body: { appId, timeStamp: timestamp, nonceStr: nonce, package: packageValue, signType: "RSA", paySign }
};
const accessToken = cloud.shared.get("wxaccess_token");
if (typeof accessToken !== "string" || !accessToken.trim()) {
reject("CUSTOMER_SERVICE_UNAVAILABLE", "当前云函数环境的 wxaccess_token 缓存为空或无效,尚未发送客服消息,请检查微信 access_token 获取及缓存流程");
}
console.info("[GoldMiner customer link] sending", {
outTradeNo: order.outTradeNo, linkTime: param.time, orderTime: String(order.time),
signatureTime: timestamp, hasName: !!param.name, hasCheckUrl: !!param.checkUrl,
});
await withPaymentTimeout(async () => {
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" },
body: JSON.stringify({
touser: order.openid, msgtype: "link", link: {
title: "黄金矿工活动支付", description: "金额:" + (total / 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",
}
}),
}).catch(() => reject("CUSTOMER_SERVICE_UNAVAILABLE", "请求微信客服消息接口失败,未取得响应,请稍后重试发送支付链接"));
const httpStatus = Number.isInteger(response.status) ? response.status : "未知";
const sent = await response.json().catch(() => reject("CUSTOMER_SERVICE_UNAVAILABLE", `微信客服消息接口返回非 JSON 响应(HTTP ${httpStatus}),请稍后重试`));
if (!response.ok || sent?.errcode !== 0) {
const wechatErrcode = Number.isSafeInteger(sent?.errcode) ? sent.errcode : "未提供有效错误码";
// Never log the request URL/body: they contain the token and payment signature.
console.error("goldMiner customer message rejected", { outTradeNo: order.outTradeNo, httpStatus, wechatErrcode });
reject("CUSTOMER_SERVICE_UNAVAILABLE", `微信客服消息发送失败(HTTP ${httpStatus},微信 errcode=${wechatErrcode}),请根据微信错误码检查客服会话及 access_token`);
}
}, "CUSTOMER_SERVICE_UNAVAILABLE");
console.info("[GoldMiner customer link] sent", { outTradeNo: order.outTradeNo });
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 {
if (body.openid !== order.openid) reject("UNAUTHORIZED", "支付链接账号与订单账号不一致");
if (!matchesCustomerLinkTime(body.time, order)) {
reject("UNAUTHORIZED", "支付链接 time 与该订单保存的 iosTime 不一致,请原样传递最新链接中的秒级时间");
}
}
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) {
// Preserve this attempt's identity; old copies without a nonce retain their original ID.
const parts = ["goldMinerOrder", order.paymentAppEnv, order.goldMiner.uid, order.goldMiner.periodId];
if (order.goldMiner.orderNonce) parts.push(order.goldMiner.orderNonce);
const id = key(...parts);
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" }; }