diff --git a/laf-cloud/functions/SignInActivity.ts b/laf-cloud/functions/SignInActivity.ts new file mode 100644 index 0000000..b813cee --- /dev/null +++ b/laf-cloud/functions/SignInActivity.ts @@ -0,0 +1,196 @@ +import cloud from '@lafjs/cloud' +import { createHash } from 'crypto'; + +const db = cloud.database(); +const CONFIG_COLLECTION = "sign_in_activity_config"; +const CLAIM_COLLECTION = "sign_in_activity_claims"; +const USERS_COLLECTION = "users"; +const DAY_MS = 24 * 60 * 60 * 1000; +const UTC_8_OFFSET_MS = 8 * 60 * 60 * 1000; +const REWARD_DAYS = 7; + +export type SignInRewardItem = { + type: "coin" | "freeze" | "hammer" | "magic_wand" | "infinite_health" | "cat_skin"; + count: number; + itemId?: number; +}; + +export type SignInActivityConfig = { + activityId: string; + enabled: boolean; + triggerLevel: number; + durationDays: number; + timezone?: string; + rewards: Array<{ + day: number; + items: SignInRewardItem[]; + }>; + updatedAt?: number; + createdAt?: number; +}; + +export type SignInActivityState = { + activityId: string; + startAt: number; + endAt: number; + activatedAt: number; + completedAt?: number | null; +}; + +export function getChinaDayStart(now = Date.now()) { + return Math.floor((now + UTC_8_OFFSET_MS) / DAY_MS) * DAY_MS - UTC_8_OFFSET_MS; +} + +export function getChinaDateKey(now = Date.now()) { + return String(Math.floor((now + UTC_8_OFFSET_MS) / DAY_MS)); +} + +export function getUserSignInLevel(user: any) { + const level = Number(user?.endLevelNum); + return Number.isFinite(level) && level > 0 ? level : 0; +} + +export function normalizeSignInState(value: any): SignInActivityState | null { + if (!value) { + return null; + } + if (typeof value === "string") { + try { + value = JSON.parse(value); + } catch { + return null; + } + } + if (!value.activityId || !Number.isFinite(Number(value.startAt)) || !Number.isFinite(Number(value.endAt))) { + return null; + } + return { + activityId: String(value.activityId), + startAt: Number(value.startAt), + endAt: Number(value.endAt), + activatedAt: Number(value.activatedAt) || Number(value.startAt), + completedAt: Number(value.completedAt) || null, + }; +} + +export function isValidSignInConfig(config: any): config is SignInActivityConfig { + if (!config?.activityId) { + return false; + } + if (!Number.isFinite(Number(config.triggerLevel)) || Number(config.triggerLevel) < 0) { + return false; + } + if (!Number.isInteger(Number(config.durationDays)) || Number(config.durationDays) <= 0) { + return false; + } + if (!Array.isArray(config.rewards) || config.rewards.length !== REWARD_DAYS) { + return false; + } + return config.rewards.every((reward, index) => + Number(reward?.day) === index + 1 + && Array.isArray(reward.items) + && reward.items.length > 0 + && reward.items.every(isValidRewardItem) + ); +} + +function isValidRewardItem(item: any) { + const validTypes = ["coin", "freeze", "hammer", "magic_wand", "infinite_health", "cat_skin"]; + const validCatId = item?.type !== "cat_skin" + || (Number.isInteger(Number(item.itemId)) && Number(item.itemId) > 0); + return validTypes.includes(item?.type) + && Number.isFinite(Number(item.count)) + && Number(item.count) > 0 + && validCatId + && (item.itemId === undefined || Number.isFinite(Number(item.itemId))); +} + +export async function getActiveSignInConfig(): Promise { + try { + const result = await db.collection(CONFIG_COLLECTION).where({ enabled: true }).get(); + const configs = Array.isArray(result.data) ? result.data.filter(isValidSignInConfig) : []; + if (configs.length !== 1) { + return null; + } + configs.sort((left, right) => Number(right.updatedAt || right.createdAt || 0) - Number(left.updatedAt || left.createdAt || 0)); + return configs[0]; + } catch { + return null; + } +} + +export async function getSignInConfig(activityId: string): Promise { + try { + const result = await db.collection(CONFIG_COLLECTION).where({ activityId }).getOne(); + return isValidSignInConfig(result.data) ? result.data : null; + } catch { + return null; + } +} + +export async function activateSignInActivity(user: any, now = Date.now()) { + if (!user?._id || normalizeSignInState(user.signInActivity)) { + return { activated: false, state: normalizeSignInState(user?.signInActivity), config: null }; + } + const config = await getActiveSignInConfig(); + if (!config || getUserSignInLevel(user) < Number(config.triggerLevel)) { + return { activated: false, state: null, config }; + } + const startAt = getChinaDayStart(now); + const state: SignInActivityState = { + activityId: config.activityId, + startAt, + endAt: startAt + Number(config.durationDays) * DAY_MS, + activatedAt: now, + completedAt: null, + }; + const result = await db.collection(USERS_COLLECTION) + .where({ _id: user._id }) + .update({ signInActivity: state }); + const activated = getUpdatedCount(result) > 0; + if (activated) { + user.signInActivity = state; + } + return { activated, state: activated ? state : normalizeSignInState(user.signInActivity), config }; +} + +function getUpdatedCount(result: any) { + const count = Number(result?.updated ?? result?.matched ?? result?.data?.updated); + return Number.isFinite(count) ? count : 1; +} + +export async function getSignInClaims(uid: string, activityId: string) { + const result = await db.collection(CLAIM_COLLECTION).where({ uid, activityId }).get(); + const claims = Array.isArray(result.data) ? result.data.slice() : []; + claims.sort((left, right) => Number(left.rewardDay) - Number(right.rewardDay)); + return claims; +} + +export function buildClaimId(uid: string, activityId: string, dateKey: string) { + return createHash("sha256").update(`${uid}:${activityId}:${dateKey}`).digest("hex"); +} + +export async function addSignInClaim(payload: any) { + return db.collection(CLAIM_COLLECTION).add(payload); +} + +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 }); + return nextState; +} + +export function getSignInActivityStatus(state: SignInActivityState | null, claimedCount: number, now = Date.now()) { + if (!state) { + return "locked" as const; + } + if (claimedCount >= REWARD_DAYS || state.completedAt) { + return "completed" as const; + } + if (now >= state.endAt) { + return "expired" as const; + } + return "active" as const; +} + +export const SIGN_IN_REWARD_DAYS = REWARD_DAYS; diff --git a/laf-cloud/functions/drawHead.ts b/laf-cloud/functions/drawHead.ts index 0a8943c..2f77f57 100644 --- a/laf-cloud/functions/drawHead.ts +++ b/laf-cloud/functions/drawHead.ts @@ -32,7 +32,7 @@ export default async function (ctx: FunctionContext) { let headArr = res.data.headArr; if (!headArr) { - headArr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17] ]; + headArr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]; ]; } diff --git a/laf-cloud/functions/setUserLevel.ts b/laf-cloud/functions/setUserLevel.ts index 8a46676..760cf82 100644 --- a/laf-cloud/functions/setUserLevel.ts +++ b/laf-cloud/functions/setUserLevel.ts @@ -1,6 +1,7 @@ import cloud from '@lafjs/cloud' const db = cloud.database(); import Utils from "@/Utils"; +import { activateSignInActivity } from "@/SignInActivity"; export default async function (ctx: FunctionContext) { const action = ctx.body.action; const uid = ctx.body.uid; @@ -46,7 +47,12 @@ export default async function (ctx: FunctionContext) { } let totalLevel = userLevel + (endLevelNum / 10000) await db.collection(dbname).where({ _id: uid }).update({ userLevel: userLevel, endLevelNum: endLevelNum, totalLevel: totalLevel }); - return { code: 1, data: { userLevel: userLevel, endLevelNum: endLevelNum }, msg: "用户数据更新成功" } + let signInActivityActivated = false; + if (dbname === "users") { + const activation = await activateSignInActivity({ ...res.data, userLevel, endLevelNum, totalLevel }); + signInActivityActivated = activation.activated; + } + return { code: 1, data: { userLevel: userLevel, endLevelNum: endLevelNum, signInActivityActivated }, msg: "用户数据更新成功" } case 'read': let userLevel1 = res.data?.userLevel; //let endLevelNum1=res.data?.endLevelNum; diff --git a/laf-cloud/functions/signInActivityInfo.ts b/laf-cloud/functions/signInActivityInfo.ts new file mode 100644 index 0000000..b35bef4 --- /dev/null +++ b/laf-cloud/functions/signInActivityInfo.ts @@ -0,0 +1,82 @@ +import cloud from '@lafjs/cloud' +import Utils from "@/Utils"; +import { + activateSignInActivity, + getActiveSignInConfig, + getChinaDateKey, + getSignInActivityStatus, + getSignInClaims, + getSignInConfig, + getUserSignInLevel, + normalizeSignInState, +} from "@/SignInActivity"; + +const db = cloud.database(); + +export default async function (ctx: FunctionContext) { + const uid = ctx.body?.uid; + if (!uid) { + return { code: 0, data: null, msg: "未获取到uid" }; + } + if (ctx.body?.gameName === "iaa") { + return { code: 0, data: null, msg: "七日签到仅支持主游戏" }; + } + const userResult = await db.collection("users").where({ _id: uid }).getOne(); + const user = userResult.data; + if (!user) { + return { code: 0, data: null, msg: "未获取到用户信息" }; + } + if (user.token && !Utils.checkToken(ctx.body?.token, user.token)) { + return { code: 0, data: null, msg: "token校验失败" }; + } + + const now = Date.now(); + let state = normalizeSignInState(user.signInActivity); + let config = state + ? await getSignInConfig(state.activityId) + : await getActiveSignInConfig(); + if (!config) { + return { code: 0, data: null, msg: "签到活动配置不存在或配置无效" }; + } + const triggerReached = getUserSignInLevel(user) >= Number(config.triggerLevel); + if (!state && !triggerReached) { + return { code: 1, data: false, msg: "未达到签到活动开启关卡" }; + } + if (!state) { + const activation = await activateSignInActivity(user, now); + state = activation.state; + config = activation.config || config; + if (!state) { + return { code: 0, data: null, msg: "签到活动开启失败" }; + } + } + + const claims = await getSignInClaims(uid, state.activityId); + const claimedDays = new Set(claims.map(claim => Number(claim.rewardDay))); + const todayClaimed = claims.some(claim => String(claim.dateKey) === getChinaDateKey(now)); + const claimedCount = claims.length; + const status = getSignInActivityStatus(state, claimedCount, now); + + return { + code: 1, + data: { + serverNow: now, + activityId: config.activityId, + status, + triggerLevel: Number(config.triggerLevel), + triggerReached, + startAt: state.startAt, + endAt: state.endAt, + claimedCount, + todayClaimed, + canClaim: status === "active" && !todayClaimed, + nextRewardDay: status === "active" && !todayClaimed ? claimedCount + 1 : null, + rewards: config.rewards.map(reward => ({ + day: Number(reward.day), + items: reward.items, + claimed: claimedDays.has(Number(reward.day)), + })), + }, + msg: "成功", + }; +} diff --git a/laf-cloud/functions/signInActivityInfo.yaml b/laf-cloud/functions/signInActivityInfo.yaml new file mode 100644 index 0000000..f13500e --- /dev/null +++ b/laf-cloud/functions/signInActivityInfo.yaml @@ -0,0 +1,7 @@ +name: signInActivityInfo +desc: "七日签到活动信息" +methods: + - GET + - POST +tags: + - activity diff --git a/laf-cloud/functions/signInClaim.ts b/laf-cloud/functions/signInClaim.ts new file mode 100644 index 0000000..58ebf6b --- /dev/null +++ b/laf-cloud/functions/signInClaim.ts @@ -0,0 +1,95 @@ +import cloud from '@lafjs/cloud' +import Utils from "@/Utils"; +import { + SIGN_IN_REWARD_DAYS, + addSignInClaim, + buildClaimId, + getChinaDateKey, + getSignInActivityStatus, + getSignInClaims, + getSignInConfig, + markSignInCompleted, + normalizeSignInState, +} from "@/SignInActivity"; + +const db = cloud.database(); + +export default async function (ctx: FunctionContext) { + const uid = ctx.body?.uid; + if (!uid) { + return { code: 0, data: null, msg: "未获取到uid" }; + } + if (ctx.body?.gameName === "iaa") { + return { code: 0, data: null, msg: "七日签到仅支持主游戏" }; + } + const userResult = await db.collection("users").where({ _id: uid }).getOne(); + const user = userResult.data; + if (!user) { + return { code: 0, data: null, msg: "未获取到用户信息" }; + } + if (user.token && !Utils.checkToken(ctx.body?.token, user.token)) { + return { code: 0, data: null, msg: "token校验失败" }; + } + + const state = normalizeSignInState(user.signInActivity); + if (!state) { + return { code: 0, data: null, msg: "签到活动尚未开启" }; + } + const config = await getSignInConfig(state.activityId); + if (!config) { + return { code: 0, data: null, msg: "签到活动配置不存在或配置无效" }; + } + const now = Date.now(); + let claims = await getSignInClaims(uid, state.activityId); + const status = getSignInActivityStatus(state, claims.length, now); + if (status === "completed") { + return { code: 0, data: null, msg: "七日签到奖励已全部领取" }; + } + if (status === "expired") { + return { code: 0, data: null, msg: "七日签到活动已结束" }; + } + + const dateKey = getChinaDateKey(now); + if (claims.some(claim => String(claim.dateKey) === dateKey)) { + return { code: 0, data: null, msg: "今天已经领取" }; + } + const rewardDay = claims.length + 1; + const reward = config.rewards[rewardDay - 1]; + if (!reward || rewardDay > SIGN_IN_REWARD_DAYS) { + return { code: 0, data: null, msg: "签到奖励配置异常" }; + } + const claimId = buildClaimId(uid, state.activityId, dateKey); + const payload = { + _id: claimId, + claimId, + uid, + activityId: state.activityId, + dateKey, + rewardDay, + rewards: reward.items, + claimedAt: now, + }; + try { + await addSignInClaim(payload); + } catch { + return { code: 0, data: null, msg: "今天已经领取" }; + } + + claims = claims.concat(payload); + const completed = claims.length >= SIGN_IN_REWARD_DAYS; + if (completed) { + await markSignInCompleted(uid, state, now); + } + return { + code: 1, + data: { + claimId, + rewardDay, + rewards: reward.items, + claimedCount: claims.length, + todayClaimed: true, + completed, + }, + msg: "领取成功", + }; +} diff --git a/laf-cloud/functions/signInClaim.yaml b/laf-cloud/functions/signInClaim.yaml new file mode 100644 index 0000000..f6b498a --- /dev/null +++ b/laf-cloud/functions/signInClaim.yaml @@ -0,0 +1,6 @@ +name: signInClaim +desc: "领取七日签到奖励" +methods: + - POST +tags: + - activity diff --git a/laf-cloud/sign-in-activity-config.json b/laf-cloud/sign-in-activity-config.json new file mode 100644 index 0000000..08d7962 --- /dev/null +++ b/laf-cloud/sign-in-activity-config.json @@ -0,0 +1,55 @@ +{ + "activityId": "seven_day_sign_in_v1", + "enabled": true, + "triggerLevel": 23, + "durationDays": 14, + "timezone": "UTC+8", + "rewards": [ + { + "day": 1, + "items": [ + { "type": "infinite_health", "count": 900 } + ] + }, + { + "day": 2, + "items": [ + { "type": "hammer", "count": 1 } + ] + }, + { + "day": 3, + "items": [ + { "type": "infinite_health", "count": 1800 } + ] + }, + { + "day": 4, + "items": [ + { "type": "freeze", "count": 1 }, + { "type": "magic_wand", "count": 1 } + ] + }, + { + "day": 5, + "items": [ + { "type": "coin", "count": 600 } + ] + }, + { + "day": 6, + "items": [ + { "type": "hammer", "count": 1 }, + { "type": "freeze", "count": 1 }, + { "type": "magic_wand", "count": 1 } + ] + }, + { + "day": 7, + "items": [ + { "type": "infinite_health", "count": 3600 }, + { "type": "cat_skin", "count": 1, "itemId": 12 } + ] + } + ] +} diff --git a/laf-cloud/signInActivity.API.md b/laf-cloud/signInActivity.API.md new file mode 100644 index 0000000..72bcb3f --- /dev/null +++ b/laf-cloud/signInActivity.API.md @@ -0,0 +1,297 @@ +# 七日签到后端接口文档 + +## 1. 基本约定 + +- 基础地址:`https://.laf.run` +- 数据格式:`application/json` +- 时区:UTC+8 +- 活动周期:触发当天为第 1 个自然日,第 15 天 00:00 结束 +- 签到规则:14 个自然日内累计领取 7 次,允许断签,每个自然日最多领取一次 +- 适用用户库:仅主游戏 `users`;`gameName: "iaa"` 不支持签到活动 +- 身份校验:请求携带登录接口返回的 `uid` 和 `token` +- 通用成功码:`code: 1` +- 通用失败码:`code: 0` + +签到奖励由后端校验并记录,实际金币、道具、无限体力和猫皮肤仍由前端根据领取接口返回的 `rewards` 发放。前端必须使用 `claimId` 做发奖幂等。 + +## 2. 推荐调用流程 + +1. 玩家进入主界面后调用 `signInActivityInfo`。 +2. 如果返回 `data: false`,表示玩家尚未达到目标关卡,不展示活动。 +3. 如果返回活动对象,按照 `status`、`canClaim`、`todayClaimed` 和 `rewards` 渲染活动。 +4. 玩家点击领取时调用 `signInClaim`。 +5. 领取成功后,前端先检查本地是否处理过 `claimId`,未处理过才发放 `rewards`。 +6. 发奖完成后保存 `claimId`,并重新请求 `signInActivityInfo` 刷新页面状态。 + +活动开启入口: + +- 主游戏通关上报 `setUserLevel(action: "save")` 达到目标关卡时,后端立即创建活动,并返回 `signInActivityActivated: true`。 +- 已经达到目标关卡但没有活动状态的用户,首次调用 `signInActivityInfo` 时创建活动。 +- `login` 不读取签到配置、不判断资格,也不创建签到活动。 + +## 3. 查询签到活动信息 + +### `POST /signInActivityInfo` + +云函数同时配置了 GET 和 POST;客户端统一使用 POST。 + +### 请求参数 + +```ts +interface SignInActivityInfoRequest { + uid: string; + token: string; + gameName?: string; +} +``` + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `uid` | string | 是 | 用户 `_id` | +| `token` | string | 是 | 登录令牌 | +| `gameName` | string | 否 | 不传表示主游戏;传 `iaa` 会被拒绝 | + +### 未达到目标关卡 + +```json +{ + "code": 1, + "data": false, + "msg": "未达到签到活动开启关卡" +} +``` + +### 达到目标关卡 + +用户没有活动状态时,本次请求会创建活动;已有活动状态时不会重置开始和结束时间。 + +```json +{ + "code": 1, + "data": { + "serverNow": 1788310800000, + "activityId": "seven_day_sign_in_v1", + "status": "active", + "triggerLevel": 23, + "triggerReached": true, + "startAt": 1788278400000, + "endAt": 1789488000000, + "claimedCount": 0, + "todayClaimed": false, + "canClaim": true, + "nextRewardDay": 1, + "rewards": [ + { + "day": 1, + "items": [ + { "type": "infinite_health", "count": 900 } + ], + "claimed": false + } + ] + }, + "msg": "成功" +} +``` + +### 返回字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `serverNow` | number | 服务器当前毫秒时间戳 | +| `activityId` | string | 用户参与的活动配置 ID | +| `status` | string | `active`、`completed` 或 `expired` | +| `triggerLevel` | number | 活动开启目标关卡,当前配置为 23 | +| `triggerReached` | boolean | 服务端确认的关卡是否达标 | +| `startAt` | number | 活动第 1 天 UTC+8 零点时间戳 | +| `endAt` | number | 活动结束时间戳,不包含该时刻 | +| `claimedCount` | number | 已领取奖励天数,范围 0~7 | +| `todayClaimed` | boolean | 当前 UTC+8 自然日是否已经领取 | +| `canClaim` | boolean | 当前是否可以领取下一档奖励 | +| `nextRewardDay` | number \| null | 下一档奖励序号;当前不可领取时为 `null` | +| `rewards` | RewardDay[] | 完整七天奖励及每档领取状态 | + +状态说明: + +| 状态 | 说明 | +| --- | --- | +| `active` | 活动期内且尚未领取满 7 天 | +| `completed` | 已领取全部 7 天奖励 | +| `expired` | 已到第 15 天 00:00 且未领取满 7 天 | + +## 4. 领取签到奖励 + +### `POST /signInClaim` + +客户端不能指定领取第几天。后端按照当前累计领取次数自动发放下一档配置,并使用用户、活动和自然日生成唯一 `claimId`。 + +### 请求参数 + +```ts +interface SignInClaimRequest { + uid: string; + token: string; + gameName?: string; +} +``` + +### 成功响应 + +```json +{ + "code": 1, + "data": { + "claimId": "71b4...9a2f", + "rewardDay": 1, + "rewards": [ + { "type": "infinite_health", "count": 900 } + ], + "claimedCount": 1, + "todayClaimed": true, + "completed": false + }, + "msg": "领取成功" +} +``` + +### 返回字段 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `claimId` | string | 本次领取唯一标识,前端发奖幂等键 | +| `rewardDay` | number | 本次领取的奖励序号,范围 1~7 | +| `rewards` | RewardItem[] | 本次需要由前端发放的奖励 | +| `claimedCount` | number | 领取成功后的累计领取天数 | +| `todayClaimed` | boolean | 成功时固定为 `true` | +| `completed` | boolean | 本次领取后是否已经领满 7 天 | + +## 5. 通关上报接口变动 + +### `POST /setUserLevel` + +原有请求参数不变。仅 `action: "save"` 且成功保存主游戏关卡时,响应 `data` 新增 `signInActivityActivated`。 + +```json +{ + "code": 1, + "data": { + "userLevel": 1, + "endLevelNum": 23, + "signInActivityActivated": true + }, + "msg": "用户数据更新成功" +} +``` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `signInActivityActivated` | boolean | 本次上报是否首次创建了签到活动;前端可据此立即弹出活动 | + +注意: + +- 资格使用服务端保存后的 `endLevelNum` 判断,当前门槛为 23。 +- 只有 `users` 会执行签到活动开启逻辑;`usersAd` 返回 `false`。 +- 活动已经存在时返回 `false`,不会重置活动时间。 +- `action: "read"` 的响应没有新增该字段。 +- 当上报关卡低于服务端已有进度并走原有提前返回逻辑时,响应中也没有该字段。 + +## 6. 奖励数据结构 + +```ts +interface RewardDay { + day: number; + items: RewardItem[]; + claimed: boolean; +} + +interface RewardItem { + type: "coin" | "freeze" | "hammer" | "magic_wand" | "infinite_health" | "cat_skin"; + count: number; + itemId?: number; +} +``` + +| `type` | 含义 | `count` 单位 | 额外字段 | +| --- | --- | --- | --- | +| `coin` | 金币 | 个 | 无 | +| `hammer` | 锤子 | 个 | 无 | +| `freeze` | 冻结道具 | 个 | 无 | +| `magic_wand` | 魔法棒 | 个 | 无 | +| `infinite_health` | 无限体力 | 秒 | 无 | +| `cat_skin` | 猫皮肤 | 个 | 必须提供正整数 `itemId` | + +当前七天配置: + +| Day | 奖励数据 | 展示含义 | +| --- | --- | --- | +| 1 | `infinite_health: 900` | 无限体力 15 分钟 | +| 2 | `hammer: 1` | 锤子 ×1 | +| 3 | `infinite_health: 1800` | 无限体力 30 分钟 | +| 4 | `freeze: 1`、`magic_wand: 1` | 冻结 ×1、魔法棒 ×1 | +| 5 | `coin: 600` | 金币 ×600 | +| 6 | `hammer: 1`、`freeze: 1`、`magic_wand: 1` | 三种道具各 ×1 | +| 7 | `infinite_health: 3600`、`cat_skin: 1, itemId: 12` | 无限体力 1 小时、专属猫 12 | + +## 7. 错误响应 + +两个签到接口共用的错误: + +| `code` | `data` | `msg` | 触发条件 | +| --- | --- | --- | --- | +| 0 | null | `未获取到uid` | 未传 `uid` | +| 0 | null | `七日签到仅支持主游戏` | `gameName` 为 `iaa` | +| 0 | null | `未获取到用户信息` | 用户不存在 | +| 0 | null | `token校验失败` | token 不匹配 | +| 0 | null | `签到活动配置不存在或配置无效` | 找不到配置、配置非法或同时启用多条配置 | + +`signInActivityInfo` 特有错误: + +| `code` | `data` | `msg` | +| --- | --- | --- | +| 0 | null | `签到活动开启失败` | + +`signInClaim` 特有错误: + +| `code` | `data` | `msg` | +| --- | --- | --- | +| 0 | null | `签到活动尚未开启` | +| 0 | null | `七日签到奖励已全部领取` | +| 0 | null | `七日签到活动已结束` | +| 0 | null | `今天已经领取` | +| 0 | null | `签到奖励配置异常` | + +## 8. 数据库配置 + +配置集合:`sign_in_activity_config` + +- 必须且只能存在一条 `enabled: true` 的有效配置。 +- 当前可导入配置文件:`laf-cloud/sign-in-activity-config.json`。 +- 用户开始活动后通过 `activityId` 继续读取配置,因此旧配置可以禁用,但在参与用户全部结束前不能删除。 + +用户活动状态写入 `users.signInActivity`: + +```ts +interface SignInActivityState { + activityId: string; + startAt: number; + endAt: number; + activatedAt: number; + completedAt: number | null; +} +``` + +领取记录集合:`sign_in_activity_claims` + +- 每次领取保存奖励序号、奖励快照和领取时间。 +- 记录 `_id` 与 `claimId` 相同,由 `uid + activityId + UTC+8自然日` 计算 SHA-256。 +- 同一用户、同一活动、同一自然日的并发请求只能成功写入一条记录。 + +## 9. 前端接入注意事项 + +- 不要根据客户端本地关卡自行判定资格,以 `signInActivityInfo` 返回值为准。 +- `data: false` 是正常成功响应,不应作为网络或服务器错误处理。 +- 领取成功不代表后端已经增加资产;前端负责发奖。 +- 前端必须保存已处理的 `claimId`,避免重复回调造成重复发奖。 +- `infinite_health.count` 单位是秒。 +- Day 7 的 `cat_skin.itemId` 当前为 12,客户端必须先支持猫 12 的资源、展示和幂等写入 `catArr`。 +- 倒计时使用 `serverNow` 和 `endAt`,不要完全依赖客户端本地时间。 diff --git a/laf-cloud/signInActivity.README.md b/laf-cloud/signInActivity.README.md new file mode 100644 index 0000000..7c3ceb0 --- /dev/null +++ b/laf-cloud/signInActivity.README.md @@ -0,0 +1,30 @@ +# 七日签到数据库配置 + +部署云函数前,需要在 `sign_in_activity_config` 集合中创建且只创建一条 `enabled: true` 的有效配置。 +活动中的用户通过 `activityId` 读取配置,因此旧配置不能在仍有用户参与时删除。 +`login` 不判断或开启签到活动;前端调用 `signInActivityInfo` 时,未达到目标关卡返回 `data: false`,达到目标关卡则创建活动并返回完整奖励与领取状态。 + +```js +{ + activityId: "sign_in_v1", + enabled: true, + triggerLevel: 23, + durationDays: 14, + timezone: "UTC+8", + rewards: [ + { day: 1, items: [{ type: "infinite_health", count: 900 }] }, + { day: 2, items: [{ type: "hammer", count: 1 }] }, + { day: 3, items: [{ type: "infinite_health", count: 1800 }] }, + { day: 4, items: [{ type: "freeze", count: 1 }, { type: "magic_wand", count: 1 }] }, + { day: 5, items: [{ type: "coin", count: 600 }] }, + { day: 6, items: [{ type: "hammer", count: 1 }, { type: "freeze", count: 1 }, { type: "magic_wand", count: 1 }] }, + { day: 7, items: [{ type: "infinite_health", count: 3600 }, { type: "cat_skin", count: 1, itemId: 12 }] } + ], + createdAt: Date.now(), + updatedAt: Date.now() +} +``` + +奖励 `type` 只允许:`coin`、`freeze`、`hammer`、`magic_wand`、`infinite_health`、`cat_skin`;`count` 必须大于 0。`cat_skin` 必须提供大于 0 的整数 `itemId`。 +本期可直接导入的完整配置见 `sign-in-activity-config.json`;其中专属猫暂定为 `itemId: 12`,启用前必须确认客户端资源和发奖逻辑均已支持猫 12。 +`sign_in_activity_claims` 使用后端生成的 SHA-256 `_id` 实现同用户、同活动、同自然日的领取幂等,无需客户端传入领取天数。 diff --git a/laf-cloud/tests/sign-in-activity.test.mjs b/laf-cloud/tests/sign-in-activity.test.mjs new file mode 100644 index 0000000..c7954bb --- /dev/null +++ b/laf-cloud/tests/sign-in-activity.test.mjs @@ -0,0 +1,285 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +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 levelUrl = new URL("../functions/setUserLevel.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 state = { + user: null, + configs: [], + claims: [], +}; + +globalThis.__signInCloudMock = { + database() { + return { + collection(name) { + return { + async add(payload) { + if (name !== "sign_in_activity_claims") throw new Error("unexpected add"); + if (state.claims.some(item => item._id === payload._id)) throw new Error("duplicate key"); + state.claims.push(structuredClone(payload)); + return { ok: true }; + }, + where(query) { + return { + async get() { + return { data: getCollection(name).filter(item => matches(item, query)) }; + }, + async getOne() { + return { data: getCollection(name).find(item => matches(item, query)) || null }; + }, + async update(payload) { + const targets = getCollection(name).filter(item => matches(item, query)); + targets.forEach(item => Object.assign(item, structuredClone(payload))); + return { updated: targets.length }; + }, + }; + }, + }; + }, + }; + }, +}; + +globalThis.__signInUtilsMock = { + checkToken(received, expected) { + return received === expected; + }, +}; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@lafjs/cloud") { + return { url: "data:text/javascript,export default globalThis.__signInCloudMock", shortCircuit: true }; + } + if (specifier === "@/Utils") { + return { url: "data:text/javascript,export default globalThis.__signInUtilsMock", shortCircuit: true }; + } + if (specifier === "@/SignInActivity") { + return { url: domainUrl.href, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, +}); + +const { default: activityInfo } = await import(infoUrl); +const { default: claimReward } = await import(claimUrl); +const { default: setUserLevel } = await import(levelUrl); +const { getChinaDayStart, isValidSignInConfig } = await import(domainUrl); + +const config = { + activityId: "sign_in_v1", + enabled: true, + triggerLevel: 10, + durationDays: 14, + timezone: "UTC+8", + rewards: Array.from({ length: 7 }, (_, index) => ({ + day: index + 1, + items: [{ type: "coin", count: (index + 1) * 100 }], + })), + updatedAt: 1, +}; + +function reset(userOverrides = {}) { + state.user = { + _id: "u1", + token: "secret", + endLevelNum: 0, + userLevel: 1, + ...userOverrides, + }; + state.configs = [structuredClone(config)]; + state.claims = []; +} + +function getCollection(name) { + if (name === "users") return state.user ? [state.user] : []; + if (name === "sign_in_activity_config") return state.configs; + if (name === "sign_in_activity_claims") return state.claims; + if (name === "usersAd") return []; + return []; +} + +function matches(item, query) { + return Object.entries(query || {}).every(([key, expected]) => item?.[key] === expected); +} + +function withNow(now, callback) { + const original = Date.now; + Date.now = () => now; + return Promise.resolve(callback()).finally(() => { Date.now = original; }); +} + +test("cloud functions and yaml files exist", () => { + assert.equal(existsSync(infoUrl), true); + assert.equal(existsSync(claimUrl), 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); +}); + +test("production configuration contains the approved level and rewards", async () => { + const productionConfig = JSON.parse(await readFile(productionConfigUrl, "utf8")); + assert.equal(isValidSignInConfig(productionConfig), true); + assert.equal(productionConfig.triggerLevel, 23); + assert.deepEqual(productionConfig.rewards, [ + { day: 1, items: [{ type: "infinite_health", count: 900 }] }, + { day: 2, items: [{ type: "hammer", count: 1 }] }, + { day: 3, items: [{ type: "infinite_health", count: 1800 }] }, + { day: 4, items: [{ type: "freeze", count: 1 }, { type: "magic_wand", count: 1 }] }, + { day: 5, items: [{ type: "coin", count: 600 }] }, + { day: 6, items: [{ type: "hammer", count: 1 }, { type: "freeze", count: 1 }, { type: "magic_wand", count: 1 }] }, + { day: 7, items: [{ type: "infinite_health", count: 3600 }, { type: "cat_skin", count: 1, itemId: 12 }] }, + ]); +}); + +test("activity info returns false before the trigger level", async () => { + reset({ endLevelNum: 9 }); + const result = await activityInfo({ body: { uid: "u1", token: "secret" } }); + assert.equal(result.code, 1); + assert.equal(result.data, false); + assert.equal(state.user.signInActivity, undefined); +}); + +test("activity info activates an eligible player and returns rewards", async () => { + const now = Date.UTC(2026, 8, 2, 1); + reset({ endLevelNum: 10 }); + const result = await withNow(now, () => activityInfo({ body: { uid: "u1", token: "secret" } })); + assert.equal(result.code, 1); + assert.equal(result.data.status, "active"); + 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)); +}); + +test("reaching the target level activates a fourteen-day activity", async () => { + const now = Date.UTC(2026, 8, 2, 15, 59, 59); + reset({ endLevelNum: 9 }); + const result = await withNow(now, () => setUserLevel({ + body: { uid: "u1", token: "secret", action: "save", userLevel: 1, endLevelNum: 10 }, + })); + assert.equal(result.code, 1); + assert.equal(result.data.signInActivityActivated, true); + assert.equal(state.user.signInActivity.startAt, getChinaDayStart(now)); + assert.equal(state.user.signInActivity.endAt, getChinaDayStart(now) + 14 * 24 * 60 * 60 * 1000); + + const repeated = await withNow(now + 1000, () => setUserLevel({ + body: { uid: "u1", token: "secret", action: "save", userLevel: 1, endLevelNum: 11 }, + })); + assert.equal(repeated.data.signInActivityActivated, false); + assert.equal(state.user.signInActivity.startAt, getChinaDayStart(now)); +}); + +test("claims accumulate across non-consecutive days and reject same-day repeats", async () => { + const firstDay = Date.UTC(2026, 8, 2, 1); + reset({ + endLevelNum: 10, + signInActivity: { + activityId: config.activityId, + startAt: getChinaDayStart(firstDay), + endAt: getChinaDayStart(firstDay) + 14 * 24 * 60 * 60 * 1000, + activatedAt: firstDay, + completedAt: null, + }, + }); + const first = await withNow(firstDay, () => claimReward({ body: { uid: "u1", token: "secret" } })); + assert.equal(first.code, 1); + assert.equal(first.data.rewardDay, 1); + + const duplicate = await withNow(firstDay + 1000, () => claimReward({ body: { uid: "u1", token: "secret" } })); + assert.equal(duplicate.code, 0); + assert.match(duplicate.msg, /今天已经领取/); + + const thirdDay = await withNow(firstDay + 2 * 24 * 60 * 60 * 1000, () => claimReward({ + body: { uid: "u1", token: "secret" }, + })); + assert.equal(thirdDay.code, 1); + assert.equal(thirdDay.data.rewardDay, 2); + assert.equal(state.claims.length, 2); +}); + +test("concurrent same-day claims create only one claim record", async () => { + const firstDay = Date.UTC(2026, 8, 2, 1); + reset({ + endLevelNum: 10, + signInActivity: { + activityId: config.activityId, + startAt: getChinaDayStart(firstDay), + endAt: getChinaDayStart(firstDay) + 14 * 24 * 60 * 60 * 1000, + activatedAt: firstDay, + completedAt: null, + }, + }); + const results = await withNow(firstDay, () => Promise.all([ + claimReward({ body: { uid: "u1", token: "secret" } }), + claimReward({ body: { uid: "u1", token: "secret" } }), + ])); + assert.deepEqual(results.map(result => result.code).sort(), [0, 1]); + assert.equal(state.claims.length, 1); +}); + +test("the activity expires exactly at UTC+8 midnight after day fourteen", async () => { + const startAt = Date.UTC(2026, 8, 1, 16); + reset({ + endLevelNum: 10, + signInActivity: { + activityId: config.activityId, + startAt, + endAt: startAt + 14 * 24 * 60 * 60 * 1000, + activatedAt: startAt, + completedAt: null, + }, + }); + const beforeEnd = await withNow(state.user.signInActivity.endAt - 1, () => activityInfo({ + body: { uid: "u1", token: "secret" }, + })); + assert.equal(beforeEnd.data.status, "active"); + + const atEnd = await withNow(state.user.signInActivity.endAt, () => activityInfo({ + body: { uid: "u1", token: "secret" }, + })); + assert.equal(atEnd.data.status, "expired"); + assert.equal(atEnd.data.canClaim, false); +}); + +test("seven different days complete the activity", async () => { + const firstDay = Date.UTC(2026, 8, 2, 1); + reset({ + endLevelNum: 10, + signInActivity: { + activityId: config.activityId, + startAt: getChinaDayStart(firstDay), + endAt: getChinaDayStart(firstDay) + 14 * 24 * 60 * 60 * 1000, + activatedAt: firstDay, + completedAt: null, + }, + }); + let result; + for (let day = 0; day < 7; day++) { + result = await withNow(firstDay + day * 24 * 60 * 60 * 1000, () => claimReward({ + body: { uid: "u1", token: "secret" }, + })); + } + 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"); +}); + +test("invalid token and usersAd requests are rejected without writes", async () => { + reset({ endLevelNum: 10 }); + const badToken = await activityInfo({ body: { uid: "u1", token: "wrong" } }); + assert.equal(badToken.code, 0); + + const usersAd = await activityInfo({ body: { uid: "u1", token: "secret", gameName: "iaa" } }); + assert.equal(usersAd.code, 0); + assert.equal(state.claims.length, 0); + assert.equal(state.user.signInActivity, undefined); +});