MatchMaster/server/laf-cloud/functions/SignInActivity.ts

210 lines
6.8 KiB
TypeScript

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 getUserSignInTriggerLevel(user: any, config: SignInActivityConfig): number {
let assignments = user?.abTests;
if (typeof assignments === "string") {
try { assignments = JSON.parse(assignments); } catch { assignments = null; }
}
const assignment = assignments?.layer_1;
const isNewPlayerB = !!assignment && assignment.name === "new1_50" && assignment.enabled !== false
&& ["B", "2"].includes(String(assignment.group).toUpperCase());
return isNewPlayerB ? 15 : Number(config.triggerLevel);
}
export function getUserSignInLevel(user: any) {
const level = Number(user?.levelAmount);
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) < getUserSignInTriggerLevel(user, config)) {
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: JSON.stringify(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: JSON.stringify(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;