merge feat-newVersion into main
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# 微信小游戏强制更新
|
||||
|
||||
## 当前前端接口全部豁免
|
||||
|
||||
按当前配置要求,前端调用的 49 个后端接口名已全部加入 `versionPolicy.ts` 的 `nonPlayerFunctions`,包括 `login`、存档、活动和前端支付请求。该名单优先级最高:即使接口出现在 `apiBlacklist`、玩家命中 `openidWhitelist`、版本缺失或过低、当前策略配置无效,也会跳过版本检查,继续执行原接口鉴权和业务逻辑。登录函数内的延后检查也复用同一豁免规则。
|
||||
|
||||
清单覆盖主包和分包,以及动态选择的支付/活动入口;按真实后端函数名维护,`getAddressRankSelf` 实际请求 `getAddressRank`。`getRank`、`getSRank` 和五彩地址排行实际请求其他服务,在本文件添加对应名字不会改变那些服务的部署配置。第三方 SDK、CDN 资源请求不属于本后端云函数清单。
|
||||
|
||||
发布 `versionPolicy` 后生效。后续若要恢复某个接口的强更检查,先从 `nonPlayerFunctions` 删除该接口,再按下述版本策略配置;以下强更流程仅适用于未被豁免的接口。
|
||||
|
||||
## 当前交付与默认状态
|
||||
|
||||
- `functions/versionPolicy.ts`:人工维护版本策略,代码发布后生效,不使用数据库集合。
|
||||
- `functions/__interceptor__.ts`:Laf 全局 HTTP 云函数拦截器;白名单非空时,登录校验延后到 `login.ts` 取得微信身份后、任何账号写入之前。
|
||||
- 两个模块的 YAML 均为 `methods: []`,不要开放直接 HTTP 访问。
|
||||
- 客户端 `MatchMaster/assets/Script/module/Pay/Utils.ts` 的 POST、POSTJSON 均注入 `GameConfig.GM_INFO.version`,并统一识别强更错误。
|
||||
- 客户端 `ClientUpdate.ts` 在启动时注册微信更新管理器,在服务端要求强更时阻断游戏;普通更新不主动打断玩家。
|
||||
- 初始配置为 `currentVersion = "1.0.82"`、`force: false`、`minVersion: null`、`apiBlacklist: []`,默认不拦截任何版本。首次正式发布时,根据实际发布版本维护 GameConfig 与服务端配置。
|
||||
- 原登录回包的 `data.version = "9.9.99"` 及存档覆盖字段 `forcedUpdate` 保持原有含义,与本功能无关。
|
||||
|
||||
## 人工配置
|
||||
|
||||
```ts
|
||||
// 填写当前小游戏 AppID 下的 openid。[] 表示对所有玩家执行版本检查。
|
||||
export const openidWhitelist: string[] = ["openid_of_test_player"];
|
||||
|
||||
export const currentVersion = "1.0.84";
|
||||
export const versions: Record<string, VersionPolicy> = {
|
||||
"1.0.83": {
|
||||
force: true,
|
||||
minVersion: "1.0.83",
|
||||
apiBlacklist: ["userData", "userCoin", "cloudRise/index"],
|
||||
},
|
||||
"1.0.84": {
|
||||
force: false,
|
||||
minVersion: "1.0.83",
|
||||
apiBlacklist: ["userData", "userCoin", "cloudRise/index"],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
这里的接口仅为示例,实际名单由维护者填写,不自动加入、推断或合并历史名单。
|
||||
|
||||
`openidWhitelist` 是同一模块中独立于 `versions` 的全局名单,适用于当前策略中的登录及黑名单接口:非空时仅名单内玩家受版本门槛限制;清空后立即恢复全量检查。未加入名单的玩家跳过全部版本门槛(包括历史最低兼容版本),后端必须仍兼容这些旧客户端。openid 精确匹配、区分大小写,不接受空字符串、空白或非字符串元素。修改后发布 `versionPolicy` 才生效,客户端无需新增 openid 请求字段。
|
||||
|
||||
登录使用微信 `code2session` 返回的真实 openid,首次注册的玩家也能命中;只交换一次登录 code。其他受限请求按现有 token 查询 `users`,未找到时查询 `usersAd`,使用数据库中的 openid;不使用客户端自报的 openid、uid 或 gameName 判断白名单。token 缺失、非法或失效时继续交给原接口鉴权,不强行提示版本更新;数据库查询失败则返回暂不可用,不绕过检查。兼容版本、非黑名单接口和空白名单模式均不额外查询身份。
|
||||
|
||||
- `currentVersion` 指向服务端正在生效的完整策略,不能按请求版本选择历史配置,也不能按对象键顺序选最新版。
|
||||
- `force` 标识该次发布是否为破坏性更新,不是取消所有历史门槛的开关。
|
||||
- `minVersion: null` 明确关闭版本门槛。普通更新应保留最低兼容版本;降低门槛需要人工修改,并确认后端仍能兼容旧数据。
|
||||
- `force: true` 时必须同时设置 `minVersion === currentVersion`;`force: false` 时可以保留较低门槛,且 `minVersion` 不能大于 `currentVersion`。
|
||||
- 版本必须为三个非负整数组成的字符串,不接受空白、前导零、预发布后缀或超出安全整数范围的分段;按数值比较,例如 `1.0.10 > 1.0.9`。
|
||||
- 接口名使用 Laf 的完整函数名,精确且区分大小写,例如 `cloudRise/index`;不要写域名、开头的 `/`、通配符或 `action`。
|
||||
- 登录固定检查,无须加入黑名单。名单中一个接口的所有 action(含读取)都会受限制。
|
||||
- 微信支付、客服等服务端回调、后台任务和管理接口不得加入玩家名单;实现对现有回调和已识别的后台入口另有排除,防止误伤。新增非玩家入口时也应检查该排除规则。
|
||||
- 配置条目不存在、版本/字段格式错误、缺少 `minVersion` 等属于错误配置,不等同于关闭。返回 `CLIENT_UPDATE_CONFIG_UNAVAILABLE`,不执行玩家业务。非玩家排除入口不受影响。
|
||||
|
||||
## 请求和响应
|
||||
|
||||
客户端 POST JSON 或表单中统一携带:
|
||||
|
||||
```json
|
||||
{ "version": "1.0.82", "token": "...", "action": "read" }
|
||||
```
|
||||
|
||||
即使调用方自行传入 `version`,请求入口也会覆盖为 GameConfig 中的实际版本;不会覆盖调用方原对象。登录和不带 token 的公开玩家请求同样上报。旧排行榜/五彩服务的请求也上报,但那些服务的服务端拦截不在本次范围内。资源下载、微信原生支付和第三方埋点不套用玩家业务协议。
|
||||
|
||||
服务端流程:
|
||||
|
||||
1. OPTIONS、内部 call 及已识别的非玩家入口放行。
|
||||
2. 获取当前服务端策略并检查配置有效性。
|
||||
3. 非 login 且不在当前黑名单的请求放行。
|
||||
4. `minVersion === null` 放行。
|
||||
5. 相等或更高版本放行;缺失、非法或低于最低兼容版本时,空白名单直接返回强更错误,非空白名单仅对服务端确认命中的玩家返回强更错误。登录在取得微信身份后执行这一步。
|
||||
6. 放行仅表示继续执行目标函数,目标函数原有鉴权和业务检查不变。
|
||||
|
||||
POST 从 `ctx.body.version` 读取;GET/HEAD 从 `ctx.query.version` 读取,不相互兜底。目标函数名取运行时 `ctx.__function_name`,兼容回退为 `ctx.request.path` 去掉首个斜杠,不接受客户端自报的函数名。
|
||||
|
||||
强更错误使用 HTTP 200 的业务 JSON(现有客户端只解析 2xx):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"errorCode": "CLIENT_UPDATE_REQUIRED",
|
||||
"msg": "当前游戏版本过低,请更新后重新进入",
|
||||
"data": { "version": "1.0.84", "minVersion": "1.0.83" }
|
||||
}
|
||||
```
|
||||
|
||||
配置异常使用 `code: 0`、`errorCode: "CLIENT_UPDATE_CONFIG_UNAVAILABLE"`、`data: null`、`msg: "服务暂不可用,请稍后重试"`,不触发更新界面。策略本身不从数据库读取;白名单身份查询失败也返回暂不可用。非法配置对非玩家入口之外的请求保守返回暂不可用,与是否命中白名单无关。
|
||||
|
||||
## 客户端行为
|
||||
|
||||
- 首次收到强更错误后,暂停场景输入、游戏与音频,阻止所有后续玩家业务请求。
|
||||
- 同一进程内不解除阻断;后续并发成功回包也转换为强更错误,不再进入成功业务逻辑。已经在服务器执行完的操作不会回滚,客户端不自动重放。
|
||||
- 已收到 `onUpdateReady` 时,只显示一个不可取消的“重启更新”弹窗,用户点击后调用 `applyUpdate()`。重启沿用既有自动登录流程。
|
||||
- 检查或下载中展示“正在准备更新”。等待超过 30 秒、检查无新包、下载失败或不支持更新管理器时,维持阻断,提示退出后重新进入;后续若收到 ready 仍可切换为重启提示。
|
||||
- 微信更新管理器没有主动重新下载 API,不能把重新调用 `getUpdateManager()` 当成下载重试。
|
||||
- 返回前台不会恢复游戏或绕过更新状态;原登录轮询、登录失败重试与进入 HomeScene 的异步回调都检查阻断状态。
|
||||
- 更新交互仅支持微信小游戏,使用微信原生提示和更新管理器,不提供编辑器/浏览器预览、DOM 面板或模拟开关。
|
||||
|
||||
## 发布顺序
|
||||
|
||||
1. 核实线上 Laf/Sealaf 支持双参数 `(ctx, next)` 全局拦截器。不能给 `next` 设置默认值,平台按函数形参数量选择执行方式。
|
||||
2. 发布 `versionPolicy` 的默认关闭配置,再发布 `__interceptor__`。确认现有线上拦截器没有其他逻辑需要合并,切勿直接覆盖线上独有逻辑。
|
||||
3. 发布包含上报与更新处理的新客户端。GameConfig 中版本应与实际游戏包版本一致,正式版和体验版使用同一套判断;当前项目测试/正式 URL 相同,不会按 `isDebug` 自动豁免。
|
||||
4. 真机确认新包可获取后,再发布提高门槛的 `versionPolicy`;部署完成后请求受保护接口验证新配置已生效。
|
||||
5. 后续普通更新复制有效策略,保留最低门槛和仍需要限制的接口。不要因 `force` 改为 false 而清空门槛。
|
||||
|
||||
旧包没有更新处理代码,只能被服务端拒绝,无法凭新错误码为旧包补上更新交互。启用门槛前应完成过渡版本发布。设置最低版本高于平台实际可获取的版本会让玩家停在更新提示,因此必须遵循先发布包、验证可获取、再启用门槛的顺序。
|
||||
|
||||
## 发布白名单功能
|
||||
|
||||
首次上线白名单功能需要一并发布 `versionPolicy`、`__interceptor__` 和 `login`;后续只调整名单或版本策略时发布 `versionPolicy` 即可。
|
||||
|
||||
## 与微信灰度发布的关系
|
||||
|
||||
服务端名单与微信后台灰度比例独立,不会自动同步。按本实现推导:若新版本仅向 10% 玩家发布,却把服务端最低兼容版本提高到该版本且白名单为空,另外 90% 中低于门槛的玩家在登录或访问黑名单接口时同样会被要求更新;但微信未向他们提供新包时,更新管理器无法完成更新,客户端会停在更新阻断流程中。
|
||||
|
||||
白名单只能限制“谁需要强更”,不能让名单内玩家自动取得微信灰度包。灰度期间应保持原有全量兼容门槛;要测试新门槛,使用非空白名单,并先确认这些账号实际可取得目标包。全量发布且确认目标包可获取后,再启用全量新门槛。不要用“清空白名单”表示暂停测试,因为清空意味着全量检查。
|
||||
|
||||
微信官方 [UpdateManager.applyUpdate](https://developers.weixin.qq.com/minigame/dev/api/base/update/UpdateManager.applyUpdate.html) 要求收到 `onUpdateReady` 后调用;[官方 API 类型定义](https://github.com/wechat-miniprogram/minigame-api-typings/blob/master/types/wx/lib.wx.api.d.ts) 的 UpdateManager 注释也明确新包下载由微信客户端触发,下载成功才触发 ready。这里的 90% 受影响结论来自本项目服务端规则与平台分发范围独立的推导;没有据此宣称微信会将灰度包提供给未命中玩家。
|
||||
|
||||
## 自动化验证
|
||||
|
||||
在服务端仓库运行:
|
||||
|
||||
```powershell
|
||||
node --test laf-cloud/tests/client-version-policy.test.mjs laf-cloud/tests/login-wucai-state.test.mjs
|
||||
```
|
||||
|
||||
在 MatchMaster 仓库运行:
|
||||
|
||||
```powershell
|
||||
node --test tools/test-client-update.cjs tools/test-wucai-request-barrier.cjs tools/test-activity-schedule.cjs
|
||||
```
|
||||
|
||||
自动测试覆盖数值版本比较、非法/缺失版本、持续门槛、黑名单精确匹配、拦截前无业务执行、回调豁免、GET/POST 参数、全入口上报、并发回包、更新就绪/失败/无更新/超时、回前台和登录重试。客户端更新测试使用不含 DOM 的微信 API 模拟环境。
|
||||
|
||||
上线前真机验证:
|
||||
|
||||
- 兼容包正常自动登录;旧包的 login、黑名单接口返回强更;非黑名单接口仍按原逻辑执行。
|
||||
- 先开旧包再发布新包:新包 ready 后点击重启,重新上报新版本并自动登录。
|
||||
- 在线游戏收到强更后不能操作、不能继续发送玩家请求;切后台再回来仍阻断。
|
||||
- 断网、无新包、下载失败时不放行;恢复并重新进入后可取得新版。
|
||||
- 微信支付/客服回调和任务继续正常运行。
|
||||
- 正式版和体验版分别验证。体验版需实际上传两个包测试,模拟 API 的单元测试不能证明微信分发行为。
|
||||
|
||||
强更机制测试使用未豁免的模拟接口,覆盖版本比较、名单内外玩家、伪造 openid、GET/POST token 来源、身份查询失败和业务异常传播。登录回归验证旧版/缺失/非法版本在名单为空或非空、配置有效或无效时,仍按接口豁免规则正常登录。`login-wucai-state.test.mjs` 的既有用例 `login opens the personal CloudRise period after resolving the account` 在改动前后均失败(`null !== 1`);此外本地人工配置 `force: true, minVersion: null` 会使当前策略有效性测试失败。两项均不能表述为回归通过;当前接口豁免不会修正配置,只是跳过该校验。
|
||||
|
||||
参考:[Laf 拦截器](https://doc.laf.run/zh/cloud-function/interceptor.html)、[Laf 模块导入](https://doc.laf.run/zh/cloud-function/import.html)、[Laf 执行器](https://github.com/labring/laf/blob/main/runtimes/nodejs/src/support/engine/executor.ts)、[微信更新管理器](https://developers.weixin.qq.com/minigame/dev/api/base/update/wx.getUpdateManager.html)。
|
||||
@@ -0,0 +1,34 @@
|
||||
import cloud from "@lafjs/cloud";
|
||||
import { checkClientVersion, configUnavailable, openidWhitelist, shouldCheckPlayerVersion } from "@/versionPolicy";
|
||||
|
||||
// Laf detects this signature by function.length: keep exactly two non-default parameters.
|
||||
export default async function (ctx: FunctionContext, next: (context: FunctionContext) => Promise<any>) {
|
||||
const method = String(ctx.method || ctx.request?.method || "").toUpperCase();
|
||||
if (method === "OPTIONS" || method === "CALL") return await next(ctx);
|
||||
// Set by Laf's router, not by a client-provided body/header. Preserve nested function names.
|
||||
const functionName = ctx.__function_name || ctx.request?.path?.replace(/^\//, "");
|
||||
if (!functionName) return configUnavailable();
|
||||
const params = method === "GET" || method === "HEAD" ? ctx.query : ctx.body;
|
||||
const rejection = checkClientVersion(functionName, params?.version);
|
||||
if (rejection?.errorCode === "CLIENT_UPDATE_REQUIRED" && openidWhitelist.length > 0) {
|
||||
// Login has no trusted openid yet. It checks after code2session, before any user writes.
|
||||
if (functionName === "login") return await next(ctx);
|
||||
// Never trust a request's openid/uid for whitelist membership. Resolve the login token.
|
||||
const token = params?.token;
|
||||
if (typeof token !== "string" || !token) return await next(ctx);
|
||||
let openid: unknown;
|
||||
try {
|
||||
const db = cloud.database();
|
||||
const main = await db.collection("users").where({ token }).getOne();
|
||||
const user = main.data || (await db.collection("usersAd").where({ token }).getOne()).data;
|
||||
openid = user?.openid;
|
||||
} catch {
|
||||
// A failed lookup must not silently let a targeted old client into business logic.
|
||||
return configUnavailable();
|
||||
}
|
||||
// Unauthenticated requests continue to the endpoint's existing authentication checks.
|
||||
if (!shouldCheckPlayerVersion(openid)) return await next(ctx);
|
||||
}
|
||||
if (rejection) return rejection;
|
||||
return await next(ctx);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
name: __interceptor__
|
||||
desc: "玩家请求版本门禁,在业务执行前拒绝不兼容客户端"
|
||||
methods: []
|
||||
tags: []
|
||||
@@ -1,5 +1,6 @@
|
||||
// 百人赛单独上线:暂不导入黄金矿工模块或执行其查单、权益补发。
|
||||
import cloud from '@lafjs/cloud'
|
||||
import { checkClientVersion, openidWhitelist, shouldCheckPlayerVersion } from "@/versionPolicy";
|
||||
import { createHash } from 'crypto';
|
||||
import * as JungleConfigModule from "@/jungleConfig";
|
||||
import {
|
||||
@@ -65,6 +66,14 @@ export default async function (ctx: FunctionContext) {
|
||||
return;
|
||||
}
|
||||
let openid = info[0];
|
||||
// Scoped rollout needs WeChat's verified identity, including accounts not created yet.
|
||||
// Full rollout is already checked by the global interceptor before code2session.
|
||||
const requestMethod = String(ctx.method || ctx.request?.method || "").toUpperCase();
|
||||
if (requestMethod !== "CALL" && openidWhitelist.length > 0 && shouldCheckPlayerVersion(openid)) {
|
||||
const clientVersion = requestMethod === "GET" || requestMethod === "HEAD" ? ctx.query?.version : body.version;
|
||||
const rejection = checkClientVersion("login", clientVersion);
|
||||
if (rejection) return rejection;
|
||||
}
|
||||
let session_key = info[1];
|
||||
let unionid = info[2];
|
||||
let userData = await getUserData(dbname, openid);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
export interface VersionPolicy {
|
||||
force: boolean;
|
||||
minVersion: string | null;
|
||||
apiBlacklist: string[];
|
||||
}
|
||||
|
||||
// Empty means all players; otherwise only these verified WeChat openids are checked.
|
||||
export const openidWhitelist: string[] = [
|
||||
"o0tJD3asJusHgpSq35y7qOODjb8M", // 420418
|
||||
"o0tJD3fvbo5eBKBxJ1ZJ9l1fM_X4", // 916541
|
||||
"o0tJD3bAysG5Y3e93RikOtyLyN58", // 100057
|
||||
"o0tJD3R-js9YQIz24WGNNWepW1j0", // 1327711
|
||||
];
|
||||
|
||||
export function shouldCheckPlayerVersion(openid: unknown): boolean {
|
||||
return openidWhitelist.length === 0 || (typeof openid === "string" && openidWhitelist.includes(openid));
|
||||
}
|
||||
|
||||
// Publish this internal module after editing. Select the SERVER release, never the request version.
|
||||
export const currentVersion = "1.0.86";
|
||||
export const versions: Record<string, VersionPolicy> = {
|
||||
"1.0.82": { force: false, minVersion: null, apiBlacklist: [] },
|
||||
"1.0.83": { force: false, minVersion: null, apiBlacklist: [] },
|
||||
"1.0.84": { force: false, minVersion: null, apiBlacklist: [] },
|
||||
"1.0.85": { force: false, minVersion: null, apiBlacklist: [] },
|
||||
"1.0.86": { force: false, minVersion: null, apiBlacklist: [] },
|
||||
"1.0.87": { force: false, minVersion: null, apiBlacklist: [] }
|
||||
};
|
||||
|
||||
// Explicit version-check exemptions, including all currently called frontend APIs.
|
||||
// Entries here take precedence over apiBlacklist, openidWhitelist and policy validation.
|
||||
const nonPlayerFunctions = new Set([
|
||||
// 微信支付相关
|
||||
"wx/payCallBack", "wx/iosPayCall", "wx/KeFuInfo", "wx/getOrderReward", "wx/getPayInfo", "wx/iosgetPayInfo", "wx/iosorderPaySig", "wx/orderPaySig",
|
||||
"suRecall",
|
||||
// 黄金矿工
|
||||
"goldMiner/merchantNotify", "goldMiner/index",
|
||||
// 用户付费统计
|
||||
"rechargeStats", "repairPayUser",
|
||||
"clearPay", "clearSrank", "clearPHBDataById", "creatCdk", "creatCDkTwo",
|
||||
"searchOrder", "searchPlayerDataByLevel",
|
||||
// Frontend API names after URL routing (getAddressRankSelf -> getAddressRank).
|
||||
"activityConfig/list", "address", "canRebornGift", "cloudRise/index", "comment",
|
||||
// "dayTaskInfo",
|
||||
"drawCat", "drawHead", "getAddressRank", "getFilm", "getHeadInfo",
|
||||
"getRank", "getSRank", "getShareLv", "getTaskReward", "getUserInfoByID",
|
||||
"jungleTreasure", "limitedTimeEvent",
|
||||
// "login",
|
||||
"miniProgramWelfare",
|
||||
"monthGetReward", "monthlyCard", "passCheck", "passCheckLv", "passCheckSettlement",
|
||||
"passCheckUpgrade", "pyramid/api", "rebornGift", "redeem", "setCatArr",
|
||||
"setForcedUpdate", "setUserLevel", "setWinStreak", "shareLvSuccess", "shopDouble",
|
||||
"signInActivityInfo", "userCoin", "userData", "userHealth", "userLevel", "userPower",
|
||||
"userProp", "wucaiTransfer",
|
||||
]);
|
||||
|
||||
export function isNonPlayerFunction(name: string): boolean {
|
||||
return name.startsWith("__") || nonPlayerFunctions.has(name)
|
||||
|| /(?:Admin|Jobs)$/.test(name) || /(?:^|\/)(?:admin|jobs|testPeriods)(?:\/|$)/.test(name);
|
||||
}
|
||||
|
||||
function parseVersion(value: unknown): number[] | null {
|
||||
if (typeof value !== "string" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value)) return null;
|
||||
const parts = value.split(".").map(Number);
|
||||
return parts.every(Number.isSafeInteger) ? parts : null;
|
||||
}
|
||||
|
||||
/** null means malformed; otherwise -1 / 0 / 1 (numeric components, not string ordering). */
|
||||
export function compareVersions(left: unknown, right: unknown): number | null {
|
||||
const a = parseVersion(left), b = parseVersion(right);
|
||||
if (!a || !b) return null;
|
||||
for (let index = 0; index < 3; index++) {
|
||||
if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function configUnavailable() {
|
||||
return { code: 0, errorCode: "CLIENT_UPDATE_CONFIG_UNAVAILABLE", data: null, msg: "服务暂不可用,请稍后重试" };
|
||||
}
|
||||
|
||||
export function validatePolicy(release: string, policy: VersionPolicy): boolean {
|
||||
if (!parseVersion(release) || !policy || typeof policy.force !== "boolean"
|
||||
|| !Array.isArray(policy.apiBlacklist)
|
||||
|| !policy.apiBlacklist.every(name => typeof name === "string" && /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(name))) return false;
|
||||
if (policy.minVersion === null) return !policy.force;
|
||||
const compared = compareVersions(policy.minVersion, release);
|
||||
return compared !== null && compared <= 0 && (!policy.force || compared === 0);
|
||||
}
|
||||
|
||||
/** Returns null to continue normal authentication/business logic, otherwise a business error. */
|
||||
export function checkClientVersion(
|
||||
functionName: string,
|
||||
clientVersion: unknown,
|
||||
release = currentVersion,
|
||||
policies: Record<string, VersionPolicy> = versions,
|
||||
) {
|
||||
if (isNonPlayerFunction(functionName)) return null;
|
||||
if (!Array.isArray(openidWhitelist)
|
||||
|| !openidWhitelist.every(openid => typeof openid === "string" && /^[A-Za-z0-9_-]+$/.test(openid))) return configUnavailable();
|
||||
const policy = policies && Object.prototype.hasOwnProperty.call(policies, release) ? policies[release] : null;
|
||||
if (!validatePolicy(release, policy)) return configUnavailable();
|
||||
if (functionName !== "login" && !policy.apiBlacklist.includes(functionName)) return null;
|
||||
if (policy.minVersion === null) return null;
|
||||
const compared = compareVersions(clientVersion, policy.minVersion);
|
||||
if (compared !== null && compared >= 0) return null;
|
||||
return {
|
||||
code: 0,
|
||||
errorCode: "CLIENT_UPDATE_REQUIRED",
|
||||
msg: "当前游戏版本过低,请更新后重新进入",
|
||||
data: { version: release, minVersion: policy.minVersion },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function (_ctx: FunctionContext) {
|
||||
return { data: 'hi, laf' }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
name: versionPolicy
|
||||
desc: "客户端版本策略(内部模块,人工维护后发布)"
|
||||
methods: []
|
||||
tags: []
|
||||
@@ -0,0 +1,180 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { currentVersion, versions, checkClientVersion, compareVersions, validatePolicy, openidWhitelist, shouldCheckPlayerVersion } from '../functions/versionPolicy.ts';
|
||||
|
||||
const identity = { users: [], usersAd: [], reads: 0, fail: false };
|
||||
globalThis.__versionCloud = { database: () => ({ collection: name => ({ where: query => ({
|
||||
async getOne() {
|
||||
identity.reads++;
|
||||
if (identity.fail) throw new Error('database unavailable');
|
||||
assert.equal(typeof query.token, 'string');
|
||||
return { data: identity[name].find(user => user.token === query.token) || null };
|
||||
},
|
||||
}) }) }) };
|
||||
|
||||
const hooks = registerHooks({ resolve(specifier, context, next) {
|
||||
if (specifier === '@/versionPolicy') return { url: new URL('../functions/versionPolicy.ts', import.meta.url).href, shortCircuit: true };
|
||||
if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__versionCloud', shortCircuit: true };
|
||||
return next(specifier, context);
|
||||
} });
|
||||
const { default: interceptor } = await import('../functions/__interceptor__.ts');
|
||||
hooks.deregister();
|
||||
const policy = (overrides = {}) => ({ force: false, minVersion: '1.0.83', apiBlacklist: ['gatedPlayer', 'gatedPlayerData', 'gatedPlayer/index'], ...overrides });
|
||||
const check = (name, version, overrides = {}) => checkClientVersion(name, version, '1.0.84', { '1.0.84': policy(overrides) });
|
||||
|
||||
test('checked-in current policy is valid and an explicitly disabled release accepts legacy clients', () => {
|
||||
assert.equal(validatePolicy(currentVersion, versions[currentVersion]), true);
|
||||
const disabled = { '1.0.82': policy({ minVersion: null, apiBlacklist: [] }) };
|
||||
assert.equal(checkClientVersion('gatedPlayer', undefined, '1.0.82', disabled), null);
|
||||
});
|
||||
|
||||
test('numeric comparison handles component boundaries and rejects ambiguous or malformed versions', () => {
|
||||
for (const [a, b, result] of [['1.0.10', '1.0.9', 1], ['1.0.83', '1.0.83', 0], ['1.9.99', '2.0.0', -1], ['1.0.99', '1.1.0', -1]]) {
|
||||
assert.equal(compareVersions(a, b), result);
|
||||
}
|
||||
for (const value of [undefined, null, 123, '', '1.0', '1.0.83-beta', ' 1.0.83', '01.0.83', ['1.0.83'], {}, '9007199254740992.0.0']) {
|
||||
assert.equal(compareVersions(value, '1.0.83'), null);
|
||||
assert.equal(check('gatedPlayer', value).errorCode, 'CLIENT_UPDATE_REQUIRED');
|
||||
}
|
||||
});
|
||||
|
||||
test('ordinary releases retain the minimum even when force is false; future versions are accepted', () => {
|
||||
for (const name of ['gatedPlayer', 'gatedPlayerData', 'gatedPlayer/index']) {
|
||||
const denied = check(name, '1.0.82');
|
||||
assert.equal(denied.errorCode, 'CLIENT_UPDATE_REQUIRED');
|
||||
assert.deepEqual(denied.data, { version: '1.0.84', minVersion: '1.0.83' });
|
||||
for (const version of ['1.0.83', '1.0.84', '2.0.0']) assert.equal(check(name, version), null);
|
||||
}
|
||||
assert.equal(check('userCoin', undefined), null, 'unlisted endpoints remain outside the gate');
|
||||
assert.equal(check('gatedPlayer/indexExtra', undefined), null, 'blacklist matching is exact');
|
||||
assert.equal(check('gatedPlayer', undefined, { apiBlacklist: [] }), null);
|
||||
});
|
||||
|
||||
test('server release selects the policy; client version cannot select an old or missing policy', () => {
|
||||
const policies = { '1.0.82': policy({ minVersion: null }), '1.0.84': policy() };
|
||||
assert.equal(checkClientVersion('gatedPlayer', '1.0.82', '1.0.84', policies).errorCode, 'CLIENT_UPDATE_REQUIRED');
|
||||
assert.equal(checkClientVersion('gatedPlayer', '9.9.99', '1.0.84', policies), null);
|
||||
});
|
||||
|
||||
test('invalid current configuration fails closed as service unavailable, never as upgrade required', () => {
|
||||
for (const override of [
|
||||
{ force: true }, { minVersion: '2.0.0' }, { minVersion: undefined }, { minVersion: '1.0' },
|
||||
{ force: 'false' }, { force: true, minVersion: null }, { apiBlacklist: null }, { apiBlacklist: ['/userData'] },
|
||||
]) assert.equal(check('gatedPlayer', '9.9.99', override).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE');
|
||||
assert.equal(check('gatedPlayer', '1.0.83', { force: true, minVersion: '1.0.84' }).errorCode, 'CLIENT_UPDATE_REQUIRED');
|
||||
assert.equal(checkClientVersion('gatedPlayer', '1.0.83', 'missing', {}).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE');
|
||||
assert.equal(check('gatedPlayer', undefined, { minVersion: null }), null);
|
||||
});
|
||||
|
||||
test('server callbacks, jobs, and administrative functions remain outside player checks', () => {
|
||||
for (const name of ['wx/payCallBack', 'wx/iosPayCall', 'wx/KeFuInfo', 'goldMiner/merchantNotify', 'wxPerformanceCall',
|
||||
'suRecall', 'passCheckJobs', 'pyramid/jobs', 'cloudRise/admin', 'abConfigAdmin', 'signInTestAdmin', '__init__']) {
|
||||
assert.equal(check(name, undefined, { apiBlacklist: [name] }), null);
|
||||
assert.equal(checkClientVersion(name, undefined, 'bad', {}), null, 'even invalid policy must not stop server callbacks');
|
||||
}
|
||||
});
|
||||
|
||||
test('Laf interceptor blocks before invoking business logic and preserves next response/context', async () => {
|
||||
assert.equal(interceptor.length, 2, 'Laf selects middleware semantics by parameter count');
|
||||
const original = versions[currentVersion];
|
||||
versions[currentVersion] = policy({ minVersion: currentVersion });
|
||||
let calls = 0;
|
||||
const next = async ctx => { calls++; return { business: true, context: ctx }; };
|
||||
try {
|
||||
for (const name of ['gatedPlayer', 'gatedPlayerData', 'gatedPlayer/index']) {
|
||||
const response = await interceptor({ __function_name: name, method: 'POST', body: { version: '1.0.1', action: 'read' } }, next);
|
||||
assert.equal(response.errorCode, 'CLIENT_UPDATE_REQUIRED');
|
||||
}
|
||||
assert.equal(calls, 0, 'no business side effects before an upgrade');
|
||||
const ctx = { __function_name: 'gatedPlayerData', method: 'POST', body: { version: currentVersion } };
|
||||
const response = await interceptor(ctx, next);
|
||||
assert.equal(response.context, ctx);
|
||||
assert.equal(calls, 1);
|
||||
await interceptor({ __function_name: 'userCoin', method: 'POST', body: {} }, next);
|
||||
await interceptor({ __function_name: 'gatedPlayer', method: 'OPTIONS' }, next);
|
||||
await interceptor({ __function_name: 'gatedPlayer', method: 'call' }, next);
|
||||
assert.equal(calls, 4);
|
||||
} finally { versions[currentVersion] = original; }
|
||||
});
|
||||
|
||||
test('uses runtime target name and correct body/query source, with nested path fallback', async () => {
|
||||
const original = versions[currentVersion];
|
||||
versions[currentVersion] = policy({ minVersion: currentVersion });
|
||||
const next = async () => ({ passed: true });
|
||||
try {
|
||||
const blocked = await interceptor({ __function_name: 'gatedPlayer', method: 'POST', body: { __function_name: 'unlisted' }, query: { version: currentVersion } }, next);
|
||||
assert.equal(blocked.errorCode, 'CLIENT_UPDATE_REQUIRED');
|
||||
assert.deepEqual(await interceptor({ __function_name: 'gatedPlayer', method: 'GET', query: { version: currentVersion } }, next), { passed: true });
|
||||
assert.equal((await interceptor({ request: { path: '/gatedPlayer/index', method: 'POST' }, body: {} }, next)).errorCode, 'CLIENT_UPDATE_REQUIRED');
|
||||
assert.equal((await interceptor({ method: 'POST', body: {} }, next)).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE');
|
||||
} finally { versions[currentVersion] = original; }
|
||||
});
|
||||
|
||||
test('policy module and interceptor have no direct HTTP methods', () => {
|
||||
for (const name of ['versionPolicy', '__interceptor__']) {
|
||||
const yaml = readFileSync(new URL(`../functions/${name}.yaml`, import.meta.url), 'utf8');
|
||||
assert.match(yaml, /methods: \[\]/);
|
||||
}
|
||||
});
|
||||
|
||||
test('openid whitelist is exact, empty means all players, and malformed entries fail closed', () => {
|
||||
const original = [...openidWhitelist];
|
||||
try {
|
||||
openidWhitelist.splice(0);
|
||||
assert.equal(shouldCheckPlayerVersion(undefined), true);
|
||||
openidWhitelist.push('openid-A');
|
||||
assert.equal(shouldCheckPlayerVersion('openid-A'), true);
|
||||
for (const openid of ['openid-a', 'other', undefined, { $ne: null }]) {
|
||||
assert.equal(shouldCheckPlayerVersion(openid), false);
|
||||
}
|
||||
for (const invalid of ['', ' openid-A', null, 123]) {
|
||||
openidWhitelist.splice(0, openidWhitelist.length, invalid);
|
||||
assert.equal(check('gatedPlayer', '1.0.82').errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE');
|
||||
assert.equal(check('wx/payCallBack', undefined), null);
|
||||
}
|
||||
} finally { openidWhitelist.splice(0, openidWhitelist.length, ...original); }
|
||||
});
|
||||
|
||||
test('scoped interceptor resolves tokens for nonexempt APIs and never trusts claimed openid', async () => {
|
||||
const original = versions[currentVersion], originalWhitelist = [...openidWhitelist];
|
||||
versions[currentVersion] = policy({ minVersion: currentVersion });
|
||||
openidWhitelist.splice(0, openidWhitelist.length, 'openid-target');
|
||||
identity.users = [{ token: 'target-token', openid: 'openid-target' }, { token: 'other-token', openid: 'openid-other' }];
|
||||
identity.usersAd = [{ token: 'ad-token', openid: 'openid-target' }];
|
||||
identity.reads = 0;
|
||||
let calls = 0;
|
||||
const next = async () => { calls++; return { passed: true }; };
|
||||
const post = (body = {}, name = 'gatedPlayerData') => interceptor({ __function_name: name, method: 'POST', body: { version: '1.0.1', ...body } }, next);
|
||||
try {
|
||||
assert.deepEqual(await post({ openid: 'openid-target' }, 'login'), { passed: true });
|
||||
assert.equal(identity.reads, 0, 'exempt login needs no identity lookup in interceptor');
|
||||
calls = 0;
|
||||
for (const token of ['target-token', 'ad-token']) {
|
||||
assert.equal((await post({ token, openid: 'other', uid: 'forged', gameName: 'fake' })).errorCode, 'CLIENT_UPDATE_REQUIRED');
|
||||
}
|
||||
assert.equal(calls, 0, 'targeted old requests cannot enter business code');
|
||||
for (const token of ['other-token', 'unknown', '', undefined, { $ne: null }]) {
|
||||
assert.deepEqual(await post({ token, openid: 'openid-target' }), { passed: true });
|
||||
}
|
||||
assert.equal((await interceptor({ __function_name: 'gatedPlayerData', method: 'GET', query: { version: '1.0.1', token: 'target-token' }, body: { token: 'other-token' } }, next)).errorCode, 'CLIENT_UPDATE_REQUIRED');
|
||||
const beforeReads = identity.reads;
|
||||
await post({ token: 'target-token', version: currentVersion });
|
||||
await post({ token: 'target-token' }, 'userCoin');
|
||||
await post({ token: 'target-token' }, 'wx/payCallBack');
|
||||
assert.equal(identity.reads, beforeReads, 'compatible/unlisted/callback requests need no identity lookup');
|
||||
const businessError = new Error('business error');
|
||||
await assert.rejects(interceptor({ __function_name: 'gatedPlayerData', method: 'POST', body: { token: 'other-token', version: '1.0.1' } }, async () => { throw businessError; }), error => error === businessError);
|
||||
identity.fail = true;
|
||||
const beforeCalls = calls;
|
||||
assert.equal((await post({ token: 'target-token' })).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE');
|
||||
assert.equal(calls, beforeCalls, 'identity lookup failure cannot bypass the gate');
|
||||
openidWhitelist.splice(0);
|
||||
assert.equal((await post()).errorCode, 'CLIENT_UPDATE_REQUIRED', 'empty list restores full rollout without identity lookup');
|
||||
} finally {
|
||||
identity.fail = false;
|
||||
versions[currentVersion] = original;
|
||||
openidWhitelist.splice(0, openidWhitelist.length, ...originalWhitelist);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { registerHooks } from "node:module";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { currentVersion, versions, openidWhitelist } from "../functions/versionPolicy.ts";
|
||||
|
||||
const functionUrl = new URL("../functions/login.ts", import.meta.url);
|
||||
const dailyTaskConfigUrl = new URL("../functions/dailyTaskConfig.ts", import.meta.url);
|
||||
@@ -113,6 +114,7 @@ globalThis.__loginWucaiCloudMock = {
|
||||
},
|
||||
},
|
||||
async fetch(url) {
|
||||
state.wxCalls = (state.wxCalls || 0) + 1;
|
||||
const code = new URL(url).searchParams.get("js_code") || "code-1";
|
||||
const suffix = code.replace("code-", "");
|
||||
return {
|
||||
@@ -145,6 +147,7 @@ registerHooks({
|
||||
return next(url, url.endsWith('.ts') ? { ...context, format: 'module-typescript' } : context);
|
||||
},
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === "@/versionPolicy") return { url: new URL("../functions/versionPolicy.ts", import.meta.url).href, shortCircuit: true };
|
||||
assert(!specifier.startsWith("@/goldMiner/") && !specifier.startsWith("@/dailyTask"),
|
||||
"CloudRise-only login must load without gold miner or daily task V2 modules");
|
||||
if (specifier === "@/cloudRise/periods") return {url:"data:text/javascript,export async function ensureOnLogin(uid) {globalThis.__cloudRiseLoginUid = uid;}",shortCircuit:true};
|
||||
@@ -182,8 +185,10 @@ registerHooks({
|
||||
},
|
||||
});
|
||||
const { default: login } = await import(functionUrl);
|
||||
const { default: versionInterceptor } = await import(new URL("../functions/__interceptor__.ts", import.meta.url));
|
||||
|
||||
function reset(users = [], source = null) {
|
||||
state.wxCalls = 0;
|
||||
globalThis.__goldMinerLogin.recovered = null;
|
||||
globalThis.__goldMinerLogin.calls = [];
|
||||
globalThis.__goldMinerLogin.fail = false;
|
||||
@@ -944,3 +949,27 @@ test("first auto import does not overwrite a target that gained resources concur
|
||||
assert.equal(state.users[0].coinAmount, 25);
|
||||
assert.equal(state.users[0].wucai_type, -1);
|
||||
});
|
||||
|
||||
test("exempt login accepts old clients with and without an openid whitelist, even with invalid policy", async () => {
|
||||
const original = versions[currentVersion], originalWhitelist = [...openidWhitelist];
|
||||
try {
|
||||
for (const whitelist of [[], ["openid-1"], ["openid-other"]]) {
|
||||
openidWhitelist.splice(0, openidWhitelist.length, ...whitelist);
|
||||
for (const minVersion of [currentVersion, null]) {
|
||||
versions[currentVersion] = { force: true, minVersion, apiBlacklist: ["login"] };
|
||||
for (const version of [undefined, "invalid", "1.0.1"]) {
|
||||
reset();
|
||||
const result = await versionInterceptor({ __function_name: "login", method: "POST", body: {
|
||||
code: "code-1", version,
|
||||
}, headers: {}, socket: {} }, login);
|
||||
assert.equal(result.code, 1, "login exemption wins over the blacklist, whitelist and policy validation");
|
||||
assert.equal(state.users[0].openid, "openid-1");
|
||||
assert.equal(state.wxCalls, 1, "normal login exchanges the code once");
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
versions[currentVersion] = original;
|
||||
openidWhitelist.splice(0, openidWhitelist.length, ...originalWhitelist);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user