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

186 lines
11 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 } 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, insertOnce, mutatePlayer, closePlayer
} from "@/goldMiner/service";
const orders = () => collection("order");
/** Both channels reserve the same account/period order before contacting a provider. */
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) 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;
// One stable order per account/period prevents concurrent clients creating two charges.
const id = key("goldMinerOrder", env, body.uid, period.periodId);
const outTradeNo = (env === "test" ? "wct_" : "wcx_") + id.slice(0, 24);
let order = await insertOnce("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 },
createRequestId: body.createRequestId, confirmedAt: 0, fulfillmentStatus: "pending", fulfillmentRoute: null,
targetPeriodId: null, revision: 0
}
});
if ((order.goldMiner.paymentChannel || "native") !== channel) reject("PAYMENT_CHANNEL_CONFLICT", "本期已有其他渠道订单,请继续原渠道支付");
if (order.goldMiner.confirmedAt || order.state !== 0) { await fulfillOrder(outTradeNo); reject("ALREADY_UNLOCKED"); }
// Recheck an entitlement inserted while the order was being created.
if ((await collection(T.players).findOne({ _id: row._id }))?.entitlement) 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", "缺少订单对应的活动快照");
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" }; }