fix(sign-in): 序列化签到状态并新增测试管理接口

This commit is contained in:
huanghaipeng 2026-09-07 13:54:51 +08:00
parent 92fd96a7fd
commit ca1e9012c5
6 changed files with 404 additions and 6 deletions

View File

@ -146,7 +146,7 @@ export async function activateSignInActivity(user: any, now = Date.now()) {
}; };
const result = await db.collection(USERS_COLLECTION) const result = await db.collection(USERS_COLLECTION)
.where({ _id: user._id }) .where({ _id: user._id })
.update({ signInActivity: state }); .update({ signInActivity: JSON.stringify(state) });
const activated = getUpdatedCount(result) > 0; const activated = getUpdatedCount(result) > 0;
if (activated) { if (activated) {
user.signInActivity = state; user.signInActivity = state;
@ -176,7 +176,9 @@ export async function addSignInClaim(payload: any) {
export async function markSignInCompleted(uid: string, state: SignInActivityState, completedAt: number) { export async function markSignInCompleted(uid: string, state: SignInActivityState, completedAt: number) {
const nextState = { ...state, completedAt }; const nextState = { ...state, completedAt };
await db.collection(USERS_COLLECTION).where({ _id: uid }).update({ signInActivity: nextState }); await db.collection(USERS_COLLECTION).where({ _id: uid }).update({
signInActivity: JSON.stringify(nextState),
});
return nextState; return nextState;
} }

View File

@ -0,0 +1,180 @@
import cloud from '@lafjs/cloud'
import {
buildClaimId,
getActiveSignInConfig,
getChinaDateKey,
getChinaDayStart,
getSignInClaims,
normalizeSignInState,
} from "@/SignInActivity";
const db = cloud.database();
const USERS_COLLECTION = "users";
const CLAIM_COLLECTION = "sign_in_activity_claims";
const DAY_MS = 24 * 60 * 60 * 1000;
export default async function (ctx: FunctionContext) {
const adminToken = ctx.body?.adminToken || ctx.query?.adminToken;
if (!checkAdminToken(adminToken)) {
return { code: 0, data: null, msg: "测试接口未启用或管理密钥错误" };
}
const action = String(ctx.body?.action || ctx.query?.action || "inspect");
const uid = ctx.body?.uid || ctx.query?.uid;
if (!uid) {
return { code: 0, data: null, msg: "未获取到uid" };
}
const userResult = await db.collection(USERS_COLLECTION).where({ _id: uid }).getOne();
const user = userResult.data;
if (!user) {
return { code: 0, data: null, msg: "未获取到用户信息" };
}
switch (action) {
case "inspect":
return await inspectUser(user);
case "reset":
await clearClaims(uid);
await updateUser(uid, { signInActivity: null });
return await inspectUser({ ...user, signInActivity: null });
case "set_level":
return await setLevel(user, ctx.body?.levelAmount);
case "prepare":
return await prepareScenario(user, String(ctx.body?.scenario || ""));
default:
return { code: 0, data: null, msg: "无效action,请使用 inspect、reset、set_level 或 prepare" };
}
}
function checkAdminToken(adminToken: string) {
const env = (globalThis as any).process?.env || {};
const expected = String(env.SIGN_IN_TEST_ADMIN_TOKEN || "");
return !!expected && !!adminToken && adminToken === expected;
}
async function setLevel(user: any, rawLevel: any) {
const levelAmount = Number(rawLevel);
if (!Number.isInteger(levelAmount) || levelAmount < 0) {
return { code: 0, data: null, msg: "levelAmount必须是大于等于0的整数" };
}
await updateUser(user._id, { levelAmount });
return await inspectUser({ ...user, levelAmount });
}
async function prepareScenario(user: any, scenario: string) {
const config = await getActiveSignInConfig();
if (!config) {
return { code: 0, data: null, msg: "签到活动配置不存在或配置无效" };
}
const now = Date.now();
const triggerLevel = Number(config.triggerLevel);
if (scenario === "locked" || scenario === "eligible") {
const levelAmount = scenario === "locked" ? Math.max(0, triggerLevel - 1) : triggerLevel;
await clearClaims(user._id);
await updateUser(user._id, { levelAmount, signInActivity: null });
return await inspectUser({ ...user, levelAmount, signInActivity: null }, scenario);
}
const dayReadyMatch = /^day_([1-7])_ready$/.exec(scenario);
if (dayReadyMatch) {
const claimedCount = Number(dayReadyMatch[1]) - 1;
return await prepareActivity(user, config, now, scenario, claimedCount, false, false);
}
if (scenario === "claimed_today") {
return await prepareActivity(user, config, now, scenario, 1, true, false);
}
if (scenario === "expired") {
return await prepareActivity(user, config, now, scenario, 0, false, true);
}
if (scenario === "completed") {
return await prepareActivity(user, config, now, scenario, 7, false, false, true);
}
return {
code: 0,
data: null,
msg: "无效scenario,请使用 locked、eligible、day_1_ready至day_7_ready、claimed_today、expired或completed",
};
}
async function prepareActivity(
user: any,
config: any,
now: number,
scenario: string,
claimedCount: number,
todayClaimed: boolean,
expired: boolean,
completed = false,
) {
await clearClaims(user._id);
const todayStart = getChinaDayStart(now);
const startAt = expired
? todayStart - Number(config.durationDays) * DAY_MS
: todayStart - Math.min(claimedCount, 7) * DAY_MS;
const endAt = expired
? todayStart
: startAt + Number(config.durationDays) * DAY_MS;
const signInActivity = {
activityId: config.activityId,
startAt,
endAt,
activatedAt: startAt,
completedAt: completed ? now : null,
};
await updateUser(user._id, {
levelAmount: Number(config.triggerLevel),
signInActivity: JSON.stringify(signInActivity),
});
for (let index = 0; index < claimedCount; index++) {
const rewardDay = index + 1;
const daysAgo = todayClaimed && index === claimedCount - 1
? 0
: claimedCount - index;
const claimedAt = now - daysAgo * DAY_MS;
const dateKey = getChinaDateKey(claimedAt);
const claimId = buildClaimId(user._id, config.activityId, dateKey);
await db.collection(CLAIM_COLLECTION).add({
_id: claimId,
claimId,
uid: user._id,
activityId: config.activityId,
dateKey,
rewardDay,
rewards: config.rewards[index].items,
claimedAt,
testPrepared: true,
});
}
return await inspectUser({
...user,
levelAmount: Number(config.triggerLevel),
signInActivity,
}, scenario);
}
async function inspectUser(user: any, scenario?: string) {
const state = normalizeSignInState(user.signInActivity);
const claims = state ? await getSignInClaims(user._id, state.activityId) : [];
return {
code: 1,
data: {
scenario: scenario || null,
uid: user._id,
levelAmount: Number(user.levelAmount) || 0,
signInActivity: state,
claimedCount: claims.length,
claims,
},
msg: "测试状态准备成功",
};
}
async function clearClaims(uid: string) {
await db.collection(CLAIM_COLLECTION).where({ uid }).remove({ multi: true });
}
async function updateUser(uid: string, data: any) {
await db.collection(USERS_COLLECTION).where({ _id: uid }).update(data);
}

View File

@ -0,0 +1,7 @@
name: signInTestAdmin
desc: "仅用于测试环境准备七日签到账号状态"
methods:
- POST
tags:
- activity
- test

View File

@ -234,7 +234,7 @@ interface RewardItem {
- 当前可导入配置文件:`laf-cloud/sign-in-activity-config.json`。 - 当前可导入配置文件:`laf-cloud/sign-in-activity-config.json`。
- 用户开始活动后通过 `activityId` 继续读取配置,因此旧配置可以禁用,但在参与用户全部结束前不能删除。 - 用户开始活动后通过 `activityId` 继续读取配置,因此旧配置可以禁用,但在参与用户全部结束前不能删除。
用户活动状态写入 `users.signInActivity`: 用户活动状态写入 `users.signInActivity`。数据库中使用 JSON 字符串保存,读取时由服务端解析为下述结构;这样可以兼容字段原值为 `null` 的用户,并避免 Laf 将对象更新展开成点路径后触发 MongoDB 错误:
```ts ```ts
interface SignInActivityState { interface SignInActivityState {
@ -261,3 +261,7 @@ interface SignInActivityState {
- `infinite_health.count` 单位是秒。 - `infinite_health.count` 单位是秒。
- Day 7 的 `cat_skin.itemId` 当前为 12,客户端必须先支持猫 12 的资源、展示和幂等写入 `catArr`。 - Day 7 的 `cat_skin.itemId` 当前为 12,客户端必须先支持猫 12 的资源、展示和幂等写入 `catArr`。
- 倒计时使用 `serverNow` 和 `endAt`,不要完全依赖客户端本地时间。 - 倒计时使用 `serverNow` 和 `endAt`,不要完全依赖客户端本地时间。
## 9. 单账号测试接口
测试环境可发布 `POST /signInTestAdmin`,用一个账号快速准备未达标、待领取 Day 1~7、当天已领、过期和完成状态。该接口的密钥配置、参数和测试顺序见 `laf-cloud/signInActivity.TESTING.md`;禁止在正式环境发布。

View File

@ -0,0 +1,80 @@
# 七日签到单账号快速测试
## 安全配置
只在测试环境发布 `signInTestAdmin`,并在 Laf 应用环境变量中设置一个高强度随机值:
```text
SIGN_IN_TEST_ADMIN_TOKEN=<随机管理密钥>
```
环境变量未配置或请求密钥不匹配时,接口完全不可用。不要把该密钥写入前端代码,也不要在正式环境发布此函数。
接口使用 `POST /signInTestAdmin` 和 `application/x-www-form-urlencoded`。
公共参数:
| 参数 | 说明 |
| --- | --- |
| `adminToken` | 与环境变量 `SIGN_IN_TEST_ADMIN_TOKEN` 相同 |
| `uid` | 唯一测试账号的用户 `_id` |
| `action` | `inspect`、`reset`、`set_level` 或 `prepare` |
## 操作接口
### 查看当前测试状态
```text
action=inspect&uid=<uid>&adminToken=<token>
```
返回玩家 `levelAmount`、`signInActivity`、领取数量和领取记录。
### 清空签到状态
```text
action=reset&uid=<uid>&adminToken=<token>
```
清空该账号的 `users.signInActivity` 和所有 `sign_in_activity_claims` 记录,不修改关卡和资产。
### 设置测试关卡
```text
action=set_level&uid=<uid>&levelAmount=23&adminToken=<token>
```
只修改 `users.levelAmount`,不修改签到状态和资产。
### 准备测试场景
```text
action=prepare&uid=<uid>&scenario=<场景>&adminToken=<token>
```
| scenario | 准备结果 | 下一步验证 |
| --- | --- | --- |
| `locked` | 关卡设为门槛减 1,清空活动和领取记录 | 请求活动信息应返回 `data: false` |
| `eligible` | 关卡设为门槛,清空活动和领取记录 | 请求活动信息应创建活动并返回奖励列表 |
| `day_1_ready` | 活动已开启且尚未领取 | 领取 Day 1 |
| `day_2_ready` | 已预置 Day 1 历史记录 | 领取 Day 2 |
| `day_3_ready` | 已预置 Day 1~2 历史记录 | 领取 Day 3 |
| `day_4_ready` | 已预置 Day 1~3 历史记录 | 领取 Day 4 |
| `day_5_ready` | 已预置 Day 1~4 历史记录 | 领取 Day 5 |
| `day_6_ready` | 已预置 Day 1~5 历史记录 | 领取 Day 6 |
| `day_7_ready` | 已预置 Day 1~6 历史记录 | 领取 Day 7 并验证完成状态和猫 12 |
| `claimed_today` | 已预置今天的 Day 1 领取记录 | 再次领取应返回“今天已经领取” |
| `expired` | 活动结束时间设为当前自然日零点 | 活动状态应为 `expired`,领取应失败 |
| `completed` | 已预置七天领取记录及完成时间 | 活动状态应为 `completed`,领取应失败 |
每次 `prepare` 都会先删除该测试账号原有的签到领取记录,并覆盖该账号的签到活动状态,但不会发放或扣除任何实际资产。
## 推荐测试顺序
1. `prepare locked`,验证未达标不展示。
2. `prepare eligible`,验证首次请求创建活动且重复请求不重置时间。
3. 依次使用 `day_1_ready` 至 `day_7_ready`,逐档验证前端奖励。
4. `prepare claimed_today`,验证重复点击和重复请求。
5. `prepare expired`,验证过期页面和禁止领取。
6. `prepare completed`,验证完成页面和禁止领取。
7. 测试结束执行 `reset`。

View File

@ -6,6 +6,7 @@ import { registerHooks } from "node:module";
const infoUrl = new URL("../functions/signInActivityInfo.ts", import.meta.url); const infoUrl = new URL("../functions/signInActivityInfo.ts", import.meta.url);
const claimUrl = new URL("../functions/signInClaim.ts", import.meta.url); const claimUrl = new URL("../functions/signInClaim.ts", import.meta.url);
const testAdminUrl = new URL("../functions/signInTestAdmin.ts", import.meta.url);
const domainUrl = new URL("../functions/SignInActivity.ts", import.meta.url); const domainUrl = new URL("../functions/SignInActivity.ts", import.meta.url);
const productionConfigUrl = new URL("../sign-in-activity-config.json", import.meta.url); const productionConfigUrl = new URL("../sign-in-activity-config.json", import.meta.url);
@ -13,6 +14,7 @@ const state = {
user: null, user: null,
configs: [], configs: [],
claims: [], claims: [],
lastUserUpdate: null,
}; };
globalThis.__signInCloudMock = { globalThis.__signInCloudMock = {
@ -36,9 +38,24 @@ globalThis.__signInCloudMock = {
}, },
async update(payload) { async update(payload) {
const targets = getCollection(name).filter(item => matches(item, query)); const targets = getCollection(name).filter(item => matches(item, query));
targets.forEach(item => Object.assign(item, structuredClone(payload))); if (name === "users") state.lastUserUpdate = structuredClone(payload);
targets.forEach(item => {
if (item.signInActivity === null
&& payload.signInActivity
&& typeof payload.signInActivity === "object") {
throw new Error("Cannot create field 'activatedAt' in element {signInActivity: null}");
}
Object.assign(item, structuredClone(payload));
});
return { updated: targets.length }; return { updated: targets.length };
}, },
async remove() {
if (name !== "sign_in_activity_claims") throw new Error("unexpected remove");
const kept = state.claims.filter(item => !matches(item, query));
const deleted = state.claims.length - kept.length;
state.claims = kept;
return { deleted };
},
}; };
}, },
}; };
@ -70,6 +87,7 @@ registerHooks({
const { default: activityInfo } = await import(infoUrl); const { default: activityInfo } = await import(infoUrl);
const { default: claimReward } = await import(claimUrl); const { default: claimReward } = await import(claimUrl);
const { default: signInTestAdmin } = await import(testAdminUrl);
const { getChinaDayStart, isValidSignInConfig } = await import(domainUrl); const { getChinaDayStart, isValidSignInConfig } = await import(domainUrl);
const config = { const config = {
@ -95,6 +113,7 @@ function reset(userOverrides = {}) {
}; };
state.configs = [structuredClone(config)]; state.configs = [structuredClone(config)];
state.claims = []; state.claims = [];
state.lastUserUpdate = null;
} }
function getCollection(name) { function getCollection(name) {
@ -118,8 +137,10 @@ function withNow(now, callback) {
test("cloud functions and yaml files exist", () => { test("cloud functions and yaml files exist", () => {
assert.equal(existsSync(infoUrl), true); assert.equal(existsSync(infoUrl), true);
assert.equal(existsSync(claimUrl), true); assert.equal(existsSync(claimUrl), true);
assert.equal(existsSync(testAdminUrl), true);
assert.equal(existsSync(new URL("../functions/signInActivityInfo.yaml", import.meta.url)), true); assert.equal(existsSync(new URL("../functions/signInActivityInfo.yaml", import.meta.url)), true);
assert.equal(existsSync(new URL("../functions/signInClaim.yaml", import.meta.url)), true); assert.equal(existsSync(new URL("../functions/signInClaim.yaml", import.meta.url)), true);
assert.equal(existsSync(new URL("../functions/signInTestAdmin.yaml", import.meta.url)), true);
}); });
test("production configuration contains the approved level and rewards", async () => { test("production configuration contains the approved level and rewards", async () => {
@ -154,7 +175,18 @@ test("activity info activates an eligible player and returns rewards", async ()
assert.equal(result.data.triggerLevel, 10); assert.equal(result.data.triggerLevel, 10);
assert.equal(result.data.triggerReached, true); assert.equal(result.data.triggerReached, true);
assert.equal(result.data.rewards.length, 7); assert.equal(result.data.rewards.length, 7);
assert.equal(state.user.signInActivity.startAt, getChinaDayStart(now)); assert.equal(typeof state.lastUserUpdate.signInActivity, "string");
assert.equal(JSON.parse(state.lastUserUpdate.signInActivity).startAt, getChinaDayStart(now));
});
test("activity info activates when the persisted activity state is null", async () => {
const now = Date.UTC(2026, 8, 2, 1);
reset({ levelAmount: 10, signInActivity: null });
const result = await withNow(now, () => activityInfo({ body: { uid: "u1", token: "secret" } }));
assert.equal(result.code, 1);
assert.equal(result.data.status, "active");
assert.equal(typeof state.lastUserUpdate.signInActivity, "string");
assert.equal(JSON.parse(state.lastUserUpdate.signInActivity).activatedAt, now);
}); });
test("claims accumulate across non-consecutive days and reject same-day repeats", async () => { test("claims accumulate across non-consecutive days and reject same-day repeats", async () => {
@ -250,7 +282,7 @@ test("seven different days complete the activity", async () => {
assert.equal(result.code, 1); assert.equal(result.code, 1);
assert.equal(result.data.completed, true); assert.equal(result.data.completed, true);
assert.equal(result.data.claimedCount, 7); assert.equal(result.data.claimedCount, 7);
assert.equal(typeof state.user.signInActivity.completedAt, "number"); assert.equal(typeof JSON.parse(state.user.signInActivity).completedAt, "number");
}); });
test("invalid token and usersAd requests are rejected without writes", async () => { test("invalid token and usersAd requests are rejected without writes", async () => {
@ -263,3 +295,96 @@ test("invalid token and usersAd requests are rejected without writes", async ()
assert.equal(state.claims.length, 0); assert.equal(state.claims.length, 0);
assert.equal(state.user.signInActivity, undefined); assert.equal(state.user.signInActivity, undefined);
}); });
test("test admin is disabled without the server-side admin token", async () => {
reset({ levelAmount: 10 });
const oldToken = process.env.SIGN_IN_TEST_ADMIN_TOKEN;
delete process.env.SIGN_IN_TEST_ADMIN_TOKEN;
try {
const result = await signInTestAdmin({
body: { action: "inspect", uid: "u1", adminToken: "guess" },
query: {},
});
assert.equal(result.code, 0);
assert.match(result.msg, /未启用|密钥错误/);
} finally {
if (oldToken === undefined) delete process.env.SIGN_IN_TEST_ADMIN_TOKEN;
else process.env.SIGN_IN_TEST_ADMIN_TOKEN = oldToken;
}
});
test("one account can be prepared for locked and eligible scenarios", async () => {
reset({ levelAmount: 99, signInActivity: { activityId: "old", startAt: 1, endAt: 2, activatedAt: 1 } });
await withAdminToken(async adminToken => {
const locked = await signInTestAdmin({
body: { action: "prepare", scenario: "locked", uid: "u1", adminToken },
query: {},
});
assert.equal(locked.code, 1);
assert.equal(state.user.levelAmount, 9);
assert.equal(state.user.signInActivity, null);
const eligible = await signInTestAdmin({
body: { action: "prepare", scenario: "eligible", uid: "u1", adminToken },
query: {},
});
assert.equal(eligible.code, 1);
assert.equal(state.user.levelAmount, 10);
assert.equal(state.user.signInActivity, null);
const info = await activityInfo({ body: { uid: "u1", token: "secret" } });
assert.equal(info.data.status, "active");
});
});
test("test admin prepares every reward day for the same account", async () => {
const now = Date.UTC(2026, 8, 4, 2);
reset({ levelAmount: 10 });
await withNow(now, () => withAdminToken(async adminToken => {
for (let day = 1; day <= 7; day++) {
const prepared = await signInTestAdmin({
body: { action: "prepare", scenario: `day_${day}_ready`, uid: "u1", adminToken },
query: {},
});
assert.equal(prepared.code, 1);
assert.equal(prepared.data.claimedCount, day - 1);
const claim = await claimReward({ body: { uid: "u1", token: "secret" } });
assert.equal(claim.code, 1);
assert.equal(claim.data.rewardDay, day);
assert.equal(claim.data.completed, day === 7);
}
}));
});
test("test admin prepares claimed, expired, and completed states", async () => {
const now = Date.UTC(2026, 8, 4, 2);
reset({ levelAmount: 10 });
await withNow(now, () => withAdminToken(async adminToken => {
for (const expected of [
{ scenario: "claimed_today", status: "active", claimMessage: /今天已经领取/ },
{ scenario: "expired", status: "expired", claimMessage: /活动已结束/ },
{ scenario: "completed", status: "completed", claimMessage: /全部领取/ },
]) {
const prepared = await signInTestAdmin({
body: { action: "prepare", scenario: expected.scenario, uid: "u1", adminToken },
query: {},
});
assert.equal(prepared.code, 1);
const info = await activityInfo({ body: { uid: "u1", token: "secret" } });
assert.equal(info.data.status, expected.status);
const claim = await claimReward({ body: { uid: "u1", token: "secret" } });
assert.equal(claim.code, 0);
assert.match(claim.msg, expected.claimMessage);
}
}));
});
async function withAdminToken(callback) {
const oldToken = process.env.SIGN_IN_TEST_ADMIN_TOKEN;
process.env.SIGN_IN_TEST_ADMIN_TOKEN = "test-admin-secret";
try {
return await callback("test-admin-secret");
} finally {
if (oldToken === undefined) delete process.env.SIGN_IN_TEST_ADMIN_TOKEN;
else process.env.SIGN_IN_TEST_ADMIN_TOKEN = oldToken;
}
}