七日签到活动需求

This commit is contained in:
huanghaipeng 2026-09-02 15:53:47 +08:00
parent 591318d75c
commit 4b761d27d9
11 changed files with 1061 additions and 2 deletions

View 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;

View File

@ -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]; ];
}

View File

@ -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;

View 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: "成功",
};
}

View File

@ -0,0 +1,7 @@
name: signInActivityInfo
desc: "七日签到活动信息"
methods:
- GET
- POST
tags:
- activity

View 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: "领取成功",
};
}

View File

@ -0,0 +1,6 @@
name: signInClaim
desc: "领取七日签到奖励"
methods:
- POST
tags:
- activity

View 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 }
]
}
]
}

View 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`,不要完全依赖客户端本地时间。

View 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` 实现同用户、同活动、同自然日的领取幂等,无需客户端传入领取天数。

View 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);
});