314 lines
19 KiB
TypeScript
314 lines
19 KiB
TypeScript
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" }; }
|