server/laf-cloud/functions/login.ts
2026-09-21 17:33:04 +08:00

819 lines
31 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 { createHash } from 'crypto';
import * as JungleConfigModule from "@/jungleConfig";
import {
WUCAI_TYPE_AUTO_MIGRATED,
WUCAI_TYPE_NONE,
WUCAI_TYPE_PENDING_CHOICE,
buildWucaiPreview,
fetchWucaiUser,
isPristineWucaiTarget,
isValidWucaiType,
normalizeWucaiUser,
} from "./wucaiMigration";
const JungleConfig: any = JungleConfigModule as any;
const db = cloud.database();
import crypto from 'crypto';
const mchid = process.env.WX_MCH_ID;//商户id
const serialNo = process.env.WX_MCH_CERT_SERIAL_NO;//证书序列号
const ThinkingData = require('thinkingdata-node');
import Ip2region from 'ip2region';
// 新版每日/周任务暂不上线,仅保留源码备查(743ea34 引入,local 分支保留)。
// import { refreshTaskData } from "@/dailyTaskService";
const query = new Ip2region();
const canIos = false;
const version = "9.9.99";
const AB_CONFIG_COLLECTION = "ab_config";
const MANAGED_AB_LAYERS = ["1", "2", "3"];
const DEFAULT_AB_GROUP = "Default";
export default async function (ctx: FunctionContext) {
const body = ctx.body || {};
let code = body.code;
let distinctId = body.distinctId;
let isDebug = body.isDebug;
const from = body.from;
const supportsWucaiMigration = Number(body.wucai_capability) >= 1;
let dbname = "users";
// const gameName = body.gameName;
// if (gameName == "iaa") {
// dbname = "usersAd";
// }
if (from === "wucai" && dbname !== "users") {
return wucaiLoginFail("WUCAI_UNSUPPORTED_GAME", "五彩账号迁移仅支持主用户库");
}
if (from === "wucai" && !supportsWucaiMigration) {
return wucaiLoginFail("WUCAI_CLIENT_UPGRADE_REQUIRED", "当前客户端不支持五彩账号迁移,请更新游戏");
}
let idname = process.env.IDCOUNT_NAME;
// if (gameName == "iaa") {
// idname = "69fabaacb4d89b170da4bf57";
// }
let info = await getWXOpenId(code, dbname);
if (!info) {
return;
}
let openid = info[0];
let session_key = info[1];
let unionid = info[2];
let userData = await getUserData(dbname, openid);
const isNewUser = !userData;
if (userData?.freeze) {
return { code: 0, data: "账号封禁", msg: "账号封禁" };
}
let token = await stringToHash(Date.now() + openid);
let abTests: any = null;
let shouldPersistABTests = false;
let wucaiPreview = null;
if (!userData) {
if (dbname === "users") {
abTests = await getActiveABAssignments(openid, null, true);
}
let migratedUser = null;
if (from === "wucai") {
const source = await fetchWucaiUser(unionid);
if (!source) {
return wucaiLoginFail("WUCAI_SOURCE_UNAVAILABLE", "五彩数据获取失败,请重新进入游戏后重试");
}
migratedUser = normalizeWucaiUser(source);
}
const onlyId = await allocateOnlyId(idname);
await db.collection(dbname).add({
openid: openid,
unionid: unionid,
session_key: session_key,
register_time: Date.now(),
onlyId: onlyId,
distinctId: distinctId,
isDebug: isDebug,
levelAmount: migratedUser?.levelAmount ?? 0,
coinAmount: migratedUser?.coinAmount ?? 0,
healthAmount: migratedUser?.healthAmount ?? 5,
healthtimestamp: migratedUser?.healthtimestamp ?? 0,
freezeAmount: migratedUser?.freezeAmount ?? 3,
hammerAmount: migratedUser?.hammerAmount ?? 3,
magicAmount: migratedUser?.magicAmount ?? 3,
userPowerTime: migratedUser?.userPowerTime ?? 0,
monthCardReward: migratedUser?.monthCardReward ?? 0,
monthCardTime: migratedUser?.monthCardTime ?? 0,
miniProgramWelfare: { favorite: false, desktop: false },
rebornGiftCount: 0,
rebornGiftTime: 0,
useravatar: Math.floor(Math.random() * 4) + "",
username: "user",
pay_user: migratedUser?.pay_user ?? false,
isWhite: false,
isFirst: true,
starter_pack: migratedUser?.starter_pack ?? 0,
starter_packState: migratedUser?.starter_packState ?? 0,
starterPackVersion: migratedUser?.starterPackVersion ?? 1,
starterPackRound: migratedUser?.starterPackRound ?? 0,
winStreak: migratedUser?.winStreak ?? 0,
passCheck: migratedUser?.passCheck ?? null,
endLevelNum: migratedUser?.endLevelNum ?? 0,
totalLevel: migratedUser?.totalLevel ?? 0,
userLevel: migratedUser?.userLevel ?? 0,
film: migratedUser?.film ?? 0,
careerAmount: 0,
useravatarIcon: "0",
userCat: (Math.floor(Math.random() * 3) + 1) + "",
forcedUpdate: !!migratedUser,
...(dbname === "users" ? {
wucai_type: migratedUser ? WUCAI_TYPE_AUTO_MIGRATED : WUCAI_TYPE_NONE,
} : {}),
headArr: migratedUser?.headArr ?? [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
catArr: migratedUser?.catArr ?? [1, 2, 3],
token: token,
...(dbname === "users" ? { abTests: abTests } : {})
});
} else {
const migrationUpdate: any = {};
if (dbname === "users") {
const hasStoredWucaiType = Object.prototype.hasOwnProperty.call(userData, "wucai_type");
if (hasStoredWucaiType && !isValidWucaiType(userData.wucai_type)) {
return wucaiLoginFail("WUCAI_INVALID_STATE", "五彩迁移状态异常,请联系客服");
}
let wucaiType = hasStoredWucaiType
? Number(userData.wucai_type)
: WUCAI_TYPE_NONE;
if (!hasStoredWucaiType) {
migrationUpdate.wucai_type = WUCAI_TYPE_NONE;
}
if ((wucaiType === WUCAI_TYPE_AUTO_MIGRATED || wucaiType === WUCAI_TYPE_PENDING_CHOICE)
&& !supportsWucaiMigration) {
return wucaiLoginFail("WUCAI_CLIENT_UPGRADE_REQUIRED", "当前账号需要更新游戏后继续五彩账号迁移");
}
let source = null;
if (wucaiType === WUCAI_TYPE_PENDING_CHOICE
|| (wucaiType === WUCAI_TYPE_NONE && from === "wucai")) {
source = await fetchWucaiUser(unionid || userData.unionid);
if (!source) {
return wucaiLoginFail("WUCAI_SOURCE_UNAVAILABLE", "五彩数据获取失败,请重新进入游戏后重试");
}
}
if (wucaiType === WUCAI_TYPE_NONE && from === "wucai") {
const hasOrder = await hasUserOrder(userData.openid);
if (isPristineWucaiTarget(userData, hasOrder)) {
Object.assign(migrationUpdate, normalizeWucaiUser(source), {
wucai_type: WUCAI_TYPE_AUTO_MIGRATED,
forcedUpdate: true,
});
wucaiType = WUCAI_TYPE_AUTO_MIGRATED;
} else {
migrationUpdate.wucai_type = WUCAI_TYPE_PENDING_CHOICE;
wucaiType = WUCAI_TYPE_PENDING_CHOICE;
wucaiPreview = buildWucaiPreview(source);
}
} else if (wucaiType === WUCAI_TYPE_PENDING_CHOICE) {
wucaiPreview = buildWucaiPreview(source);
}
}
if (dbname === "users") {
abTests = await getActiveABAssignments(openid, userData.abTests, false);
shouldPersistABTests = JSON.stringify(userData.abTests) !== JSON.stringify(abTests);
}
/* 新版每日/周任务暂不执行;启用时需替换下方旧版逻辑并配套客户端。
const task = refreshTaskData(
userData.task,
userData._id,
Number(userData.levelAmount) + 1 || 1,
).data;
*/
// 当前上线使用旧版四项任务逻辑:743ea34 之前的实现,8e4a978 已恢复。
let taskTime = userData.taskTime || 0;
let task = userData.task;
if (task) {
task = JSON.parse(task);
} else {
task = {
levelPass: {
value: 0,
state: 0,
},
share: {
value: 0,
state: 0,
},
useEnergy: {
value: 0,
state: 0,
},
useProp: {
value: 0,
state: 0,
}
}
}
const now = new Date(Date.now() + 8 * 3600000);
now.setHours(0, 0, 0, 0); // 直接修改当前Date对象
const timestamp = now.getTime();
if (timestamp > taskTime) {
for (var key in task) {
task[key].value = 0;
task[key].state = 0;
}
}
const loginUpdate: any = {
session_key: session_key,
...(unionid ? { unionid } : {}),
distinctId: distinctId,
isDebug: isDebug,
isFirst: false,
task: JSON.stringify(task),
// 新版配套时间写法(暂不执行):taskTime: Date.now(),
taskTime: Date.now() + 8 * 3600000,
token: token,
...migrationUpdate,
};
if (!Array.isArray(userData.catArr)) {
loginUpdate.catArr = [1, 2, 3];
}
if (userData.userCat == null || userData.userCat === "") {
loginUpdate.userCat = (Math.floor(Math.random() * 3) + 1) + "";
}
await db.collection(dbname)
.where({ _id: userData._id })
.update(loginUpdate);
if (shouldPersistABTests) {
try {
await db.collection(dbname)
.where({ _id: userData._id })
.update({ abTests: abTests });
} catch (e) {
console.log("AB测试分组写回失败", e);
}
}
}
await delayExecution(0.1);
userData = await getUserData(dbname, openid);
/* 新版每日/周任务登录后补齐逻辑暂不执行,避免将旧账号任务改写为 V2。
const ensuredTask = refreshTaskData(
userData.task,
userData._id,
Number(userData.levelAmount) + 1 || 1,
);
if (ensuredTask.changed) {
userData.task = JSON.stringify(ensuredTask.data);
userData.taskTime = Date.now();
await db.collection(dbname).where({ _id: userData._id }).update({
task: userData.task,
taskTime: userData.taskTime,
});
}
*/
let outTradeNo: any = [];
if (!isNewUser) {
let res = null;
if (userData.openid) {
res = await db.collection("order").where({ state: 1, openid: userData.openid }).get();
}
if (res.data && res.data.length > 0) {
for (let i = 0; i < res.data.length; i++) {
outTradeNo.push(res.data[i]);
}
}
if (dbname == "users") {
let ret = await getIosOrder(userData.openid, ctx);
outTradeNo = outTradeNo.concat(ret);
}
}
if (dbname == "users") {
// 旧客户端不处理新战令订单,其他商品补发保持原流程。
userData.outTradeNo = Number(ctx.body.passVersion) === 2 ? outTradeNo : outTradeNo.filter(o =>
!(o.passVersion === 2 || o.itemid === 'battlepass_30' || o.itemid === 'battlepass_12'));
}
if (!userData.onlyId) {
const onlyId = await allocateOnlyId(idname);
userData.onlyId = onlyId;
await db.collection(dbname).where({ _id: userData._id }).update({ session_key: session_key, onlyId: onlyId });
}
let share = userData.shareLv;
if (share) {
userData.shareLv = JSON.parse(share);
} else {
userData.shareLv = [];
}
userData.careerAmount = userData.careerAmount || 0;
// 老用户缺少字段或某一项时按未领取返回,不写回整对象,避免覆盖并发领取。
userData.miniProgramWelfare = {
favorite: userData.miniProgramWelfare?.favorite === true,
desktop: userData.miniProgramWelfare?.desktop === true,
};
if (dbname === "users") {
userData.abTests = abTests;
}
userData.rebornGiftCount = 0;
if (userData.isWhite && !userData.film && userData.film != 0) {
userData.film = userData.levelAmount;
}
userData.canIos = canIos;
userData.version = version;
if (dbname !== "users") {
delete userData.jungleTreasureState;
delete userData.jungleTreasureServerNow;
delete userData.pendingJungleSettlement;
} else if (Object.prototype.hasOwnProperty.call(userData, "jungleTreasureState")) {
// 仅随登录返回服务器时间,不在登录阶段创建或修改活动状态。
userData.jungleTreasureServerNow = Date.now();
}
if (wucaiPreview) {
userData.wucai_preview = wucaiPreview;
}
delete userData.isWuCai;
return { code: 1, data: userData }
}
async function allocateOnlyId(idname: string): Promise<number> {
if (!idname) {
throw new Error("计数器 ID 未配置,请检查 IDCOUNT_NAME");
}
const result: any = await cloud.mongo.db.collection<{ _id: string; count: number }>("idcount").findOneAndUpdate(
{ _id: idname },
{ $inc: { count: 1 } },
{ returnDocument: "before", projection: { count: 1, _id: 0 } }
);
// 兼容旧版驱动的 ModifyResult 和新版驱动直接返回的文档。
const counter = result && ("value" in result ? result.value : result);
if (!counter) {
throw new Error(`计数器不存在:${idname}`);
}
return counter.count;
}
function stringToHash(input: string, algorithm: 'sha1' | 'sha256' | 'sha512' = 'sha256'): string {
return createHash(algorithm).update(input).digest('hex');
}
async function hasUserOrder(openid: string): Promise<boolean> {
if (!openid) {
return false;
}
const result = await db.collection("order").where({ openid }).get();
return Array.isArray(result.data) && result.data.length > 0;
}
function wucaiLoginFail(errorCode: string, msg: string) {
return { code: 0, errorCode, data: null, msg };
}
function getTodayUTCMidnightTimestamp(): number {
const now = new Date();
// 1. 获取当前 UTC 时间
const utcMidnight = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
0, 0, 0, 0
);
// 2. 加上 8 小时(北京时间 = UTC+8)
return utcMidnight + 8 * 60 * 60 * 1000;
}
const getClientIp = (req) => {
return (
req.headers['x-forwarded-for']?.split(',')[0]?.trim() || // 代理链中的第一个 IP
req.headers['x-real-ip'] || // Nginx 配置的 real-ip
req.socket.remoteAddress // 原始 IP(可能是代理)
);
};
async function getWXOpenId(code, dbname) {
let appid = process.env.WX_MINIGAME_APP_ID;
let appsecret = process.env.WX_MINIGAME_APP_SECRET;
if (dbname == "usersAd") {
appid = "";
appsecret = "";
}
const api_url = `https://api.weixin.qq.com/sns/jscode2session`;
const param = `appid=${appid}&secret=${appsecret}&js_code=${code}&grant_type=authorization_code`;
let res = null;
try {
res = await cloud.fetch(`${api_url}?${param}`, {
headers: {
"Accept-Encoding": 'gzip'
}
});
} catch {
}
if (res == null || res.data.errcode > 0) {
return null
}
//console.log(res.data);
return [res?.data?.openid, res?.data?.session_key, res?.data?.unionid]
}
/**获取当前用户数据 */
async function getUserData(dbname: string, openid: any, isFirst?) {
let { data } = await db.collection(dbname).where({ openid: openid }).get();
if (data.length) {
return data[0];
}
return null;
}
async function delayExecution(seconds: number) {
return new Promise((resolve) => {
setTimeout(() => {
// 在这里编写需要延时执行的代码
resolve("延时执行完成");
}, seconds * 1000);
});
}
async function getIosOrder(openid, ctx) {
let res = await db.collection("order").where({ state: 0, type: "ios", openid: openid }).get();
let res1 = await db.collection("users").where({ openid: openid }).getOne();
let list = [];
if (res.data.length > 0) {
for (let i = 0; i < res.data.length; i++) {
let ret = await getOrder(res.data[i].outTradeNo);
if (ret.trade_state == "SUCCESS") {
list.push(res.data[i]);
sead(res.data[i], res1.data, getClientIp(ctx));
const jungleTier = JungleConfig.getJunglePaidTierByProductId(res.data[i].itemid);
if (jungleTier) {
const unlockResult = JungleConfig.unlockJungleTierByProductId(
res1.data.jungleTreasureState,
res.data[i].itemid,
res.data[i].outTradeNo,
);
const userUpdate: any = { pay_user: true };
if (unlockResult.ok && unlockResult.changed && unlockResult.state) {
userUpdate.jungleTreasureState = JungleConfig.serializeJungleState(unlockResult.state);
res1.data.jungleTreasureState = unlockResult.state;
}
await db.collection("users").where({ _id: res1.data._id }).update(userUpdate);
} else switch (res.data[i].itemid) {
case "month_Card":
// 月卡有效期由 monthlyCard 使用订单号统一结算,避免补单与客户端重复加时。
await db.collection("users").where({ _id: res1.data._id }).update({ pay_user: true });
break;
case "reborn_Gift":
case "reborn_Gift_2":
case "reborn_Gift_3":
case "reborn_Gift_4":
case "reborn_Gift_5":
db.collection("users").where({ _id: res1.data._id }).update({ rebornGiftTime: Date.now(), rebornGiftCount: 0, pay_user: true });
break;
case "starter_pack":
db.collection("users").where({ _id: res1.data._id }).update({ starter_packState: 1, pay_user: true });
break;
default:
db.collection("users").where({ _id: res1.data._id }).update({ pay_user: true });
break;
}
// 月卡补单确认成功后进入待客户端补发状态。
if (res.data[i].itemid === "month_Card" || jungleTier || /^battlepass(?:_30|_12)?$/.test(res.data[i].itemid)
|| /^reborn_Gift(?:_[2-5])?$/.test(res.data[i].itemid)) {
await db.collection("order").where({ outTradeNo: res.data[i].outTradeNo }).update({ state: 1 });
}
} else {
if (!res.data[i].passVersion && Date.now() - res.data[i].time > 3 * 3600000) {
let ret = await db.collection('order').where({ outTradeNo: res.data[i].outTradeNo }).remove({ multi: true });
}
}
}
}
return list;
}
async function sead(order, userInfo, ip) {
if (!userInfo.distinctId) {
return;
}
let id = '95993f9ab6f1402a87abe5147827e5e0';
if (userInfo.isDebug != "true") {
id = '40e3d5c5f2af49a4a074205564ce5dbb';
}
let teSDK = ThinkingData.initWithBatchMode(id, 'https://data.nika4fun.com/sync_data', {
dryRun: false, // report data to TE or not
deviceId: "123456789",//设备唯一标识
});
let trackEvent = {
accountId: order.openid,
distinctId: userInfo.distinctId,//访客id
event: 'payment',
time: new Date(Date.now() + 8 * 3600000),
ip: ip,
properties: {
order_id: order.outTradeNo,
pay_amount: order.goodsPrice,
payment_name: order.itemid,
payment_num: order.itemCount,
payment_type: "ios",
is_on_table: Number(userInfo.addLevel) >= 5,
current_level: userInfo.lv, //当前关卡等级 number
current_health: userInfo.healthAmount, //当前体力值
tmp_coin: userInfo.coinAmount,//当前金币
version: "1.6",
user_id: userInfo._id, //用户id
pay_user: userInfo.pay_user,
abTest_layer_one: getABTestGroupValue(userInfo.abTests, "layer_1"),
abTest_layer_two: getABTestGroupValue(userInfo.abTests, "layer_2"),
abTest_layer_three: getABTestGroupValue(userInfo.abTests, "layer_3")
},
callback(e) {
if (e) {
console.log(e);
}
}
};
teSDK.track(trackEvent);
teSDK.close();
return;
}
function getABTestGroupValue(abTests: any, layerKey: string) {
if (!abTests) {
return 0;
}
if (typeof abTests === "string") {
try {
abTests = JSON.parse(abTests);
} catch {
return 0;
}
}
const groupMap = { A: 1, B: 2, C: 3, D: 4, E: 5 };
const group = String(abTests[layerKey]?.group || "").toUpperCase();
return groupMap[group] || 0;
}
/**获取订单*/
async function getOrder(outTradeNo) {
const url = `https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=1719895437`;
const nonceStr = crypto.randomBytes(16).toString('hex');
const timestamp = Math.floor(Date.now() / 1000).toString();
let au = await sign(outTradeNo, nonceStr, timestamp);
let res = await fetch(url, {
method: 'GET', // 默认是 GET,可省略
headers: {
'Authorization': au, // 认证令牌
'Content-Type': 'application/json', // 声明请求体类型(GET 请求通常不需要)
'Accept': 'application/json', // 声明期望的响应类型
}
})
const data = await res.json();
return data;
}
async function sign(outTradeNo, nonceStr, timestamp) {
const data = `GET\n/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=1719895437\n${timestamp}\n${nonceStr}\n\n`
// 创建签名对象
const sign = crypto.createSign('RSA-SHA256');
// 更新签名内容
sign.update(data);
// 获取存储桶
const bucket = cloud.storage.bucket(process.env.WX_PAY_PRIVATE_KEY_BUCKET);
// 获取文件列表
const res = await bucket.readFile('apiclient_key.pem');
const privateKeyPem = await res.Body.transformToString();
// 使用私钥签名(PKCS1 填充)
const signature = sign.sign({
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_PADDING
}, 'base64');
// 构建Authorization头
const authorization = `WECHATPAY2-SHA256-RSA2048 mchid="${mchid}",nonce_str="${nonceStr}",signature="${signature}",timestamp="${timestamp}",serial_no="${serialNo}"`;
return authorization;
}
async function getActiveABAssignments(openid: string, oldAbTests: any, isNewUser: boolean) {
const now = Date.now();
const abTests = normalizeABTests(oldAbTests, now);
let configs = [];
try {
const ret = await db.collection(AB_CONFIG_COLLECTION).get();
configs = pickConfigByLayer(ret.data || []);
} catch (e) {
return ensureManagedABLayers(abTests, now);
}
const configByLayer: any = {};
for (const config of configs) {
configByLayer[getABLayerKey(config.layer)] = config;
}
const nextAbTests: any = { ...abTests };
for (const layer of MANAGED_AB_LAYERS) {
const layerKey = getABLayerKey(layer);
const experiment = configByLayer[layerKey];
const oldAssignment = abTests[layerKey];
if (!experiment) {
nextAbTests[layerKey] = buildDefaultABAssignment(layer, null, oldAssignment, now, 0);
continue;
}
const resetVersion = getABResetVersion(experiment.resetVersion);
if (experiment.enabled === false) {
nextAbTests[layerKey] = buildDefaultABAssignment(
layer,
experiment,
oldAssignment,
now,
resetVersion
);
continue;
}
const shouldReset = experiment.resetAssignments === true
&& resetVersion > 0
&& getABResetVersion(oldAssignment?.appliedResetVersion) !== resetVersion;
if (isNewUser || shouldReset) {
const variant = pickABVariant(openid, experiment);
nextAbTests[layerKey] = variant
? buildABAssignment(layer, experiment, variant.group, now, resetVersion)
: buildDefaultABAssignment(layer, experiment, oldAssignment, now, resetVersion);
continue;
}
if (oldAssignment) {
nextAbTests[layerKey] = {
...oldAssignment,
experimentId: getABText(experiment.experimentId),
layer: layer,
name: getABText(experiment.name),
enabled: true,
};
} else {
nextAbTests[layerKey] = buildDefaultABAssignment(layer, experiment, null, now, 0);
}
}
return nextAbTests;
}
function ensureManagedABLayers(abTests: any, now: number) {
const completed: any = { ...abTests };
for (const layer of MANAGED_AB_LAYERS) {
const layerKey = getABLayerKey(layer);
if (!completed[layerKey]) {
completed[layerKey] = buildDefaultABAssignment(layer, null, null, now, 0);
}
}
return completed;
}
function buildDefaultABAssignment(layer: string, experiment: any, oldAssignment: any, now: number, resetVersion: number) {
const wasDefault = normalizeABGroup(oldAssignment?.group) === DEFAULT_AB_GROUP;
return {
experimentId: experiment ? getABText(experiment.experimentId) : null,
layer: layer,
name: experiment ? getABText(experiment.name) : null,
enabled: !!experiment && experiment.enabled !== false,
group: DEFAULT_AB_GROUP,
assignedAt: wasDefault ? oldAssignment.assignedAt : now,
appliedResetVersion: resetVersion,
};
}
function buildABAssignment(layer: string, experiment: any, group: any, assignedAt: number, resetVersion: number) {
return {
experimentId: getABText(experiment.experimentId),
layer: layer,
name: getABText(experiment.name),
enabled: true,
group: normalizeABGroup(group),
assignedAt: assignedAt,
appliedResetVersion: resetVersion,
};
}
function normalizeABTests(abTests: any, now = Date.now()) {
if (!abTests) {
return {};
}
if (typeof abTests == "string") {
try {
abTests = JSON.parse(abTests);
} catch {
return {};
}
}
if (typeof abTests !== "object" || Array.isArray(abTests)) {
return {};
}
const normalized: any = {};
for (const key in abTests) {
const item = abTests[key];
const layer = String(item?.layer || "").trim();
const group = normalizeABGroup(item?.group);
if (!layer || !group || key !== getABLayerKey(layer)) {
continue;
}
const assignedAt = Number(item.assignedAt);
normalized[key] = {
experimentId: getABText(item.experimentId),
layer: layer,
name: getABText(item.name),
enabled: item.enabled !== false,
group: group,
assignedAt: Number.isFinite(assignedAt) && assignedAt > 0 ? assignedAt : now,
appliedResetVersion: getABResetVersion(item.appliedResetVersion),
};
}
return normalized;
}
function normalizeABGroup(group: any) {
const normalized = String(group || "").trim();
return normalized.toLowerCase() === "default" ? DEFAULT_AB_GROUP : normalized;
}
function getABText(value: any) {
if (value === null || value === undefined || value === "") {
return null;
}
return String(value);
}
function getABResetVersion(value: any) {
const version = Number(value);
return Number.isFinite(version) && version > 0 ? version : 0;
}
function getABLayerKey(layer: any) {
return `layer_${String(layer || "").trim()}`;
}
function pickConfigByLayer(configs: any[]) {
const byLayer: any = {};
for (let i = 0; i < configs.length; i++) {
const config = configs[i];
if (!isValidExperiment(config)) {
continue;
}
const layerKey = getABLayerKey(config.layer);
const old = byLayer[layerKey];
if (!old || Number(config.updatedAt || config.createdAt || 0) >= Number(old.updatedAt || old.createdAt || 0)) {
byLayer[layerKey] = config;
}
}
return Object.keys(byLayer).map(key => byLayer[key]);
}
function isValidExperiment(experiment: any) {
if (!experiment?.experimentId || !experiment?.layer || !Array.isArray(experiment?.variants)) {
return false;
}
return true;
}
function pickABVariant(openid: string, experiment: any) {
const variants = experiment.variants || [];
const totalRatio = variants.reduce((sum: number, variant: any) => sum + Number(variant.ratio || 0), 0);
if (totalRatio <= 0) {
return null;
}
const seed = `${openid}:${experiment.layer}:${experiment.experimentId}`;
const hash = createHash("sha256").update(seed).digest("hex");
const bucket = parseInt(hash.slice(0, 8), 16) % 10000;
const target = bucket / 10000 * totalRatio;
let cursor = 0;
for (let i = 0; i < variants.length; i++) {
cursor += Number(variants[i].ratio || 0);
if (target < cursor) {
return variants[i];
}
}
return variants[variants.length - 1];
}
/**
* 根据 IP 获取省份名称
* @param {string} ip - 用户 IP 地址
* @returns {string} 省份名称(如 "广东省"),未知时返回 "未知"
*/
function getProvinceByIP(ip) {
try {
const result = query.search(ip);
//console.log(result);
if (!result || !result.country.includes('中国')) {
return '其他';
}
// ip2region 返回的格式示例:"中国|0|广东省|深圳市|电信"
const parts = result.province;
const provinceCode = parts[2]; // 省份代码在第三部分
// 检查是否是已知的省份代码
// if (PROVCE_CODE_MAP[provinceCode]) {
// return PROVINCE_CODE_MAP[provinceCode];
// }
// 如果未映射,返回原始代码(可能是数据库更新后的新代码)
return parts || '其他';
} catch (error) {
console.error('IP 解析失败:', error);
return '其他';
}
}