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

196 lines
12 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 { createHmac, timingSafeEqual, randomBytes } from "crypto";
import { TABLES as T, reject, failure, key, open, validatePeriodId, identifier, isProduct } from "@/goldMiner/config";
import { resolvePeriodDates } from "@/goldMiner/testPeriods";
import {
collection, authenticate, getUser, currentPeriod, ensurePlayer, playerId, ensureEntitlement,
activateEntitlement, reserveEntitlement, mutatePlayer, closePlayer
} from "@/goldMiner/service";
const orders = () => collection("order");
/** Every purchase attempt gets a fresh provider order; entitlement ownership is per period. */
export async function prepareOrder(body: any, user: any, platform: "and" | "ios", channel: "native" | "legacy_ios", channelSnapshot: any = {}) {
const env = process.env.PAYMENT_APP_ENV || "production";
if ((!isProduct(body.itemid) && env !== 'test') || !identifier(body.createRequestId)) reject("INVALID_INPUT");
if (!["production", "test"].includes(env)) reject("PAYMENT_CONFIG_UNAVAILABLE", "PAYMENT_APP_ENV 必须为 production 或 test");
// Repair confirmed prior orders before exposing a purchase in their carry target period.
const previous = await orders().find({
"goldMiner.uid": body.uid, "goldMiner.fulfillmentStatus": { $in: ["pending", "processing", "retryable"] },
"goldMiner.confirmedAt": { $gt: 0 }
}).limit(100).toArray();
for (const order of previous) await fulfillOrder(order.outTradeNo);
const period = await currentPeriod(Date.now(), body.uid);
if (!period || !open(period)) reject("PERIOD_ENDED");
if (!period.purchaseEnabled) reject("PURCHASE_DISABLED");
const row = await ensurePlayer(body.uid, user, period);
await ensureEntitlement(body.uid, period.periodId);
const fresh = await mutatePlayer(row._id, state => {
if (state.entitlementStatus === "unlocked" || state.entitlement || state.paidOrderNo) reject("ALREADY_UNLOCKED");
if (state.configSnapshot.productId !== body.itemid) reject("INVALID_PRODUCT", "商品与当前 VIP 礼包不匹配,请刷新活动信息后重试");
return state.offerLockedAt ? null : { offerLockedAt: Date.now() };
}, true);
const offer = fresh.configSnapshot;
// createRequestId is for tracing, never for reusing a provider order number.
const orderNonce = randomBytes(16).toString("hex");
const id = key("goldMinerOrder", env, body.uid, period.periodId, orderNonce);
const outTradeNo = (env === "test" ? "wct_" : "wcx_") + id.slice(0, 24);
const order = {
_id: id, openid: user.openid, outTradeNo, paymentAppEnv: env,
itemid: body.itemid, itemCount: 1, goodsPrice: offer.priceFen, time: Date.now(),
orderTime: new Date(Date.now() + 8 * 3600000), chargeTime: 0, getTime: 0, type: platform, state: 0,
goldMiner: {
schemaVersion: 1, uid: body.uid, accountScope: "users", paymentChannel: channel, ...channelSnapshot, periodId: period.periodId,
startsAt: period.startsAt, endsAt: period.endsAt, configVersion: fresh.configVersion, configHash: fresh.configHash,
...(offer.vipLevel !== undefined ? { vipLevel: offer.vipLevel } : {}),
productSnapshot: { productId: body.itemid, priceFen: offer.priceFen },
orderNonce, createRequestId: body.createRequestId, confirmedAt: 0, fulfillmentStatus: "pending", fulfillmentRoute: null,
targetPeriodId: null, revision: 0
}
};
await orders().insertOne(order);
// Recheck an entitlement inserted while the order was being created.
const latest = await collection(T.players).findOne({ _id: row._id });
if (latest?.entitlement || latest?.paidOrderNo) reject("ALREADY_UNLOCKED");
if (!open(period)) reject("PERIOD_ENDED");
return order;
}
export async function createNativeOrder(body: any, platform: "and" | "ios") {
try {
const user = await authenticate(body);
const env = process.env.PAYMENT_APP_ENV || "production";
if ((!isProduct(body.itemid) && env !== 'test') || !identifier(body.createRequestId)) reject("INVALID_INPUT");
const signKey = process.env.WX_MIDAS_PAY_SIGN_KEY;
if (!["production", "test"].includes(env)) reject("PAYMENT_CONFIG_UNAVAILABLE", "PAYMENT_APP_ENV 必须为 production 或 test");
if (!signKey) reject("PAYMENT_CONFIG_UNAVAILABLE", "服务端缺少 WX_MIDAS_PAY_SIGN_KEY 配置");
if (!user.session_key) reject("PAYMENT_CONFIG_UNAVAILABLE", "玩家缺少有效 session_key,请重新登录后再下单");
const order = await prepareOrder(body, user, platform, "native");
const outTradeNo = order.outTradeNo;
const signData = JSON.stringify({
mode: "goods", offerId: "1450527458", buyQuantity: 1, env: 0,
currencyType: "CNY", platform: "android", zoneId: "1", productId: order.itemid,
goodsPrice: order.goodsPrice, outTradeNo, attach: "黄金矿工"
});
return {
code: 1, data: {
outTradeNo, signData, signature: createHmac("sha256", user.session_key).update(signData).digest("hex"),
paySig: createHmac("sha256", signKey).update("requestMidasPaymentGameItem&" + signData).digest("hex")
}, msg: "成功"
};
} catch (error) { return failure(error); }
}
export function verifyNotification(body: any, payload: any, order: any) {
const secret = process.env.WX_MIDAS_PAY_SIGN_KEY;
const signature = body?.MiniGame?.PayEventSig;
const env = process.env.PAYMENT_APP_ENV || "production";
if (!secret || body?.Event !== "minigame_game_pay_goods_deliver_notify" || body?.MiniGame?.IsMock === true
|| typeof body?.MiniGame?.Payload !== "string" || payload.Env !== 0 || !["production", "test"].includes(env)
|| order.paymentAppEnv !== env || typeof signature !== "string" || !/^[a-fA-F0-9]{64}$/.test(signature)
|| !order.goldMiner || (order.goldMiner.paymentChannel || "native") !== "native" || order.goldMiner.schemaVersion !== 1
|| payload.OutTradeNo !== order.outTradeNo || payload.OpenId !== order.openid
|| payload.GoodsInfo?.ProductId !== order.itemid
|| Number(payload.GoodsInfo?.Quantity) !== Number(order.itemCount)
|| Number(payload.GoodsInfo?.OrigPrice) !== Number(order.goodsPrice)) reject("INVALID_PAYMENT");
validatePeriodId(order.goldMiner.periodId);
const expected = createHmac("sha256", secret).update(body.Event + "&" + body.MiniGame.Payload).digest();
if (!timingSafeEqual(expected, Buffer.from(signature, "hex"))) reject("INVALID_PAYMENT");
}
export async function confirmNativePayment(body: any, payload: any, order: any) {
verifyNotification(body, payload, order);
const user = await getUser(order.goldMiner.uid);
if (!user || user.openid !== order.openid) reject("INVALID_PAYMENT");
await orders().updateOne({ outTradeNo: order.outTradeNo, "goldMiner.confirmedAt": 0 },
{ $set: { state: 1, chargeTime: new Date(Date.now() + 8 * 3600000), "goldMiner.confirmedAt": Date.now() } });
return fulfillOrder(order.outTradeNo);
}
export async function fulfillOrder(outTradeNo: string) {
let order = await orders().findOne({ outTradeNo });
if (!order?.goldMiner || !order.goldMiner.confirmedAt || ![1, 2].includes(order.state)) reject("PAYMENT_PENDING");
validatePeriodId(order.goldMiner.periodId);
if (order.goldMiner.fulfillmentStatus === "fulfilled") return order.goldMiner;
if (order.goldMiner.fulfillmentStatus === "manual_review") reject("MANUAL_REVIEW");
try {
const g = order.goldMiner;
const originalId = playerId(g.uid, g.periodId);
let original = await collection(T.players).findOne({ _id: originalId });
if (!original) reject("NOT_FOUND", "缺少订单对应的活动快照");
// Older carried orders stored ownership on the target period only.
const carriedOwner = original.carryTargetPeriodId
? (await collection(T.players).findOne({ _id: playerId(g.uid, original.carryTargetPeriodId) }))?.entitlement?.sourceOrderNo
: null;
original = await mutatePlayer(originalId, row => {
const owner = row.paidOrderNo || row.entitlement?.sourceOrderNo || row.sourceOrderNo || carriedOwner;
if (owner && owner !== outTradeNo) reject("ENTITLEMENT_CONFLICT", "本期已有其他已支付订单,重复支付需要人工核对");
return row.paidOrderNo ? null : { paidOrderNo: outTradeNo };
});
if (!g.fulfillmentRoute) {
let route = "current_period";
if (Date.now() >= g.endsAt) {
original = await closePlayer(originalId);
route = original.tasks.some((t: any) => t.targetWins <= original.progressAtClose && t.claimStatus !== "claimed") ? "settle_original" : "carry_next";
}
const targetPeriodId = route === "carry_next" ? (await resolvePeriodDates(g.periodId)).nextPeriodId : g.periodId;
await orders().updateOne({ outTradeNo, "goldMiner.fulfillmentRoute": null }, {
$set: {
"goldMiner.fulfillmentRoute": route, "goldMiner.targetPeriodId": targetPeriodId, "goldMiner.fulfillmentStatus": "processing"
}
});
order = await orders().findOne({ outTradeNo });
}
const current = order.goldMiner;
const dates = await resolvePeriodDates(current.targetPeriodId);
const eid = playerId(current.uid, current.targetPeriodId);
let entitlement = (await collection(T.players).findOne({ _id: eid }))?.entitlement;
if (!entitlement && current.fulfillmentRoute === "carry_next" && Date.now() >= dates.endsAt) reject("ENTITLEMENT_CONFLICT", "顺延目标期已结束,需要人工核对");
if (!entitlement && current.fulfillmentRoute === "carry_next") {
const pendingPurchase = await orders().findOne({ "goldMiner.uid": current.uid, "goldMiner.periodId": current.targetPeriodId });
if (pendingPurchase) reject("ENTITLEMENT_CONFLICT", "顺延期已有购买订单,需要核对后分配资格");
}
entitlement = entitlement || await reserveEntitlement({
_id: eid, uid: current.uid, accountScope: "users",
sourceOrderNo: outTradeNo, sourcePeriodId: current.periodId, targetPeriodId: current.targetPeriodId,
source: current.fulfillmentRoute === "carry_next" ? "delayed_payment_carry" : current.fulfillmentRoute === "settle_original" ? "delayed_payment_original" : "purchase",
state: "reserved", carryCount: current.fulfillmentRoute === "carry_next" ? 1 : 0,
...(original.configSnapshot?.vipLevel !== undefined ? { configSnapshot: original.configSnapshot } : {}),
createdAt: Date.now(), activatesAt: dates.startsAt
});
if (entitlement.sourceOrderNo !== outTradeNo) reject("ENTITLEMENT_CONFLICT");
if (current.fulfillmentRoute === "carry_next") await mutatePlayer(originalId, row => {
if (row.carryTargetPeriodId === current.targetPeriodId) return null;
return { carryTargetPeriodId: current.targetPeriodId, carryDisposition: "reserved" };
});
await activateEntitlement(entitlement);
await cloud.database().collection("users").where({ _id: current.uid }).update({ pay_user: true });
await orders().updateOne({ outTradeNo }, {
$set: {
state: 2, getTime: new Date(Date.now() + 8 * 3600000),
"goldMiner.fulfillmentStatus": "fulfilled", "goldMiner.entitlementId": eid, "goldMiner.fulfilledAt": Date.now()
}
});
return (await orders().findOne({ outTradeNo })).goldMiner;
} catch (error: any) {
const manual = error.errorCode === "ENTITLEMENT_CONFLICT";
await orders().updateOne({ outTradeNo, "goldMiner.fulfillmentStatus": { $ne: "fulfilled" } }, {
$set: {
"goldMiner.fulfillmentStatus": manual ? "manual_review" : "retryable",
"goldMiner.lastErrorCode": error.errorCode || "RETRYABLE", "goldMiner.nextRetryAt": Date.now() + 60000
}
});
throw error;
}
}
export async function paymentInfo(body: any, order: any) {
try {
const user = await authenticate(body);
if (order.goldMiner?.uid !== body.uid || order.openid !== user.openid) reject("UNAUTHORIZED");
if (!order.goldMiner.confirmedAt) reject("PAYMENT_PENDING");
const result = await fulfillOrder(order.outTradeNo);
return { code: 1, data: { pay_state: 2, goldMiner: result, rewardDelivery: "goldMiner.claim" }, msg: "活动权益已确认" };
} catch (error) { return failure(error); }
}
export default async function () { return { code: 0, msg: "internal module" }; }