diff --git a/laf-cloud/functions/signInActivityInfo.ts b/laf-cloud/functions/signInActivityInfo.ts index b35bef4..8e745d1 100644 --- a/laf-cloud/functions/signInActivityInfo.ts +++ b/laf-cloud/functions/signInActivityInfo.ts @@ -1,5 +1,6 @@ import cloud from '@lafjs/cloud' import Utils from "@/Utils"; +import signInClaim from "@/signInClaim"; import { activateSignInActivity, getActiveSignInConfig, @@ -30,7 +31,7 @@ export default async function (ctx: FunctionContext) { return { code: 0, data: null, msg: "token校验失败" }; } - const now = Date.now(); + let now = Date.now(); let state = normalizeSignInState(user.signInActivity); let config = state ? await getSignInConfig(state.activityId) @@ -51,7 +52,22 @@ export default async function (ctx: FunctionContext) { } } - const claims = await getSignInClaims(uid, state.activityId); + let claims = await getSignInClaims(uid, state.activityId); + const canAutoClaim = getSignInActivityStatus(state, claims.length, now) === "active" + && !claims.some(claim => String(claim.dateKey) === getChinaDateKey(now)); + if (canAutoClaim) { + // 等待领取落库后再返回;不在云函数响应结束后启动无人等待的后台任务。 + const result = await signInClaim(ctx); + now = Date.now(); + claims = await getSignInClaims(uid, state.activityId); + // 并发请求可能已领取成功,以数据库记录为准;其他失败不伪装成成功。 + if (result.code !== 1 + && !claims.some(claim => String(claim.dateKey) === getChinaDateKey(now)) + && getSignInActivityStatus(state, claims.length, now) === "active") { + return result; + } + } + const todayClaim = claims.find(claim => String(claim.dateKey) === getChinaDateKey(now)); const claimedDays = new Set(claims.map(claim => Number(claim.rewardDay))); const todayClaimed = claims.some(claim => String(claim.dateKey) === getChinaDateKey(now)); const claimedCount = claims.length; @@ -69,6 +85,11 @@ export default async function (ctx: FunctionContext) { endAt: state.endAt, claimedCount, todayClaimed, + todayClaim: todayClaim ? { + claimId: todayClaim.claimId, + rewardDay: Number(todayClaim.rewardDay), + rewards: todayClaim.rewards, + } : null, canClaim: status === "active" && !todayClaimed, nextRewardDay: status === "active" && !todayClaimed ? claimedCount + 1 : null, rewards: config.rewards.map(reward => ({ diff --git a/laf-cloud/signInActivity.API.md b/laf-cloud/signInActivity.API.md index 184a091..20e8575 100644 --- a/laf-cloud/signInActivity.API.md +++ b/laf-cloud/signInActivity.API.md @@ -18,10 +18,11 @@ 1. 玩家进入主界面后调用 `signInActivityInfo`。 2. 如果返回 `data: false`,表示玩家尚未达到目标关卡,不展示活动。 -3. 如果返回活动对象,按照 `status`、`canClaim`、`todayClaimed` 和 `rewards` 渲染活动。 -4. 玩家点击领取时调用 `signInClaim`。 -5. 领取成功后,前端先检查本地是否处理过 `claimId`,未处理过才发放 `rewards`。 -6. 发奖完成后保存 `claimId`,并重新请求 `signInActivityInfo` 刷新页面状态。 +3. 符合领取条件时,服务端异步执行并等待领取落库,返回领取后的活动状态。 +4. 如果 `data.todayClaim` 非空,前端检查其 `claimId` 是否已处理,未处理才发放 `todayClaim.rewards` 并保存处理标记。 +5. 按照 `status`、`canClaim`、`todayClaimed` 和 `rewards` 渲染活动。自动领取成功后 `canClaim: false`,不再需要调用 `signInClaim`。 + +当天重复请求会返回同一个 `todayClaim`,用于首次响应丢失后的重试;它不表示本次新增领取。必须按 `claimId` 去重,不能每次收到就发奖。此接口具有写入行为,不应被预加载或轮询当作纯查询使用。`todayClaim` 只返回当天记录,不包含跨日未处理奖励的补领机制。 活动只在 `users.levelAmount` 达到目标关卡且没有活动状态的用户首次调用 `signInActivityInfo` 时创建。`login` 和 `setUserLevel` 均不读取签到配置、不判断签到资格,也不创建签到活动。 @@ -61,6 +62,8 @@ interface SignInActivityInfoRequest { 用户没有活动状态时,本次请求会创建活动;已有活动状态时不会重置开始和结束时间。 +活动进行中且当天未领取时,本次请求会自动领取下一档奖励。当天已领、活动完成或过期时不新增领取记录。响应中的活动字段均为自动领取后的状态;数据库写入失败时返回失败或抛出服务端错误,不返回虚假的领取成功。 + ```json { "code": 1, @@ -72,17 +75,22 @@ interface SignInActivityInfoRequest { "triggerReached": true, "startAt": 1788278400000, "endAt": 1789488000000, - "claimedCount": 0, - "todayClaimed": false, - "canClaim": true, - "nextRewardDay": 1, + "claimedCount": 1, + "todayClaimed": true, + "todayClaim": { + "claimId": "71b4...9a2f", + "rewardDay": 1, + "rewards": [{ "type": "infinite_health", "count": 900 }] + }, + "canClaim": false, + "nextRewardDay": null, "rewards": [ { "day": 1, "items": [ { "type": "infinite_health", "count": 900 } ], - "claimed": false + "claimed": true } ] }, @@ -103,6 +111,7 @@ interface SignInActivityInfoRequest { | `endAt` | number | 活动结束时间戳,不包含该时刻 | | `claimedCount` | number | 已领取奖励天数,范围 0~7 | | `todayClaimed` | boolean | 当前 UTC+8 自然日是否已经领取 | +| `todayClaim` | object \| null | 当天领取凭据:`claimId: string`、`rewardDay: number`、`rewards: RewardItem[]`;当天无记录为 `null`,重复请求返回相同凭据 | | `canClaim` | boolean | 当前是否可以领取下一档奖励 | | `nextRewardDay` | number \| null | 下一档奖励序号;当前不可领取时为 `null` | | `rewards` | RewardDay[] | 完整七天奖励及每档领取状态 | @@ -119,6 +128,8 @@ interface SignInActivityInfoRequest { ### `POST /signInClaim` +保留兼容旧客户端,与活动信息接口复用同一领取实现。新流程无需额外调用;活动信息接口自动领取后,当天调用此接口会返回“今天已经领取”。 + 客户端不能指定领取第几天。后端按照当前累计领取次数自动发放下一档配置,并使用用户、活动和自然日生成唯一 `claimId`。 ### 请求参数 diff --git a/laf-cloud/signInActivity.TESTING.md b/laf-cloud/signInActivity.TESTING.md index deae1b5..1498bee 100644 --- a/laf-cloud/signInActivity.TESTING.md +++ b/laf-cloud/signInActivity.TESTING.md @@ -71,6 +71,8 @@ action=prepare&uid=&scenario=<场景>&adminToken= ## 推荐测试顺序 +`signInActivityInfo` 现在会自动领取。以下每次准备 `day_N_ready` 后,直接请求活动信息,检查 `todayClaim.rewardDay`、奖励内容和 `canClaim: false`。当天再次请求应返回相同 `claimId`,前端不得重复发奖;完成和过期场景不得新增记录。检查数据库状态可使用 `signInTestAdmin inspect`,避免活动信息请求触发领取。 + 1. `prepare locked`,验证未达标不展示。 2. `prepare eligible`,验证首次请求创建活动且重复请求不重置时间。 3. 依次使用 `day_1_ready` 至 `day_7_ready`,逐档验证前端奖励。 diff --git a/laf-cloud/tests/sign-in-activity.test.mjs b/laf-cloud/tests/sign-in-activity.test.mjs index 52daaaa..134d4c2 100644 --- a/laf-cloud/tests/sign-in-activity.test.mjs +++ b/laf-cloud/tests/sign-in-activity.test.mjs @@ -81,6 +81,9 @@ registerHooks({ if (specifier === "@/SignInActivity") { return { url: domainUrl.href, shortCircuit: true }; } + if (specifier === "@/signInClaim") { + return { url: claimUrl.href, shortCircuit: true }; + } return nextResolve(specifier, context); }, }); @@ -175,6 +178,11 @@ 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(result.data.todayClaimed, true); + assert.equal(result.data.canClaim, false); + assert.equal(result.data.claimedCount, 1); + assert.equal(result.data.todayClaim.rewardDay, 1); + assert.deepEqual(result.data.todayClaim.rewards, config.rewards[0].items); assert.equal(typeof state.lastUserUpdate.signInActivity, "string"); assert.equal(JSON.parse(state.lastUserUpdate.signInActivity).startAt, getChinaDayStart(now)); }); @@ -189,6 +197,49 @@ test("activity info activates when the persisted activity state is null", async assert.equal(JSON.parse(state.lastUserUpdate.signInActivity).activatedAt, now); }); +test("automatic claims return the same receipt on retry and advance on a later day", async () => { + const now = Date.UTC(2026, 8, 2, 1); + reset({ levelAmount: 10 }); + const request = () => activityInfo({ body: { uid: "u1", token: "secret" } }); + const first = await withNow(now, request); + const retry = await withNow(now + 1000, request); + assert.deepEqual(retry.data.todayClaim, first.data.todayClaim); + assert.equal(retry.data.claimedCount, 1); + assert.equal(retry.data.startAt, first.data.startAt); + const later = await withNow(now + 2 * 86400000, request); + assert.equal(later.data.todayClaim.rewardDay, 2); + assert.notEqual(later.data.todayClaim.claimId, first.data.todayClaim.claimId); + assert.equal(state.claims.length, 2); +}); + +test("concurrent automatic and manual claims share the same daily unique record", async () => { + const now = Date.UTC(2026, 8, 2, 1); + reset({ levelAmount: 10, signInActivity: { + activityId: config.activityId, startAt: getChinaDayStart(now), + endAt: getChinaDayStart(now) + 14 * 86400000, activatedAt: now, + } }); + const results = await withNow(now, () => Promise.all([ + activityInfo({ body: { uid: "u1", token: "secret" } }), + activityInfo({ body: { uid: "u1", token: "secret" } }), + claimReward({ body: { uid: "u1", token: "secret" } }), + ])); + assert.equal(state.claims.length, 1); + for (const result of results.slice(0, 2)) { + assert.equal(result.code, 1); + assert.equal(result.data.canClaim, false); + assert.equal(result.data.todayClaim.claimId, state.claims[0].claimId); + } +}); + +test("invalid configuration cannot create an automatic claim", async () => { + reset({ levelAmount: 10 }); + state.configs = []; + const result = await activityInfo({ body: { uid: "u1", token: "secret" } }); + assert.equal(result.code, 0); + assert.equal(state.user.signInActivity, undefined); + assert.equal(state.claims.length, 0); +}); + test("claims accumulate across non-consecutive days and reject same-day repeats", async () => { const firstDay = Date.UTC(2026, 8, 2, 1); reset({ @@ -347,10 +398,11 @@ test("test admin prepares every reward day for the same account", async () => { }); assert.equal(prepared.code, 1); assert.equal(prepared.data.claimedCount, day - 1); - const claim = await claimReward({ body: { uid: "u1", token: "secret" } }); + const claim = await activityInfo({ body: { uid: "u1", token: "secret" } }); assert.equal(claim.code, 1); - assert.equal(claim.data.rewardDay, day); - assert.equal(claim.data.completed, day === 7); + assert.equal(claim.data.todayClaim.rewardDay, day); + assert.equal(claim.data.canClaim, false); + assert.equal(claim.data.status, day === 7 ? "completed" : "active"); } })); }); @@ -369,8 +421,10 @@ test("test admin prepares claimed, expired, and completed states", async () => { query: {}, }); assert.equal(prepared.code, 1); + const countBeforeInfo = state.claims.length; const info = await activityInfo({ body: { uid: "u1", token: "secret" } }); assert.equal(info.data.status, expected.status); + assert.equal(state.claims.length, countBeforeInfo); const claim = await claimReward({ body: { uid: "u1", token: "secret" } }); assert.equal(claim.code, 0); assert.match(claim.msg, expected.claimMessage);