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

View File

@@ -94,6 +94,20 @@ function intText(value: unknown, fallback = '') {
return String(value);
}
function metadataNumberText(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
if (value === null || value === undefined || value === '') return '';
const parsed = Number(value);
return Number.isFinite(parsed) ? String(Math.trunc(parsed)) : '';
}
function optionalPositiveIntText(value: string) {
if (!value.trim()) return undefined;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return undefined;
return Math.max(1, Math.trunc(parsed));
}
function taskTypeLabel(type?: string | null) {
if (type === 'daily_check_in') return '每日签到';
if (type === 'feedback_submit') return '提交反馈';
@@ -207,6 +221,10 @@ interface PointTaskFormState {
validFrom: string;
validTo: string;
order: string;
requiredStreak: string;
minStreak: string;
maxStreak: string;
streakMultipleOf: string;
}
function defaultPointTaskForm(): PointTaskFormState {
@@ -223,6 +241,10 @@ function defaultPointTaskForm(): PointTaskFormState {
validFrom: '',
validTo: '',
order: '0',
requiredStreak: '',
minStreak: '',
maxStreak: '',
streakMultipleOf: '',
};
}
@@ -500,6 +522,10 @@ export default function TenantMarketingPage() {
validFrom: item.validFrom ? item.validFrom.slice(0, 10) : '',
validTo: item.validTo ? item.validTo.slice(0, 10) : '',
order: intText(item.order, '0'),
requiredStreak: metadataNumberText(item.metadata, 'requiredStreak'),
minStreak: metadataNumberText(item.metadata, 'minStreak'),
maxStreak: metadataNumberText(item.metadata, 'maxStreak'),
streakMultipleOf: metadataNumberText(item.metadata, 'streakMultipleOf'),
});
setPointFilter(prev => ({ ...prev, selectedTaskId: item.id }));
}
@@ -612,6 +638,17 @@ export default function TenantMarketingPage() {
setBusy('point-task-save');
setError('');
try {
const metadata: Record<string, unknown> = { source: 'taro-tenant-admin' };
if (pointTaskForm.taskType === 'daily_check_in') {
const requiredStreak = optionalPositiveIntText(pointTaskForm.requiredStreak);
const minStreak = optionalPositiveIntText(pointTaskForm.minStreak);
const maxStreak = optionalPositiveIntText(pointTaskForm.maxStreak);
const streakMultipleOf = optionalPositiveIntText(pointTaskForm.streakMultipleOf);
if (requiredStreak !== undefined) metadata.requiredStreak = requiredStreak;
if (minStreak !== undefined) metadata.minStreak = minStreak;
if (maxStreak !== undefined) metadata.maxStreak = maxStreak;
if (streakMultipleOf !== undefined) metadata.streakMultipleOf = streakMultipleOf;
}
const result = await upsertPointActivityTask({
id: pointTaskForm.id || undefined,
code: pointTaskForm.code.trim(),
@@ -625,7 +662,7 @@ export default function TenantMarketingPage() {
validFrom: pointTaskForm.validFrom || null,
validTo: pointTaskForm.validTo || null,
order: Math.trunc(Number(pointTaskForm.order || 0)),
metadata: { source: 'taro-tenant-admin' },
metadata,
});
Taro.showToast({ title: '积分任务已保存', icon: 'success' });
if (result.item?.id) setPointFilter(prev => ({ ...prev, selectedTaskId: result.item?.id || prev.selectedTaskId }));
@@ -978,6 +1015,14 @@ export default function TenantMarketingPage() {
<Input className='admin-input' type='number' placeholder='奖励积分' value={pointTaskForm.rewardPoints} onInput={event => setPointTaskForm(prev => ({ ...prev, rewardPoints: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='单用户领取上限' value={pointTaskForm.claimLimitPerUser} onInput={event => setPointTaskForm(prev => ({ ...prev, claimLimitPerUser: String(event.detail.value || '') }))} />
</View>
{pointTaskForm.taskType === 'daily_check_in' ? (
<View className='admin-form-grid'>
<Input className='admin-input' type='number' placeholder='仅第 N 天奖励' value={pointTaskForm.requiredStreak} onInput={event => setPointTaskForm(prev => ({ ...prev, requiredStreak: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='最小连续天数' value={pointTaskForm.minStreak} onInput={event => setPointTaskForm(prev => ({ ...prev, minStreak: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='最大连续天数' value={pointTaskForm.maxStreak} onInput={event => setPointTaskForm(prev => ({ ...prev, maxStreak: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='每 N 天循环奖励' value={pointTaskForm.streakMultipleOf} onInput={event => setPointTaskForm(prev => ({ ...prev, streakMultipleOf: String(event.detail.value || '') }))} />
</View>
) : null}
<View className='admin-actions compact'>
{(['manual', 'practice_complete', 'vocabulary_review', 'mock_exam_submit', 'daily_check_in', 'feedback_submit', 'feedback_resolved'] as const).map(type => (
<Button key={type} className={`admin-button ${pointTaskForm.taskType === type ? 'active' : ''}`} onClick={() => setPointTaskForm(prev => ({ ...prev, taskType: type }))}>{taskTypeLabel(type)}</Button>