forked from wangziqi/gongxue-base
feat: configure daily check-in rewards
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import type pg from 'pg';
|
||||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||||
import { query, queryOne, transaction } from '../../core/db.js';
|
import { query, queryOne, transaction } from '../../core/db.js';
|
||||||
@@ -33,6 +34,56 @@ interface ProfileRow {
|
|||||||
updatedAt: string;
|
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) {
|
function jsonBodyValue(value: unknown) {
|
||||||
return JSON.stringify(value && typeof value === 'object' ? value : {});
|
return JSON.stringify(value && typeof value === 'object' ? value : {});
|
||||||
}
|
}
|
||||||
@@ -59,7 +110,7 @@ function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
|||||||
|
|
||||||
function toDateOnly(value: unknown) {
|
function toDateOnly(value: unknown) {
|
||||||
if (!value) return null;
|
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);
|
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -90,6 +141,242 @@ function avatarDisplayUrl(preset: AvatarPreset) {
|
|||||||
return `/assets/avatars/default-${preset}.svg`;
|
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) {
|
export async function profileMeRoute(ctx: RequestContext) {
|
||||||
const tenantId = await tenantIdFrom(ctx);
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
const userId = await userIdFrom(ctx);
|
const userId = await userIdFrom(ctx);
|
||||||
@@ -327,14 +614,11 @@ export async function checkInRoute(ctx: RequestContext) {
|
|||||||
const userId = await userIdFrom(ctx);
|
const userId = await userIdFrom(ctx);
|
||||||
|
|
||||||
const item = await transaction(async client => {
|
const item = await transaction(async client => {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const now = new Date();
|
||||||
const existing = await client.query<{
|
const today = shanghaiDateKey(now);
|
||||||
lastCheckInDate: string | null;
|
const existing = await client.query<CheckInProfileRow>(
|
||||||
score: number;
|
|
||||||
stats: Record<string, unknown>;
|
|
||||||
}>(
|
|
||||||
`
|
`
|
||||||
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
|
from public.student_profiles sp
|
||||||
join public.platform_users u on u.id = sp.user_id
|
join public.platform_users u on u.id = sp.user_id
|
||||||
where sp.tenant_id = $1 and sp.user_id = $2
|
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];
|
const profile = existing.rows[0];
|
||||||
if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
|
if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
|
||||||
if (profile.lastCheckInDate === today) {
|
if (toDateOnly(profile.lastCheckInDate) === today) {
|
||||||
return {
|
return {
|
||||||
checkedIn: false,
|
checkedIn: false,
|
||||||
alreadyCheckedIn: true,
|
alreadyCheckedIn: true,
|
||||||
pointsAdded: 0,
|
pointsAdded: 0,
|
||||||
score: Number(profile.score || 0),
|
score: Number(profile.score || 0),
|
||||||
lastCheckInDate: today,
|
lastCheckInDate: today,
|
||||||
|
rewardBreakdown: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const yesterday = new Date();
|
const yesterdayText = shanghaiDateKey(new Date(now.getTime() - 86_400_000));
|
||||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
|
||||||
const yesterdayText = yesterday.toISOString().slice(0, 10);
|
|
||||||
const stats = objectValue(profile.stats);
|
const stats = objectValue(profile.stats);
|
||||||
const previousStreak = Number(stats.checkInStreak || 0);
|
const previousStreak = Number(stats.checkInStreak || 0);
|
||||||
const streak = profile.lastCheckInDate === yesterdayText ? previousStreak + 1 : 1;
|
const streak = toDateOnly(profile.lastCheckInDate) === yesterdayText ? previousStreak + 1 : 1;
|
||||||
const pointsAdded = 10 + Math.min(Math.max(streak - 1, 0), 6);
|
const scoreBefore = Number(profile.score || 0);
|
||||||
const balanceAfter = Number(profile.score || 0) + pointsAdded;
|
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 = {
|
const nextStats = {
|
||||||
...stats,
|
...stats,
|
||||||
checkInStreak: streak,
|
checkInStreak: streak,
|
||||||
lastCheckInPoints: pointsAdded,
|
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(
|
let score = balanceAfter;
|
||||||
`
|
if (pointsAdded > 0) {
|
||||||
insert into public.user_score_events (
|
const updatedUser = await client.query<{ score: number }>(
|
||||||
tenant_id, user_id, event_type, points, balance_after,
|
`
|
||||||
source_type, idempotency_key, metadata
|
update public.platform_users
|
||||||
)
|
set score = score + $2,
|
||||||
values ($1, $2, 'check_in', $3, $4, 'student_profiles', $5, $6::jsonb)
|
updated_at = now()
|
||||||
on conflict (tenant_id, idempotency_key) do nothing
|
where id = $1
|
||||||
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
|
returning score
|
||||||
source_type as "sourceType", created_at as "createdAt"
|
`,
|
||||||
`,
|
[userId, pointsAdded],
|
||||||
[
|
);
|
||||||
tenantId,
|
score = Number(updatedUser.rows[0]?.score || balanceAfter);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
await client.query(
|
||||||
`
|
`
|
||||||
update public.student_profiles
|
update public.student_profiles
|
||||||
@@ -423,7 +748,6 @@ export async function checkInRoute(ctx: RequestContext) {
|
|||||||
[tenantId, userId, today, JSON.stringify(nextStats)],
|
[tenantId, userId, today, JSON.stringify(nextStats)],
|
||||||
);
|
);
|
||||||
|
|
||||||
const score = updatedUser.rows[0]?.score || balanceAfter;
|
|
||||||
const autoBadges = [
|
const autoBadges = [
|
||||||
...(await autoGrantBadges(client, {
|
...(await autoGrantBadges(client, {
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -433,21 +757,27 @@ export async function checkInRoute(ctx: RequestContext) {
|
|||||||
checkInDate: today,
|
checkInDate: today,
|
||||||
checkInStreak: streak,
|
checkInStreak: streak,
|
||||||
lastCheckInPoints: pointsAdded,
|
lastCheckInPoints: pointsAdded,
|
||||||
|
rewardBreakdown,
|
||||||
|
rewardMode,
|
||||||
score,
|
score,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
...(await autoGrantBadges(client, {
|
...(pointsAdded > 0
|
||||||
tenantId,
|
? await autoGrantBadges(client, {
|
||||||
userId,
|
tenantId,
|
||||||
trigger: 'score',
|
userId,
|
||||||
evidence: {
|
trigger: 'score',
|
||||||
source: 'check_in',
|
evidence: {
|
||||||
checkInDate: today,
|
source: 'check_in',
|
||||||
checkInStreak: streak,
|
checkInDate: today,
|
||||||
lastCheckInPoints: pointsAdded,
|
checkInStreak: streak,
|
||||||
score,
|
lastCheckInPoints: pointsAdded,
|
||||||
},
|
rewardBreakdown,
|
||||||
})),
|
rewardMode,
|
||||||
|
score,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -457,10 +787,11 @@ export async function checkInRoute(ctx: RequestContext) {
|
|||||||
streak,
|
streak,
|
||||||
score,
|
score,
|
||||||
lastCheckInDate: today,
|
lastCheckInDate: today,
|
||||||
ledger: {
|
rewardMode,
|
||||||
...ledger.rows[0],
|
rewardBreakdown,
|
||||||
balanceAfter: score || ledger.rows[0].balanceAfter,
|
claims,
|
||||||
},
|
ledgers,
|
||||||
|
ledger: ledgers[ledgers.length - 1] || null,
|
||||||
autoBadges,
|
autoBadges,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,6 +94,20 @@ function intText(value: unknown, fallback = '') {
|
|||||||
return String(value);
|
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) {
|
function taskTypeLabel(type?: string | null) {
|
||||||
if (type === 'daily_check_in') return '每日签到';
|
if (type === 'daily_check_in') return '每日签到';
|
||||||
if (type === 'feedback_submit') return '提交反馈';
|
if (type === 'feedback_submit') return '提交反馈';
|
||||||
@@ -207,6 +221,10 @@ interface PointTaskFormState {
|
|||||||
validFrom: string;
|
validFrom: string;
|
||||||
validTo: string;
|
validTo: string;
|
||||||
order: string;
|
order: string;
|
||||||
|
requiredStreak: string;
|
||||||
|
minStreak: string;
|
||||||
|
maxStreak: string;
|
||||||
|
streakMultipleOf: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultPointTaskForm(): PointTaskFormState {
|
function defaultPointTaskForm(): PointTaskFormState {
|
||||||
@@ -223,6 +241,10 @@ function defaultPointTaskForm(): PointTaskFormState {
|
|||||||
validFrom: '',
|
validFrom: '',
|
||||||
validTo: '',
|
validTo: '',
|
||||||
order: '0',
|
order: '0',
|
||||||
|
requiredStreak: '',
|
||||||
|
minStreak: '',
|
||||||
|
maxStreak: '',
|
||||||
|
streakMultipleOf: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +522,10 @@ export default function TenantMarketingPage() {
|
|||||||
validFrom: item.validFrom ? item.validFrom.slice(0, 10) : '',
|
validFrom: item.validFrom ? item.validFrom.slice(0, 10) : '',
|
||||||
validTo: item.validTo ? item.validTo.slice(0, 10) : '',
|
validTo: item.validTo ? item.validTo.slice(0, 10) : '',
|
||||||
order: intText(item.order, '0'),
|
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 }));
|
setPointFilter(prev => ({ ...prev, selectedTaskId: item.id }));
|
||||||
}
|
}
|
||||||
@@ -612,6 +638,17 @@ export default function TenantMarketingPage() {
|
|||||||
setBusy('point-task-save');
|
setBusy('point-task-save');
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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({
|
const result = await upsertPointActivityTask({
|
||||||
id: pointTaskForm.id || undefined,
|
id: pointTaskForm.id || undefined,
|
||||||
code: pointTaskForm.code.trim(),
|
code: pointTaskForm.code.trim(),
|
||||||
@@ -625,7 +662,7 @@ export default function TenantMarketingPage() {
|
|||||||
validFrom: pointTaskForm.validFrom || null,
|
validFrom: pointTaskForm.validFrom || null,
|
||||||
validTo: pointTaskForm.validTo || null,
|
validTo: pointTaskForm.validTo || null,
|
||||||
order: Math.trunc(Number(pointTaskForm.order || 0)),
|
order: Math.trunc(Number(pointTaskForm.order || 0)),
|
||||||
metadata: { source: 'taro-tenant-admin' },
|
metadata,
|
||||||
});
|
});
|
||||||
Taro.showToast({ title: '积分任务已保存', icon: 'success' });
|
Taro.showToast({ title: '积分任务已保存', icon: 'success' });
|
||||||
if (result.item?.id) setPointFilter(prev => ({ ...prev, selectedTaskId: result.item?.id || prev.selectedTaskId }));
|
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.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 || '') }))} />
|
<Input className='admin-input' type='number' placeholder='单用户领取上限' value={pointTaskForm.claimLimitPerUser} onInput={event => setPointTaskForm(prev => ({ ...prev, claimLimitPerUser: String(event.detail.value || '') }))} />
|
||||||
</View>
|
</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'>
|
<View className='admin-actions compact'>
|
||||||
{(['manual', 'practice_complete', 'vocabulary_review', 'mock_exam_submit', 'daily_check_in', 'feedback_submit', 'feedback_resolved'] as const).map(type => (
|
{(['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>
|
<Button key={type} className={`admin-button ${pointTaskForm.taskType === type ? 'active' : ''}`} onClick={() => setPointTaskForm(prev => ({ ...prev, taskType: type }))}>{taskTypeLabel(type)}</Button>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
| 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates`、`GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` |
|
| 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates`、`GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` |
|
||||||
| 学生预设头像 | 可联调 | `GET/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传 |
|
| 学生预设头像 | 可联调 | `GET/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传 |
|
||||||
| 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转,处理结果和奖励积分会写入用户站内通知 |
|
| 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转,处理结果和奖励积分会写入用户站内通知 |
|
||||||
| 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分;真实签到成功会触发 `check_in` 和 `score` 规则勋章自动发放,重复签到不重复发放 |
|
| 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分;租户可用 `taskType=daily_check_in` 积分任务配置基础/连续签到奖励,后端自动写积分流水和领取记录;真实签到成功会触发 `check_in` 和 `score` 规则勋章自动发放,重复签到不重复发放 |
|
||||||
| 积分活动/兑换 | 可联调 | `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem`;复用 `user_score_events` 积分账本,任务领取和兑换均事务加锁;手动/练习/单词/模考类任务有后端证据校验,反馈解决等系统任务禁止学生自领;兑换支持库存、个人限购、余额校验、幂等 key、优惠券履约、兑换订单和完成/待履约站内通知 |
|
| 积分活动/兑换 | 可联调 | `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem`;复用 `user_score_events` 积分账本,任务领取和兑换均事务加锁;手动/练习/单词/模考类任务有后端证据校验,反馈解决等系统任务禁止学生自领;兑换支持库存、个人限购、余额校验、幂等 key、优惠券履约、兑换订单和完成/待履约站内通知 |
|
||||||
| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩规则已支持自动发放,自动/手动获得勋章会写入用户站内通知 |
|
| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩规则已支持自动发放,自动/手动获得勋章会写入用户站内通知 |
|
||||||
| 学生站内通知 | 可联调 | `GET /api/profile/notifications`、`POST /api/profile/notifications/status`;学生只能查看和更新自己的通知,支持未读/已读/忽略/归档、类型筛选和状态汇总 |
|
| 学生站内通知 | 可联调 | `GET /api/profile/notifications`、`POST /api/profile/notifications/status`;学生只能查看和更新自己的通知,支持未读/已读/忽略/归档、类型筛选和状态汇总 |
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
||||||
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
|
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
|
||||||
- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转;`user_score_events` 支持每日签到积分流水和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知;Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。
|
- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转;`user_score_events` 支持每日签到积分流水、租户配置化连续签到奖励和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知;Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/每日签到奖励/兑换操作台和用户通知查看第一版。
|
||||||
- 旧题库排行榜主链路已补齐但租户默认关闭:`GET /api/learning/leaderboard` 只有在 `feature_flags.enableLeaderboard=true` 时可用,支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
|
- 旧题库排行榜主链路已补齐但租户默认关闭:`GET /api/learning/leaderboard` 只有在 `feature_flags.enableLeaderboard=true` 时可用,支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
|
||||||
- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
|
- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
|
||||||
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
|
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
|
||||||
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
|
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
|
||||||
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 |
|
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 |
|
||||||
| 营销中心 | 可联调 | SVIP 套餐、激活码批次、激活码生成、优惠券启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细、核销报表、Banner/FAQ/公告、勋章管理、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分活动任务、积分兑换商品、兑换订单和优惠券兑换履约 | 连续签到奖励配置、积分风控报表、营销自动化和前端活动操作台 |
|
| 营销中心 | 可联调 | SVIP 套餐、激活码批次、激活码生成、优惠券启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细、核销报表、Banner/FAQ/公告、勋章管理、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分活动任务、积分兑换商品、兑换订单和优惠券兑换履约 | 积分风控报表、营销自动化和前端活动操作台 |
|
||||||
| 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 |
|
| 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 |
|
||||||
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
||||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
- 上线前需要把三套 H5 构建、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。
|
- 上线前需要把三套 H5 构建、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。
|
||||||
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。
|
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。
|
||||||
- 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
|
- 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
|
||||||
- 租户后台可以接入勋章管理、手动发放、积分任务和积分兑换:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`、`GET/PUT /api/tenant-admin/point-activity-tasks`、`GET /api/tenant-admin/point-activity-claims`、`GET/PUT /api/tenant-admin/point-exchange-items`、`GET /api/tenant-admin/point-exchange-orders`;学生端用 `GET /api/profile/badges` 展示成就,并通过 `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem` 接积分活动和兑换。
|
- 租户后台可以接入勋章管理、手动发放、积分任务、每日签到奖励和积分兑换:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`、`GET/PUT /api/tenant-admin/point-activity-tasks`、`GET /api/tenant-admin/point-activity-claims`、`GET/PUT /api/tenant-admin/point-exchange-items`、`GET /api/tenant-admin/point-exchange-orders`;学生端用 `GET /api/profile/badges` 展示成就,通过 `POST /api/profile/check-in` 自动领取 `daily_check_in` 任务奖励,并通过 `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem` 接其它积分活动和兑换。
|
||||||
- 学生消息中心可以接 `GET /api/profile/notifications`、`POST /api/profile/notifications/status`;租户后台可用 `GET /api/tenant-admin/user-notifications` 做用户通知查看。通知只做展示、跳转和已读/归档状态,业务权限和权益仍以后端源接口为准。
|
- 学生消息中心可以接 `GET /api/profile/notifications`、`POST /api/profile/notifications/status`;租户后台可用 `GET /api/tenant-admin/user-notifications` 做用户通知查看。通知只做展示、跳转和已读/归档状态,业务权限和权益仍以后端源接口为准。
|
||||||
|
|
||||||
## 不能误认为已商用完成的部分
|
## 不能误认为已商用完成的部分
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
||||||
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。
|
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。
|
||||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
||||||
- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈/积分活动/练习/单词/模考自动发放、积分任务/兑换第一阶段、用户站内通知第一版和主题模板发布基础 API 已可联调;Taro 学生个人中心已接积分任务/兑换/积分明细第一版,租户营销中心已接积分任务/兑换配置和记录查看第一版;积分风控报表、连续签到奖励配置、外部微信订阅消息/短信、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。
|
- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈/积分活动/练习/单词/模考自动发放、积分任务/兑换第一阶段、用户站内通知第一版和主题模板发布基础 API 已可联调;Taro 学生个人中心已接积分任务/兑换/积分明细第一版,租户营销中心已接积分任务/兑换配置和记录查看第一版;积分风控报表、外部微信订阅消息/短信、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。
|
||||||
|
|
||||||
## 前后端协作建议
|
## 前后端协作建议
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、播放水印上下文、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放、水印 traceId 和导入断言 | 播放、权益、后台绑定、动态水印上下文和批量 JSON 导入链路已实现,深度防盗链、转码级水印和播放统计待补 |
|
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、播放水印上下文、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放、水印 traceId 和导入断言 | 播放、权益、后台绑定、动态水印上下文和批量 JSON 导入链路已实现,深度防盗链、转码级水印和播放统计待补 |
|
||||||
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、动态水印上下文、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁、水印 traceId,assets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closed,exports worker 测试;Taro 类型检查覆盖学生资料水印预览/下载确认 | 资料资源基础闭环可跑,Taro 学生资料页已接短期签名、可见水印覆盖、追踪码展示和强制水印资源外部打开限制第一版;真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印待补 |
|
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、动态水印上下文、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁、水印 traceId,assets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closed,exports worker 测试;Taro 类型检查覆盖学生资料水印预览/下载确认 | 资料资源基础闭环可跑,Taro 学生资料页已接短期签名、可见水印覆盖、追踪码展示和强制水印资源外部打开限制第一版;真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印待补 |
|
||||||
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表、`user_notifications` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章和站内通知 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、积分任务/兑换/积分明细、消息中心、激活码和订单入口第一版;预设头像 UI、账号合并、更细学习建议和独立消息中心增强待补 |
|
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表、`user_notifications` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章和站内通知 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、积分任务/兑换/积分明细、消息中心、激活码和订单入口第一版;预设头像 UI、账号合并、更细学习建议和独立消息中心增强待补 |
|
||||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 |
|
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 |
|
||||||
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||||
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
||||||
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露;Taro 类型检查覆盖平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 |
|
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露;Taro 类型检查覆盖平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 |
|
||||||
@@ -313,5 +313,5 @@ platform-admin:
|
|||||||
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
||||||
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||||
4. 补视频商用控制:深度防盗链、转码级水印和播放统计。
|
4. 补视频商用控制:深度防盗链、转码级水印和播放统计。
|
||||||
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、连续签到奖励配置、积分风控报表、外部订阅消息/短信和更完整活动效果看板。
|
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、积分风控报表、外部订阅消息/短信和更完整活动效果看板。
|
||||||
6. 接真实短信/OAuth 生产账号、真实生产账单格式验收和异常订单运营台,并继续推进 Taro scaffold。
|
6. 接真实短信/OAuth 生产账号、真实生产账单格式验收和异常订单运营台,并继续推进 Taro scaffold。
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 |
|
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 |
|
||||||
| AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 |
|
| AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 |
|
||||||
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分和站内通知已覆盖;Taro 个人中心消息中心可查看反馈处理/奖励通知;仍缺外部订阅消息/短信提醒、批量统计和内容修复闭环 |
|
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分和站内通知已覆盖;Taro 个人中心消息中心可查看反馈处理/奖励通知;仍缺外部订阅消息/短信提醒、批量统计和内容修复闭环 |
|
||||||
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章、积分活动任务、积分兑换商品、优惠券兑换履约和租户后台配置已覆盖;后续补连续签到奖励配置、积分风控报表和更多系统触发任务 |
|
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章、积分活动任务、积分兑换商品、优惠券兑换履约和租户后台配置已覆盖;后续补积分风控报表和更多系统触发任务 |
|
||||||
| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;后续补防刷、日/周榜预聚合、运营后台排名看板 |
|
| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;后续补防刷、日/周榜预聚合、运营后台排名看板 |
|
||||||
|
|
||||||
## 租户后台功能
|
## 租户后台功能
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
| SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI |
|
| SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI |
|
||||||
| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券未核销幂等、下单抵扣、全额优惠自动开通权益、最低金额、封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细和报表已有;前端营销活动 UI 继续完善 |
|
| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券未核销幂等、下单抵扣、全额优惠自动开通权益、最低金额、封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细和报表已有;前端营销活动 UI 继续完善 |
|
||||||
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
||||||
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示、权限隔离、签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握、模考成绩自动发放和站内通知已覆盖;前端运营 UI、连续签到奖励配置和积分风控报表待补 |
|
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示、权限隔离、签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握、模考成绩自动发放和站内通知已覆盖;前端运营 UI 和积分风控报表待补 |
|
||||||
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 |
|
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 |
|
||||||
| 题库导出 PDF/Word/JSON/每日一练 ZIP | 部分覆盖 | 服务端 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练 ZIP 图片素材包异步导出已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job、审计、水印、`content_assets` 发布/下载路径和 exports worker;每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图包已补;后续补更精细试卷模板和后台操作台体验 |
|
| 题库导出 PDF/Word/JSON/每日一练 ZIP | 部分覆盖 | 服务端 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练 ZIP 图片素材包异步导出已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job、审计、水印、`content_assets` 发布/下载路径和 exports worker;每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图包已补;后续补更精细试卷模板和后台操作台体验 |
|
||||||
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
||||||
@@ -105,8 +105,8 @@
|
|||||||
7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。
|
7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。
|
||||||
8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF 报告和后台运营配置。
|
8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF 报告和后台运营配置。
|
||||||
9. 题目反馈增强:站内通知后端第一版已完成;仍缺前端消息中心、外部订阅消息/短信提醒、问题聚合统计和内容修复闭环。
|
9. 题目反馈增强:站内通知后端第一版已完成;仍缺前端消息中心、外部订阅消息/短信提醒、问题聚合统计和内容修复闭环。
|
||||||
10. 积分活动增强:积分兑换、活动任务、优惠券兑换履约和后台配置第一阶段已完成;仍缺连续签到奖励配置、积分风控报表、活动效果看板和更细系统任务触发。
|
10. 积分活动增强:积分兑换、活动任务、优惠券兑换履约和后台配置第一阶段已完成;仍缺积分风控报表、活动效果看板和更细系统任务触发。
|
||||||
11. 勋章增强:后台维护、手动发放、签到/积分/反馈/活动任务/练习次数/单词掌握/模考成绩自动发放和发放站内通知已有;仍缺前端运营配置体验、连续签到奖励配置和积分风控报表。
|
11. 勋章增强:后台维护、手动发放、签到/积分/反馈/活动任务/练习次数/单词掌握/模考成绩自动发放和发放站内通知已有;仍缺前端运营配置体验和积分风控报表。
|
||||||
|
|
||||||
### P0:前端联调到云端前
|
### P0:前端联调到云端前
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
|
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
|
||||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。
|
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。
|
||||||
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。后续补连续签到奖励配置、积分风控报表、活动效果看板和更细前端运营配置体验。
|
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。后续补积分风控报表、活动效果看板和更细前端运营配置体验。
|
||||||
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台第一版。后续补连续签到奖励配置、积分风控报表和更细活动效果看板。
|
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台第一版。后续补积分风控报表和更细活动效果看板。
|
||||||
- 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知;Taro 学生个人中心已接消息筛选、已读和归档第一版,租户营销中心已接用户通知查看和筛选第一版。后续补独立消息中心增强、外部微信订阅消息/短信和批量统计。
|
- 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知;Taro 学生个人中心已接消息筛选、已读和归档第一版,租户营销中心已接用户通知查看和筛选第一版。后续补独立消息中心增强、外部微信订阅消息/短信和批量统计。
|
||||||
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
||||||
- 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。
|
- 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
9. 积分和反馈增强
|
9. 积分和反馈增强
|
||||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||||
- 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩自动发放。
|
- 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩自动发放。
|
||||||
- 积分兑换、活动任务、优惠券兑换履约和站内通知后端第一阶段已完成;Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版;继续补连续签到奖励配置、积分风控报表、独立消息中心增强、外部订阅消息/短信和反馈聚合统计。
|
- 积分兑换、活动任务、优惠券兑换履约和站内通知后端第一阶段已完成;Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版;继续补积分风控报表、独立消息中心增强、外部订阅消息/短信和反馈聚合统计。
|
||||||
|
|
||||||
10. 数据看板
|
10. 数据看板
|
||||||
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
||||||
|
|||||||
@@ -1171,10 +1171,41 @@ POST /api/profile/check-in
|
|||||||
"item": {
|
"item": {
|
||||||
"checkedIn": true,
|
"checkedIn": true,
|
||||||
"alreadyCheckedIn": false,
|
"alreadyCheckedIn": false,
|
||||||
"pointsAdded": 10,
|
"pointsAdded": 17,
|
||||||
"streak": 1,
|
"streak": 1,
|
||||||
"score": 10,
|
"score": 17,
|
||||||
"lastCheckInDate": "2026-06-29",
|
"lastCheckInDate": "2026-06-29",
|
||||||
|
"rewardMode": "configured_tasks",
|
||||||
|
"rewardBreakdown": [
|
||||||
|
{
|
||||||
|
"taskId": "<daily-check-in-task-id>",
|
||||||
|
"taskCode": "daily-check-in",
|
||||||
|
"title": "每日签到基础奖励",
|
||||||
|
"points": 12,
|
||||||
|
"periodKey": "2026-06-29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"taskId": "<streak-task-id>",
|
||||||
|
"taskCode": "check-in-streak-day-one",
|
||||||
|
"title": "连续签到首日奖励",
|
||||||
|
"points": 5,
|
||||||
|
"periodKey": "2026-06-29"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ledgers": [
|
||||||
|
{
|
||||||
|
"eventType": "check_in",
|
||||||
|
"sourceType": "point_activity_tasks",
|
||||||
|
"sourceId": "<daily-check-in-task-id>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"taskId": "<daily-check-in-task-id>",
|
||||||
|
"periodKey": "2026-06-29",
|
||||||
|
"sourceType": "student_profiles"
|
||||||
|
}
|
||||||
|
],
|
||||||
"autoBadges": [
|
"autoBadges": [
|
||||||
{
|
{
|
||||||
"badgeId": "...",
|
"badgeId": "...",
|
||||||
@@ -1194,6 +1225,8 @@ POST /api/profile/check-in
|
|||||||
- `alreadyCheckedIn=true` 时展示今日已签到,不要本地再加分。
|
- `alreadyCheckedIn=true` 时展示今日已签到,不要本地再加分。
|
||||||
- 积分明细调用 `GET /api/profile/score-events`。
|
- 积分明细调用 `GET /api/profile/score-events`。
|
||||||
- 积分最终余额以后端 `score` 和流水为准,前端只做展示。
|
- 积分最终余额以后端 `score` 和流水为准,前端只做展示。
|
||||||
|
- 租户如配置了 `taskType=daily_check_in` 的积分任务,签到会返回 `rewardMode=configured_tasks` 和 `rewardBreakdown`;前端可展示每项奖励,但不要重新计算连续签到奖励。
|
||||||
|
- 租户未配置每日签到任务时,后端会使用兼容旧逻辑的固定积分公式,并返回 `rewardMode=legacy_formula`。
|
||||||
- 如响应包含 `autoBadges`,可展示获得勋章弹层;不要在前端根据连续天数或积分自行判断勋章是否解锁。
|
- 如响应包含 `autoBadges`,可展示获得勋章弹层;不要在前端根据连续天数或积分自行判断勋章是否解锁。
|
||||||
|
|
||||||
积分活动任务和积分兑换入口:
|
积分活动任务和积分兑换入口:
|
||||||
@@ -1229,7 +1262,7 @@ POST /api/profile/exchange-items/redeem
|
|||||||
|
|
||||||
- 积分任务领取、积分扣减、库存扣减、个人限购、优惠券兑换履约都由后端事务处理。
|
- 积分任务领取、积分扣减、库存扣减、个人限购、优惠券兑换履约都由后端事务处理。
|
||||||
- `practice_complete`、`mock_exam_submit`、`vocabulary_review` 等任务需要传 `sourceId`,后端会校验证据属于当前学生当前租户。
|
- `practice_complete`、`mock_exam_submit`、`vocabulary_review` 等任务需要传 `sourceId`,后端会校验证据属于当前学生当前租户。
|
||||||
- `feedback_resolved` 等系统流程任务禁止学生自领,前端应展示为“系统发放”或不展示领取按钮。
|
- `daily_check_in`、`feedback_resolved` 等系统流程任务禁止学生自领,前端应展示为“系统发放”或不展示领取按钮;签到只调用 `POST /api/profile/check-in`。
|
||||||
- 兑换返回 `order` 和最新积分余额后,再刷新 `profile/me`、`score-events` 和兑换列表;不要在本地先扣积分。
|
- 兑换返回 `order` 和最新积分余额后,再刷新 `profile/me`、`score-events` 和兑换列表;不要在本地先扣积分。
|
||||||
- 如果响应包含 `autoBadges`,按获得勋章提示处理;重复领取、库存不足、积分不足、限购等错误按后端 `code` 展示业务提示。
|
- 如果响应包含 `autoBadges`,按获得勋章提示处理;重复领取、库存不足、积分不足、限购等错误按后端 `code` 展示业务提示。
|
||||||
|
|
||||||
@@ -1248,6 +1281,32 @@ GET /api/tenant-admin/point-exchange-orders?itemId=<item-id>&status=pending_fulf
|
|||||||
|
|
||||||
当前 Taro 第一版已经把租户积分运营嵌入 `apps/taro/src/pages/tenant-admin/marketing/index.tsx`,支持任务配置、兑换商品配置、任务领取记录和兑换订单查看。前端只提交配置和筛选条件,权限、优惠券/资源归属、库存、限购、积分扣减和审计仍由后端处理。
|
当前 Taro 第一版已经把租户积分运营嵌入 `apps/taro/src/pages/tenant-admin/marketing/index.tsx`,支持任务配置、兑换商品配置、任务领取记录和兑换订单查看。前端只提交配置和筛选条件,权限、优惠券/资源归属、库存、限购、积分扣减和审计仍由后端处理。
|
||||||
|
|
||||||
|
每日签到奖励配置复用同一组积分任务接口:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "daily-check-in",
|
||||||
|
"title": "每日签到基础奖励",
|
||||||
|
"taskType": "daily_check_in",
|
||||||
|
"rewardPoints": 12,
|
||||||
|
"periodType": "daily",
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
连续签到规则写在 `metadata`,后端按当前连续签到天数匹配,命中后自动写 `user_score_events` 和 `user_point_activity_claims`。常用字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"requiredStreak": 7,
|
||||||
|
"minStreak": 3,
|
||||||
|
"maxStreak": 30,
|
||||||
|
"streakMultipleOf": 7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
规则说明:`requiredStreak` 表示仅第 N 天命中,`minStreak/maxStreak` 表示连续天数区间,`streakMultipleOf` 表示连续天数为 N 的倍数时命中。多个 `daily_check_in` 任务可同时生效,后端会逐项发放并返回 `rewardBreakdown`。
|
||||||
|
|
||||||
题目页、资料页或视频页可提交反馈:
|
题目页、资料页或视频页可提交反馈:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const ids = {
|
|||||||
tenantPracticeBadge: '00000000-0000-0000-0000-000000000881',
|
tenantPracticeBadge: '00000000-0000-0000-0000-000000000881',
|
||||||
tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882',
|
tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882',
|
||||||
tenantMockExamBadge: '00000000-0000-0000-0000-000000000883',
|
tenantMockExamBadge: '00000000-0000-0000-0000-000000000883',
|
||||||
|
dailyCheckInTask: '00000000-0000-0000-0000-000000000884',
|
||||||
|
streakCheckInTask: '00000000-0000-0000-0000-000000000885',
|
||||||
pointActivityTask: '00000000-0000-0000-0000-000000000877',
|
pointActivityTask: '00000000-0000-0000-0000-000000000877',
|
||||||
pointSystemTask: '00000000-0000-0000-0000-000000000878',
|
pointSystemTask: '00000000-0000-0000-0000-000000000878',
|
||||||
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
|
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
|
||||||
@@ -2878,11 +2880,69 @@ async function testProfile() {
|
|||||||
});
|
});
|
||||||
assert.equal(scoreBadge.item?.id, ids.tenantScoreBadge, 'tenant admin should create automatic score badge');
|
assert.equal(scoreBadge.item?.id, ids.tenantScoreBadge, 'tenant admin should create automatic score badge');
|
||||||
|
|
||||||
|
const dailyCheckInTask = await request('/api/tenant-admin/point-activity-tasks', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
id: ids.dailyCheckInTask,
|
||||||
|
legacyId: 'integration-point-task-daily-check-in',
|
||||||
|
code: 'integration-daily-check-in',
|
||||||
|
title: '每日签到基础奖励',
|
||||||
|
taskType: 'daily_check_in',
|
||||||
|
rewardPoints: 12,
|
||||||
|
periodType: 'daily',
|
||||||
|
claimLimitPerUser: 1,
|
||||||
|
metadata: { source: 'integration-test' },
|
||||||
|
order: 1,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(dailyCheckInTask.item?.id, ids.dailyCheckInTask, 'tenant admin should configure daily check-in reward task');
|
||||||
|
|
||||||
|
const streakCheckInTask = await request('/api/tenant-admin/point-activity-tasks', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
id: ids.streakCheckInTask,
|
||||||
|
legacyId: 'integration-point-task-check-in-streak-day-one',
|
||||||
|
code: 'integration-check-in-streak-day-one',
|
||||||
|
title: '连续签到首日奖励',
|
||||||
|
taskType: 'daily_check_in',
|
||||||
|
rewardPoints: 5,
|
||||||
|
periodType: 'daily',
|
||||||
|
claimLimitPerUser: 1,
|
||||||
|
metadata: { source: 'integration-test', requiredStreak: 1 },
|
||||||
|
order: 2,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(streakCheckInTask.item?.id, ids.streakCheckInTask, 'tenant admin should configure streak check-in reward task');
|
||||||
|
|
||||||
const checkIn = await request('/api/profile/check-in', { method: 'POST' });
|
const checkIn = await request('/api/profile/check-in', { method: 'POST' });
|
||||||
assert.equal(checkIn.item?.checkedIn, true, 'student should be able to check in');
|
assert.equal(checkIn.item?.checkedIn, true, 'student should be able to check in');
|
||||||
assert.ok(checkIn.item?.pointsAdded >= 10, 'check-in should add points');
|
assert.equal(checkIn.item?.rewardMode, 'configured_tasks', 'check-in should use configured tenant reward tasks');
|
||||||
|
assert.equal(checkIn.item?.pointsAdded, 17, 'configured check-in tasks should add total points');
|
||||||
assert.ok(checkIn.item?.score >= checkIn.item?.pointsAdded, 'check-in should return updated score');
|
assert.ok(checkIn.item?.score >= checkIn.item?.pointsAdded, 'check-in should return updated score');
|
||||||
assert.equal(checkIn.item?.ledger?.eventType, 'check_in', 'check-in should write score ledger');
|
assert.equal(checkIn.item?.ledger?.eventType, 'check_in', 'check-in should write score ledger');
|
||||||
|
assert.equal(checkIn.item?.ledger?.sourceId, ids.streakCheckInTask, 'compat check-in ledger should expose the latest configured task ledger');
|
||||||
|
assert.ok(
|
||||||
|
checkIn.item?.rewardBreakdown?.some(item => item.taskId === ids.dailyCheckInTask && item.points === 12),
|
||||||
|
'check-in should include the daily task reward breakdown',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
checkIn.item?.rewardBreakdown?.some(item => item.taskId === ids.streakCheckInTask && item.points === 5),
|
||||||
|
'check-in should include the streak task reward breakdown',
|
||||||
|
);
|
||||||
|
assert.equal(checkIn.item?.ledgers?.length, 2, 'check-in should write one ledger per configured reward task');
|
||||||
|
assert.ok(
|
||||||
|
checkIn.item?.ledgers?.some(item => item.sourceId === ids.dailyCheckInTask && item.points === 12),
|
||||||
|
'check-in ledgers should include the daily task source',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
checkIn.item?.ledgers?.some(item => item.sourceId === ids.streakCheckInTask && item.points === 5),
|
||||||
|
'check-in ledgers should include the streak task source',
|
||||||
|
);
|
||||||
|
assert.equal(checkIn.item?.claims?.length, 2, 'check-in should write one claim per configured reward task');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
checkIn.item?.autoBadges?.some(item => item.badgeId === ids.tenantCheckInBadge),
|
checkIn.item?.autoBadges?.some(item => item.badgeId === ids.tenantCheckInBadge),
|
||||||
'check-in should auto grant check-in badge',
|
'check-in should auto grant check-in badge',
|
||||||
@@ -2897,9 +2957,28 @@ async function testProfile() {
|
|||||||
assert.equal(duplicateCheckIn.item?.alreadyCheckedIn, true, 'duplicate daily check-in should report already checked in');
|
assert.equal(duplicateCheckIn.item?.alreadyCheckedIn, true, 'duplicate daily check-in should report already checked in');
|
||||||
assert.equal(duplicateCheckIn.item?.pointsAdded, 0, 'duplicate daily check-in should not add points');
|
assert.equal(duplicateCheckIn.item?.pointsAdded, 0, 'duplicate daily check-in should not add points');
|
||||||
assert.equal(duplicateCheckIn.item?.autoBadges, undefined, 'duplicate check-in should not auto grant badges');
|
assert.equal(duplicateCheckIn.item?.autoBadges, undefined, 'duplicate check-in should not auto grant badges');
|
||||||
|
assert.deepEqual(duplicateCheckIn.item?.rewardBreakdown, [], 'duplicate check-in should not return reward tasks');
|
||||||
|
|
||||||
const scoreEvents = await request('/api/profile/score-events', { query: { limit: 20 } });
|
const scoreEvents = await request('/api/profile/score-events', { query: { limit: 20 } });
|
||||||
assert.ok(scoreEvents.items?.some(item => item.eventType === 'check_in'), 'score ledger should include check-in event');
|
assert.ok(scoreEvents.items?.some(item => item.eventType === 'check_in'), 'score ledger should include check-in event');
|
||||||
|
assert.ok(
|
||||||
|
scoreEvents.items?.some(item => item.eventType === 'check_in' && item.sourceId === ids.dailyCheckInTask),
|
||||||
|
'score ledger should include daily check-in task source',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
scoreEvents.items?.some(item => item.eventType === 'check_in' && item.sourceId === ids.streakCheckInTask),
|
||||||
|
'score ledger should include streak check-in task source',
|
||||||
|
);
|
||||||
|
|
||||||
|
const dailyCheckInClaims = await request('/api/tenant-admin/point-activity-claims', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
query: { taskId: ids.dailyCheckInTask, limit: 20 },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
dailyCheckInClaims.items?.filter(item => item.userId === USER_ID).length,
|
||||||
|
1,
|
||||||
|
'tenant admin should see one daily check-in claim for the student',
|
||||||
|
);
|
||||||
|
|
||||||
const autoProfileBadges = await request('/api/profile/badges', {
|
const autoProfileBadges = await request('/api/profile/badges', {
|
||||||
query: { includeLocked: 'true', category: 'activity', limit: 20 },
|
query: { includeLocked: 'true', category: 'activity', limit: 20 },
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ const ids = {
|
|||||||
tenantPracticeBadge: '00000000-0000-0000-0000-000000000881',
|
tenantPracticeBadge: '00000000-0000-0000-0000-000000000881',
|
||||||
tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882',
|
tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882',
|
||||||
tenantMockExamBadge: '00000000-0000-0000-0000-000000000883',
|
tenantMockExamBadge: '00000000-0000-0000-0000-000000000883',
|
||||||
|
dailyCheckInTask: '00000000-0000-0000-0000-000000000884',
|
||||||
|
streakCheckInTask: '00000000-0000-0000-0000-000000000885',
|
||||||
pointActivityTask: '00000000-0000-0000-0000-000000000877',
|
pointActivityTask: '00000000-0000-0000-0000-000000000877',
|
||||||
pointSystemTask: '00000000-0000-0000-0000-000000000878',
|
pointSystemTask: '00000000-0000-0000-0000-000000000878',
|
||||||
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
|
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
|
||||||
@@ -280,7 +282,7 @@ async function main() {
|
|||||||
tenantId,
|
tenantId,
|
||||||
ids.user,
|
ids.user,
|
||||||
ids.secondStudentUser,
|
ids.secondStudentUser,
|
||||||
[ids.pointActivityTask, ids.pointSystemTask],
|
[ids.pointActivityTask, ids.pointSystemTask, ids.dailyCheckInTask, ids.streakCheckInTask],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -319,7 +321,7 @@ async function main() {
|
|||||||
or code::text like 'integration-%'
|
or code::text like 'integration-%'
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
[tenantId, [ids.pointActivityTask, ids.pointSystemTask]],
|
[tenantId, [ids.pointActivityTask, ids.pointSystemTask, ids.dailyCheckInTask, ids.streakCheckInTask]],
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -1000,7 +1002,7 @@ async function main() {
|
|||||||
set region_id = $3,
|
set region_id = $3,
|
||||||
avatar_preset = 'male',
|
avatar_preset = 'male',
|
||||||
last_check_in_date = null,
|
last_check_in_date = null,
|
||||||
stats = stats - 'checkInStreak' - 'lastCheckInPoints',
|
stats = stats - 'checkInStreak' - 'lastCheckInPoints' - 'lastCheckInRewardMode' - 'checkInRewardBreakdown',
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where tenant_id = $1
|
where tenant_id = $1
|
||||||
and user_id in ($2::uuid, $4::uuid)
|
and user_id in ($2::uuid, $4::uuid)
|
||||||
|
|||||||
Reference in New Issue
Block a user