feat: configure daily check-in rewards

This commit is contained in:
Codex
2026-06-30 14:52:10 +08:00
parent 7d2b8fd610
commit a5a2caa2e9
12 changed files with 610 additions and 94 deletions

View File

@@ -1,3 +1,4 @@
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
@@ -33,6 +34,56 @@ interface ProfileRow {
updatedAt: string;
}
interface CheckInProfileRow {
profileId: string;
lastCheckInDate: string | null;
score: number;
stats: Record<string, unknown>;
}
interface CheckInRewardTaskRow {
id: string;
code: string;
title: string;
rewardPoints: number;
claimLimitPerUser: number;
periodType: string;
metadata: JsonMap;
}
interface CheckInRewardGrant {
taskId: string;
taskCode: string;
title: string;
points: number;
periodKey: string;
ledgerId: string;
claimId: string | null;
rule: JsonMap;
}
interface ScoreLedgerRow {
id: string;
eventType: string;
points: number;
balanceAfter: number;
sourceType: string | null;
sourceId?: string | null;
createdAt: string;
}
interface PointActivityClaimRow {
id: string;
taskId: string;
periodKey: string;
scoreEventId: string | null;
sourceType: string | null;
sourceId: string | null;
status: string;
metadata: JsonMap;
claimedAt: string;
}
function jsonBodyValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' ? value : {});
}
@@ -59,7 +110,7 @@ function optionalChoice(value: unknown, allowed: string[], fallback: string) {
function toDateOnly(value: unknown) {
if (!value) return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
if (value instanceof Date && !Number.isNaN(value.getTime())) return shanghaiDateKey(value);
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10);
return null;
}
@@ -90,6 +141,242 @@ function avatarDisplayUrl(preset: AvatarPreset) {
return `/assets/avatars/default-${preset}.svg`;
}
function shanghaiDateParts(date = new Date()) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value])) as {
year: string;
month: string;
day: string;
};
}
function shanghaiDateKey(date = new Date()) {
const parts = shanghaiDateParts(date);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function shanghaiWeekKey(date = new Date()) {
const current = new Date(`${shanghaiDateKey(date)}T00:00:00.000Z`);
const day = current.getUTCDay() || 7;
current.setUTCDate(current.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(current.getUTCFullYear(), 0, 1));
const week = Math.ceil((((current.getTime() - yearStart.getTime()) / 86_400_000) + 1) / 7);
return `${current.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
}
function checkInTaskPeriodKey(task: CheckInRewardTaskRow, today: string, now = new Date()) {
if (task.periodType === 'daily') return today;
if (task.periodType === 'weekly') return shanghaiWeekKey(now);
if (task.periodType === 'monthly') {
const parts = shanghaiDateParts(now);
return `${parts.year}-${parts.month}`;
}
if (task.periodType === 'once') return `once:${task.id}`;
return `check_in:${today}`;
}
function metadataNumber(metadata: JsonMap, keys: string[]) {
for (const key of keys) {
if (!Object.hasOwn(metadata, key)) continue;
const parsed = Number(metadata[key]);
if (Number.isFinite(parsed)) return Math.trunc(parsed);
}
return null;
}
function checkInRuleFromMetadata(metadata: JsonMap) {
return {
requiredStreak: metadataNumber(metadata, ['requiredStreak', 'required_streak', 'streakDay', 'streak_day']),
minStreak: metadataNumber(metadata, ['minStreak', 'min_streak']),
maxStreak: metadataNumber(metadata, ['maxStreak', 'max_streak']),
streakMultipleOf: metadataNumber(metadata, ['streakMultipleOf', 'streak_multiple_of']),
};
}
function checkInTaskMatches(task: CheckInRewardTaskRow, streak: number) {
const metadata = objectValue(task.metadata);
const rule = checkInRuleFromMetadata(metadata);
if (rule.requiredStreak !== null && streak !== rule.requiredStreak) return false;
if (rule.minStreak !== null && streak < rule.minStreak) return false;
if (rule.maxStreak !== null && streak > rule.maxStreak) return false;
if (rule.streakMultipleOf !== null && (rule.streakMultipleOf <= 0 || streak % rule.streakMultipleOf !== 0)) return false;
return true;
}
async function loadCheckInRewardTasks(client: pg.PoolClient, tenantId: string) {
const result = await client.query<CheckInRewardTaskRow>(
`
select id, code::text, title, reward_points as "rewardPoints",
claim_limit_per_user as "claimLimitPerUser",
period_type as "periodType", metadata
from public.point_activity_tasks
where tenant_id = $1
and task_type = 'daily_check_in'
and status = 'active'
and (valid_from is null or valid_from <= now())
and (valid_to is null or valid_to >= now())
order by sort_order asc, created_at asc
limit 50
for update
`,
[tenantId],
);
return result.rows;
}
async function grantConfiguredCheckInRewards(
client: pg.PoolClient,
input: {
tenantId: string;
userId: string;
profileId: string;
today: string;
now: Date;
streak: number;
scoreBefore: number;
tasks: CheckInRewardTaskRow[];
},
) {
const rewardBreakdown: CheckInRewardGrant[] = [];
const ledgers: ScoreLedgerRow[] = [];
const claims: PointActivityClaimRow[] = [];
let pointsAdded = 0;
let scoreCursor = input.scoreBefore;
for (const task of input.tasks) {
if (!checkInTaskMatches(task, input.streak)) continue;
const periodKey = checkInTaskPeriodKey(task, input.today, input.now);
const claimCounts = await client.query<{ totalCount: string; periodCount: string }>(
`
select count(*)::text as "totalCount",
count(*) filter (where period_key = $4)::text as "periodCount"
from public.user_point_activity_claims
where tenant_id = $1 and user_id = $2 and task_id = $3 and status = 'claimed'
`,
[input.tenantId, input.userId, task.id, periodKey],
);
const periodic = ['daily', 'weekly', 'monthly'].includes(task.periodType);
const currentLimitCount = Number(periodic ? claimCounts.rows[0]?.periodCount || 0 : claimCounts.rows[0]?.totalCount || 0);
const effectiveLimit = periodic ? 1 : Math.max(1, Number(task.claimLimitPerUser || 1));
if (currentLimitCount >= effectiveLimit) continue;
const points = Number(task.rewardPoints || 0);
scoreCursor += points;
const metadata = objectValue(task.metadata);
const rule = checkInRuleFromMetadata(metadata) as JsonMap;
const ledger = await client.query<{
id: string;
eventType: string;
points: number;
balanceAfter: number;
sourceType: string | null;
sourceId: string | null;
createdAt: string;
}>(
`
insert into public.user_score_events (
tenant_id, user_id, event_type, points, balance_after,
source_type, source_id, idempotency_key, metadata
)
values ($1, $2, 'check_in', $3, $4, 'point_activity_tasks', $5, $6, $7::jsonb)
on conflict (tenant_id, idempotency_key) do nothing
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
source_type as "sourceType", source_id as "sourceId", created_at as "createdAt"
`,
[
input.tenantId,
input.userId,
points,
scoreCursor,
task.id,
`check_in:${task.id}:user:${input.userId}:period:${periodKey}`,
JSON.stringify({
taskId: task.id,
taskCode: task.code,
taskType: 'daily_check_in',
checkInDate: input.today,
streak: input.streak,
periodKey,
rule,
}),
],
);
if (!ledger.rows[0]) {
scoreCursor -= points;
continue;
}
const claim = await client.query<PointActivityClaimRow>(
`
insert into public.user_point_activity_claims (
tenant_id, user_id, task_id, period_key, score_event_id,
source_type, source_id, metadata
)
values ($1, $2, $3, $4, $5, 'student_profiles', $6, $7::jsonb)
on conflict (tenant_id, user_id, task_id, period_key) do nothing
returning id, task_id as "taskId", period_key as "periodKey",
score_event_id as "scoreEventId", source_type as "sourceType",
source_id as "sourceId", status, metadata, claimed_at as "claimedAt"
`,
[
input.tenantId,
input.userId,
task.id,
periodKey,
ledger.rows[0].id,
input.profileId,
JSON.stringify({
source: 'check_in',
checkInDate: input.today,
streak: input.streak,
taskCode: task.code,
rule,
}),
],
);
if (!claim.rows[0]) {
await client.query(
`
delete from public.user_score_events
where tenant_id = $1 and id = $2
`,
[input.tenantId, ledger.rows[0].id],
);
scoreCursor -= points;
continue;
}
pointsAdded += points;
ledgers.push(ledger.rows[0]);
claims.push(claim.rows[0]);
rewardBreakdown.push({
taskId: task.id,
taskCode: task.code,
title: task.title,
points,
periodKey,
ledgerId: ledger.rows[0].id,
claimId: claim.rows[0]?.id || null,
rule,
});
}
return {
pointsAdded,
scoreAfter: scoreCursor,
rewardBreakdown,
ledgers,
claims: claims.filter(Boolean),
};
}
export async function profileMeRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
@@ -327,14 +614,11 @@ export async function checkInRoute(ctx: RequestContext) {
const userId = await userIdFrom(ctx);
const item = await transaction(async client => {
const today = new Date().toISOString().slice(0, 10);
const existing = await client.query<{
lastCheckInDate: string | null;
score: number;
stats: Record<string, unknown>;
}>(
const now = new Date();
const today = shanghaiDateKey(now);
const existing = await client.query<CheckInProfileRow>(
`
select sp.last_check_in_date as "lastCheckInDate", u.score, sp.stats
select sp.id as "profileId", sp.last_check_in_date as "lastCheckInDate", u.score, sp.stats
from public.student_profiles sp
join public.platform_users u on u.id = sp.user_id
where sp.tenant_id = $1 and sp.user_id = $2
@@ -345,73 +629,114 @@ export async function checkInRoute(ctx: RequestContext) {
);
const profile = existing.rows[0];
if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
if (profile.lastCheckInDate === today) {
if (toDateOnly(profile.lastCheckInDate) === today) {
return {
checkedIn: false,
alreadyCheckedIn: true,
pointsAdded: 0,
score: Number(profile.score || 0),
lastCheckInDate: today,
rewardBreakdown: [],
};
}
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const yesterdayText = yesterday.toISOString().slice(0, 10);
const yesterdayText = shanghaiDateKey(new Date(now.getTime() - 86_400_000));
const stats = objectValue(profile.stats);
const previousStreak = Number(stats.checkInStreak || 0);
const streak = profile.lastCheckInDate === yesterdayText ? previousStreak + 1 : 1;
const pointsAdded = 10 + Math.min(Math.max(streak - 1, 0), 6);
const balanceAfter = Number(profile.score || 0) + pointsAdded;
const streak = toDateOnly(profile.lastCheckInDate) === yesterdayText ? previousStreak + 1 : 1;
const scoreBefore = Number(profile.score || 0);
const configuredTasks = await loadCheckInRewardTasks(client, tenantId);
const configuredRewards = await grantConfiguredCheckInRewards(client, {
tenantId,
userId,
profileId: profile.profileId,
today,
now,
streak,
scoreBefore,
tasks: configuredTasks,
});
let rewardMode = configuredTasks.length ? 'configured_tasks' : 'legacy_formula';
let pointsAdded = configuredRewards.pointsAdded;
let ledgers = configuredRewards.ledgers;
let claims = configuredRewards.claims;
let rewardBreakdown: Array<CheckInRewardGrant | Record<string, unknown>> = configuredRewards.rewardBreakdown;
let balanceAfter = configuredRewards.scoreAfter;
if (!configuredTasks.length) {
pointsAdded = 10 + Math.min(Math.max(streak - 1, 0), 6);
balanceAfter = scoreBefore + pointsAdded;
const ledger = await client.query(
`
insert into public.user_score_events (
tenant_id, user_id, event_type, points, balance_after,
source_type, idempotency_key, metadata
)
values ($1, $2, 'check_in', $3, $4, 'student_profiles', $5, $6::jsonb)
on conflict (tenant_id, idempotency_key) do nothing
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
source_type as "sourceType", created_at as "createdAt"
`,
[
tenantId,
userId,
pointsAdded,
balanceAfter,
`check_in:${userId}:${today}`,
JSON.stringify({ checkInDate: today, streak, rewardMode }),
],
);
if (!ledger.rows[0]) {
return {
checkedIn: false,
alreadyCheckedIn: true,
pointsAdded: 0,
score: scoreBefore,
lastCheckInDate: today,
rewardBreakdown: [],
};
}
ledgers = ledger.rows;
rewardBreakdown = [{
rewardType: 'legacy_formula',
title: '每日签到',
points: pointsAdded,
streak,
}];
}
const nextStats = {
...stats,
checkInStreak: streak,
lastCheckInPoints: pointsAdded,
lastCheckInRewardMode: rewardMode,
checkInRewardBreakdown: rewardBreakdown.map(item => ({
taskId: 'taskId' in item ? item.taskId : undefined,
taskCode: 'taskCode' in item ? item.taskCode : undefined,
title: item.title,
points: item.points,
periodKey: 'periodKey' in item ? item.periodKey : undefined,
rewardType: 'rewardType' in item ? item.rewardType : undefined,
})),
};
const ledger = await client.query(
`
insert into public.user_score_events (
tenant_id, user_id, event_type, points, balance_after,
source_type, idempotency_key, metadata
)
values ($1, $2, 'check_in', $3, $4, 'student_profiles', $5, $6::jsonb)
on conflict (tenant_id, idempotency_key) do nothing
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
source_type as "sourceType", created_at as "createdAt"
`,
[
tenantId,
userId,
pointsAdded,
balanceAfter,
`check_in:${userId}:${today}`,
JSON.stringify({ checkInDate: today, streak }),
],
);
if (!ledger.rows[0]) {
return {
checkedIn: false,
alreadyCheckedIn: true,
pointsAdded: 0,
score: Number(profile.score || 0),
lastCheckInDate: today,
};
let score = balanceAfter;
if (pointsAdded > 0) {
const updatedUser = await client.query<{ score: number }>(
`
update public.platform_users
set score = score + $2,
updated_at = now()
where id = $1
returning score
`,
[userId, pointsAdded],
);
score = Number(updatedUser.rows[0]?.score || balanceAfter);
}
const updatedUser = await client.query<{ score: number }>(
`
update public.platform_users
set score = score + $2,
updated_at = now()
where id = $1
returning score
`,
[userId, pointsAdded],
);
await client.query(
`
update public.student_profiles
@@ -423,7 +748,6 @@ export async function checkInRoute(ctx: RequestContext) {
[tenantId, userId, today, JSON.stringify(nextStats)],
);
const score = updatedUser.rows[0]?.score || balanceAfter;
const autoBadges = [
...(await autoGrantBadges(client, {
tenantId,
@@ -433,21 +757,27 @@ export async function checkInRoute(ctx: RequestContext) {
checkInDate: today,
checkInStreak: streak,
lastCheckInPoints: pointsAdded,
rewardBreakdown,
rewardMode,
score,
},
})),
...(await autoGrantBadges(client, {
tenantId,
userId,
trigger: 'score',
evidence: {
source: 'check_in',
checkInDate: today,
checkInStreak: streak,
lastCheckInPoints: pointsAdded,
score,
},
})),
...(pointsAdded > 0
? await autoGrantBadges(client, {
tenantId,
userId,
trigger: 'score',
evidence: {
source: 'check_in',
checkInDate: today,
checkInStreak: streak,
lastCheckInPoints: pointsAdded,
rewardBreakdown,
rewardMode,
score,
},
})
: []),
];
return {
@@ -457,10 +787,11 @@ export async function checkInRoute(ctx: RequestContext) {
streak,
score,
lastCheckInDate: today,
ledger: {
...ledger.rows[0],
balanceAfter: score || ledger.rows[0].balanceAfter,
},
rewardMode,
rewardBreakdown,
claims,
ledgers,
ledger: ledgers[ledgers.length - 1] || null,
autoBadges,
};
});