diff --git a/laf-cloud/functions/SignInActivity.ts b/laf-cloud/functions/SignInActivity.ts index 3f707d4..e93d591 100644 --- a/laf-cloud/functions/SignInActivity.ts +++ b/laf-cloud/functions/SignInActivity.ts @@ -146,7 +146,7 @@ export async function activateSignInActivity(user: any, now = Date.now()) { }; const result = await db.collection(USERS_COLLECTION) .where({ _id: user._id }) - .update({ signInActivity: state }); + .update({ signInActivity: JSON.stringify(state) }); const activated = getUpdatedCount(result) > 0; if (activated) { user.signInActivity = state; @@ -176,7 +176,9 @@ export async function addSignInClaim(payload: any) { export async function markSignInCompleted(uid: string, state: SignInActivityState, completedAt: number) { 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; } diff --git a/laf-cloud/functions/signInTestAdmin.ts b/laf-cloud/functions/signInTestAdmin.ts new file mode 100644 index 0000000..a5f4a2a --- /dev/null +++ b/laf-cloud/functions/signInTestAdmin.ts @@ -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); +} diff --git a/laf-cloud/functions/signInTestAdmin.yaml b/laf-cloud/functions/signInTestAdmin.yaml new file mode 100644 index 0000000..8f4345a --- /dev/null +++ b/laf-cloud/functions/signInTestAdmin.yaml @@ -0,0 +1,7 @@ +name: signInTestAdmin +desc: "仅用于测试环境准备七日签到账号状态" +methods: + - POST +tags: + - activity + - test diff --git a/laf-cloud/signInActivity.API.md b/laf-cloud/signInActivity.API.md index 2258a9b..184a091 100644 --- a/laf-cloud/signInActivity.API.md +++ b/laf-cloud/signInActivity.API.md @@ -234,7 +234,7 @@ interface RewardItem { - 当前可导入配置文件:`laf-cloud/sign-in-activity-config.json`。 - 用户开始活动后通过 `activityId` 继续读取配置,因此旧配置可以禁用,但在参与用户全部结束前不能删除。 -用户活动状态写入 `users.signInActivity`: +用户活动状态写入 `users.signInActivity`。数据库中使用 JSON 字符串保存,读取时由服务端解析为下述结构;这样可以兼容字段原值为 `null` 的用户,并避免 Laf 将对象更新展开成点路径后触发 MongoDB 错误: ```ts interface SignInActivityState { @@ -261,3 +261,7 @@ interface SignInActivityState { - `infinite_health.count` 单位是秒。 - Day 7 的 `cat_skin.itemId` 当前为 12,客户端必须先支持猫 12 的资源、展示和幂等写入 `catArr`。 - 倒计时使用 `serverNow` 和 `endAt`,不要完全依赖客户端本地时间。 + +## 9. 单账号测试接口 + +测试环境可发布 `POST /signInTestAdmin`,用一个账号快速准备未达标、待领取 Day 1~7、当天已领、过期和完成状态。该接口的密钥配置、参数和测试顺序见 `laf-cloud/signInActivity.TESTING.md`;禁止在正式环境发布。 diff --git a/laf-cloud/signInActivity.TESTING.md b/laf-cloud/signInActivity.TESTING.md new file mode 100644 index 0000000..deae1b5 --- /dev/null +++ b/laf-cloud/signInActivity.TESTING.md @@ -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=&adminToken= +``` + +返回玩家 `levelAmount`、`signInActivity`、领取数量和领取记录。 + +### 清空签到状态 + +```text +action=reset&uid=&adminToken= +``` + +清空该账号的 `users.signInActivity` 和所有 `sign_in_activity_claims` 记录,不修改关卡和资产。 + +### 设置测试关卡 + +```text +action=set_level&uid=&levelAmount=23&adminToken= +``` + +只修改 `users.levelAmount`,不修改签到状态和资产。 + +### 准备测试场景 + +```text +action=prepare&uid=&scenario=<场景>&adminToken= +``` + +| 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`。 diff --git a/laf-cloud/tests/sign-in-activity.test.mjs b/laf-cloud/tests/sign-in-activity.test.mjs index 34eb8ed..52daaaa 100644 --- a/laf-cloud/tests/sign-in-activity.test.mjs +++ b/laf-cloud/tests/sign-in-activity.test.mjs @@ -6,6 +6,7 @@ import { registerHooks } from "node:module"; const infoUrl = new URL("../functions/signInActivityInfo.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 productionConfigUrl = new URL("../sign-in-activity-config.json", import.meta.url); @@ -13,6 +14,7 @@ const state = { user: null, configs: [], claims: [], + lastUserUpdate: null, }; globalThis.__signInCloudMock = { @@ -36,9 +38,24 @@ globalThis.__signInCloudMock = { }, async update(payload) { 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 }; }, + 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: claimReward } = await import(claimUrl); +const { default: signInTestAdmin } = await import(testAdminUrl); const { getChinaDayStart, isValidSignInConfig } = await import(domainUrl); const config = { @@ -95,6 +113,7 @@ function reset(userOverrides = {}) { }; state.configs = [structuredClone(config)]; state.claims = []; + state.lastUserUpdate = null; } function getCollection(name) { @@ -118,8 +137,10 @@ function withNow(now, callback) { test("cloud functions and yaml files exist", () => { assert.equal(existsSync(infoUrl), 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/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 () => { @@ -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.triggerReached, true); 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 () => { @@ -250,7 +282,7 @@ test("seven different days complete the activity", async () => { assert.equal(result.code, 1); assert.equal(result.data.completed, true); 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 () => { @@ -263,3 +295,96 @@ test("invalid token and usersAd requests are rejected without writes", async () assert.equal(state.claims.length, 0); 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; + } +}