291 lines
16 KiB
TypeScript
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" }; }
|