186 lines
11 KiB
TypeScript
186 lines
11 KiB
TypeScript
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" }; }
|