七日签到活动需求
This commit is contained in:
parent
591318d75c
commit
4b761d27d9
196
laf-cloud/functions/SignInActivity.ts
Normal file
196
laf-cloud/functions/SignInActivity.ts
Normal file
|
|
@ -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<SignInActivityConfig | null> {
|
||||
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<SignInActivityConfig | null> {
|
||||
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;
|
||||
|
|
@ -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]; ];
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
82
laf-cloud/functions/signInActivityInfo.ts
Normal file
82
laf-cloud/functions/signInActivityInfo.ts
Normal file
|
|
@ -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: "成功",
|
||||
};
|
||||
}
|
||||
7
laf-cloud/functions/signInActivityInfo.yaml
Normal file
7
laf-cloud/functions/signInActivityInfo.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
name: signInActivityInfo
|
||||
desc: "七日签到活动信息"
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
tags:
|
||||
- activity
|
||||
95
laf-cloud/functions/signInClaim.ts
Normal file
95
laf-cloud/functions/signInClaim.ts
Normal file
|
|
@ -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: "领取成功",
|
||||
};
|
||||
}
|
||||
6
laf-cloud/functions/signInClaim.yaml
Normal file
6
laf-cloud/functions/signInClaim.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
name: signInClaim
|
||||
desc: "领取七日签到奖励"
|
||||
methods:
|
||||
- POST
|
||||
tags:
|
||||
- activity
|
||||
55
laf-cloud/sign-in-activity-config.json
Normal file
55
laf-cloud/sign-in-activity-config.json
Normal file
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
297
laf-cloud/signInActivity.API.md
Normal file
297
laf-cloud/signInActivity.API.md
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
# 七日签到后端接口文档
|
||||
|
||||
## 1. 基本约定
|
||||
|
||||
- 基础地址:`https://<APPID>.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`,不要完全依赖客户端本地时间。
|
||||
30
laf-cloud/signInActivity.README.md
Normal file
30
laf-cloud/signInActivity.README.md
Normal file
|
|
@ -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` 实现同用户、同活动、同自然日的领取幂等,无需客户端传入领取天数。
|
||||
285
laf-cloud/tests/sign-in-activity.test.mjs
Normal file
285
laf-cloud/tests/sign-in-activity.test.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user