From a5a2caa2e922339979cd527135d12391e6c3eb3d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 14:52:10 +0800 Subject: [PATCH] feat: configure daily check-in rewards --- apps/api/src/features/profile/routes.ts | 475 +++++++++++++++--- .../pages/tenant-admin/marketing/index.tsx | 47 +- docs/refactor/backend-capability-status.md | 2 +- docs/refactor/backend-progress.md | 2 +- docs/refactor/blueprint-coverage.md | 2 +- docs/refactor/frontend-handoff-index.md | 4 +- docs/refactor/implementation-status.md | 4 +- docs/refactor/legacy-feature-gap-matrix.md | 8 +- docs/refactor/next-development-todo.md | 6 +- docs/refactor/taro-frontend-integration.md | 65 ++- scripts/api-integration-test.js | 81 ++- scripts/smoke-seed.js | 8 +- 12 files changed, 610 insertions(+), 94 deletions(-) diff --git a/apps/api/src/features/profile/routes.ts b/apps/api/src/features/profile/routes.ts index 03fd18c7..e6d9a528 100644 --- a/apps/api/src/features/profile/routes.ts +++ b/apps/api/src/features/profile/routes.ts @@ -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; +} + +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( + ` + 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( + ` + 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; - }>( + const now = new Date(); + const today = shanghaiDateKey(now); + const existing = await client.query( ` - 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> = 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, }; }); diff --git a/apps/taro/src/pages/tenant-admin/marketing/index.tsx b/apps/taro/src/pages/tenant-admin/marketing/index.tsx index 62f4cf74..b06121f3 100644 --- a/apps/taro/src/pages/tenant-admin/marketing/index.tsx +++ b/apps/taro/src/pages/tenant-admin/marketing/index.tsx @@ -94,6 +94,20 @@ function intText(value: unknown, fallback = '') { return String(value); } +function metadataNumberText(metadata: Record | 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 = { 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() { setPointTaskForm(prev => ({ ...prev, rewardPoints: String(event.detail.value || '') }))} /> setPointTaskForm(prev => ({ ...prev, claimLimitPerUser: String(event.detail.value || '') }))} /> + {pointTaskForm.taskType === 'daily_check_in' ? ( + + setPointTaskForm(prev => ({ ...prev, requiredStreak: String(event.detail.value || '') }))} /> + setPointTaskForm(prev => ({ ...prev, minStreak: String(event.detail.value || '') }))} /> + setPointTaskForm(prev => ({ ...prev, maxStreak: String(event.detail.value || '') }))} /> + setPointTaskForm(prev => ({ ...prev, streakMultipleOf: String(event.detail.value || '') }))} /> + + ) : null} {(['manual', 'practice_complete', 'vocabulary_review', 'mock_exam_submit', 'daily_check_in', 'feedback_submit', 'feedback_resolved'] as const).map(type => ( diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 13e82e5f..27f0a1bd 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -68,7 +68,7 @@ | 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates`、`GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` | | 学生预设头像 | 可联调 | `GET/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传 | | 题目反馈/纠错 | 可联调 | `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/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩规则已支持自动发放,自动/手动获得勋章会写入用户站内通知 | | 学生站内通知 | 可联调 | `GET /api/profile/notifications`、`POST /api/profile/notifications/status`;学生只能查看和更新自己的通知,支持未读/已读/忽略/归档、类型筛选和状态汇总 | diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 241e2f6c..ba8658e5 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -23,7 +23,7 @@ - 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 - 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理已落库:`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 天周期,以及租户/地区/班级范围。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 1c8784b1..5b8172fb 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -25,7 +25,7 @@ | 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 | | 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 | | 资料下载/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、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index ef00c271..14bbe3c6 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -46,7 +46,7 @@ - 上线前需要把三套 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:`/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` 做用户通知查看。通知只做展示、跳转和已读/归档状态,业务权限和权益仍以后端源接口为准。 ## 不能误认为已商用完成的部分 @@ -56,7 +56,7 @@ - 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。 - 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。 - 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。 -- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈/积分活动/练习/单词/模考自动发放、积分任务/兑换第一阶段、用户站内通知第一版和主题模板发布基础 API 已可联调;Taro 学生个人中心已接积分任务/兑换/积分明细第一版,租户营销中心已接积分任务/兑换配置和记录查看第一版;积分风控报表、连续签到奖励配置、外部微信订阅消息/短信、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。 +- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈/积分活动/练习/单词/模考自动发放、积分任务/兑换第一阶段、用户站内通知第一版和主题模板发布基础 API 已可联调;Taro 学生个人中心已接积分任务/兑换/积分明细第一版,租户营销中心已接积分任务/兑换配置和记录查看第一版;积分风控报表、外部微信订阅消息/短信、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。 ## 前后端协作建议 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 58c42618..5c8e0765 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -34,7 +34,7 @@ | 题目视频讲解 | 已建 `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 级水印待补 | | 个人中心 | 已建 `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 分配策略、富卡片和销售转化看板待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 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 类型检查覆盖平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 | @@ -313,5 +313,5 @@ platform-admin: 2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。 3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 补视频商用控制:深度防盗链、转码级水印和播放统计。 -5. 补 AI 择校推荐报告、排行榜防刷/预聚合、连续签到奖励配置、积分风控报表、外部订阅消息/短信和更完整活动效果看板。 +5. 补 AI 择校推荐报告、排行榜防刷/预聚合、积分风控报表、外部订阅消息/短信和更完整活动效果看板。 6. 接真实短信/OAuth 生产账号、真实生产账单格式验收和异常订单运营台,并继续推进 Taro scaffold。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 417f2d15..713dd5a6 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -35,7 +35,7 @@ | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 | | AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 | | 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分和站内通知已覆盖;Taro 个人中心消息中心可查看反馈处理/奖励通知;仍缺外部订阅消息/短信提醒、批量统计和内容修复闭环 | -| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章、积分活动任务、积分兑换商品、优惠券兑换履约和租户后台配置已覆盖;后续补连续签到奖励配置、积分风控报表和更多系统触发任务 | +| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章、积分活动任务、积分兑换商品、优惠券兑换履约和租户后台配置已覆盖;后续补积分风控报表和更多系统触发任务 | | 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;后续补防刷、日/周榜预聚合、运营后台排名看板 | ## 租户后台功能 @@ -57,7 +57,7 @@ | SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI | | 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券未核销幂等、下单抵扣、全额优惠自动开通权益、最低金额、封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细和报表已有;前端营销活动 UI 继续完善 | | 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 | -| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示、权限隔离、签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握、模考成绩自动发放和站内通知已覆盖;前端运营 UI、连续签到奖励配置和积分风控报表待补 | +| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示、权限隔离、签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握、模考成绩自动发放和站内通知已覆盖;前端运营 UI 和积分风控报表待补 | | 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 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 卡片和拼图包已补;后续补更精细试卷模板和后台操作台体验 | | 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 | @@ -105,8 +105,8 @@ 7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。 8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF 报告和后台运营配置。 9. 题目反馈增强:站内通知后端第一版已完成;仍缺前端消息中心、外部订阅消息/短信提醒、问题聚合统计和内容修复闭环。 -10. 积分活动增强:积分兑换、活动任务、优惠券兑换履约和后台配置第一阶段已完成;仍缺连续签到奖励配置、积分风控报表、活动效果看板和更细系统任务触发。 -11. 勋章增强:后台维护、手动发放、签到/积分/反馈/活动任务/练习次数/单词掌握/模考成绩自动发放和发放站内通知已有;仍缺前端运营配置体验、连续签到奖励配置和积分风控报表。 +10. 积分活动增强:积分兑换、活动任务、优惠券兑换履约和后台配置第一阶段已完成;仍缺积分风控报表、活动效果看板和更细系统任务触发。 +11. 勋章增强:后台维护、手动发放、签到/积分/反馈/活动任务/练习次数/单词掌握/模考成绩自动发放和发放站内通知已有;仍缺前端运营配置体验和积分风控报表。 ### P0:前端联调到云端前 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index f6588406..9152a995 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -22,8 +22,8 @@ - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。 -- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。后续补连续签到奖励配置、积分风控报表、活动效果看板和更细前端运营配置体验。 -- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台第一版。后续补连续签到奖励配置、积分风控报表和更细活动效果看板。 +- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。后续补积分风控报表、活动效果看板和更细前端运营配置体验。 +- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台第一版。后续补积分风控报表和更细活动效果看板。 - 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知;Taro 学生个人中心已接消息筛选、已读和归档第一版,租户营销中心已接用户通知查看和筛选第一版。后续补独立消息中心增强、外部微信订阅消息/短信和批量统计。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 - 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。 @@ -145,7 +145,7 @@ 9. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 - 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩自动发放。 - - 积分兑换、活动任务、优惠券兑换履约和站内通知后端第一阶段已完成;Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版;继续补连续签到奖励配置、积分风控报表、独立消息中心增强、外部订阅消息/短信和反馈聚合统计。 + - 积分兑换、活动任务、优惠券兑换履约和站内通知后端第一阶段已完成;Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版;继续补积分风控报表、独立消息中心增强、外部订阅消息/短信和反馈聚合统计。 10. 数据看板 - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index f9390ef1..2fe49ea9 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -1171,10 +1171,41 @@ POST /api/profile/check-in "item": { "checkedIn": true, "alreadyCheckedIn": false, - "pointsAdded": 10, + "pointsAdded": 17, "streak": 1, - "score": 10, + "score": 17, "lastCheckInDate": "2026-06-29", + "rewardMode": "configured_tasks", + "rewardBreakdown": [ + { + "taskId": "", + "taskCode": "daily-check-in", + "title": "每日签到基础奖励", + "points": 12, + "periodKey": "2026-06-29" + }, + { + "taskId": "", + "taskCode": "check-in-streak-day-one", + "title": "连续签到首日奖励", + "points": 5, + "periodKey": "2026-06-29" + } + ], + "ledgers": [ + { + "eventType": "check_in", + "sourceType": "point_activity_tasks", + "sourceId": "" + } + ], + "claims": [ + { + "taskId": "", + "periodKey": "2026-06-29", + "sourceType": "student_profiles" + } + ], "autoBadges": [ { "badgeId": "...", @@ -1194,6 +1225,8 @@ POST /api/profile/check-in - `alreadyCheckedIn=true` 时展示今日已签到,不要本地再加分。 - 积分明细调用 `GET /api/profile/score-events`。 - 积分最终余额以后端 `score` 和流水为准,前端只做展示。 +- 租户如配置了 `taskType=daily_check_in` 的积分任务,签到会返回 `rewardMode=configured_tasks` 和 `rewardBreakdown`;前端可展示每项奖励,但不要重新计算连续签到奖励。 +- 租户未配置每日签到任务时,后端会使用兼容旧逻辑的固定积分公式,并返回 `rewardMode=legacy_formula`。 - 如响应包含 `autoBadges`,可展示获得勋章弹层;不要在前端根据连续天数或积分自行判断勋章是否解锁。 积分活动任务和积分兑换入口: @@ -1229,7 +1262,7 @@ POST /api/profile/exchange-items/redeem - 积分任务领取、积分扣减、库存扣减、个人限购、优惠券兑换履约都由后端事务处理。 - `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` 和兑换列表;不要在本地先扣积分。 - 如果响应包含 `autoBadges`,按获得勋章提示处理;重复领取、库存不足、积分不足、限购等错误按后端 `code` 展示业务提示。 @@ -1248,6 +1281,32 @@ GET /api/tenant-admin/point-exchange-orders?itemId=&status=pending_fulf 当前 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 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 84f6f743..3a0e5dda 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -61,6 +61,8 @@ const ids = { tenantPracticeBadge: '00000000-0000-0000-0000-000000000881', tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882', tenantMockExamBadge: '00000000-0000-0000-0000-000000000883', + dailyCheckInTask: '00000000-0000-0000-0000-000000000884', + streakCheckInTask: '00000000-0000-0000-0000-000000000885', pointActivityTask: '00000000-0000-0000-0000-000000000877', pointSystemTask: '00000000-0000-0000-0000-000000000878', 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'); + 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' }); 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.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( checkIn.item?.autoBadges?.some(item => item.badgeId === ids.tenantCheckInBadge), '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?.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.deepEqual(duplicateCheckIn.item?.rewardBreakdown, [], 'duplicate check-in should not return reward tasks'); 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' && 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', { query: { includeLocked: 'true', category: 'activity', limit: 20 }, diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index e605ab1c..37a82892 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -78,6 +78,8 @@ const ids = { tenantPracticeBadge: '00000000-0000-0000-0000-000000000881', tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882', tenantMockExamBadge: '00000000-0000-0000-0000-000000000883', + dailyCheckInTask: '00000000-0000-0000-0000-000000000884', + streakCheckInTask: '00000000-0000-0000-0000-000000000885', pointActivityTask: '00000000-0000-0000-0000-000000000877', pointSystemTask: '00000000-0000-0000-0000-000000000878', pointExchangeItem: '00000000-0000-0000-0000-000000000879', @@ -280,7 +282,7 @@ async function main() { tenantId, ids.user, 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-%' ) `, - [tenantId, [ids.pointActivityTask, ids.pointSystemTask]], + [tenantId, [ids.pointActivityTask, ids.pointSystemTask, ids.dailyCheckInTask, ids.streakCheckInTask]], ); await client.query( @@ -1000,7 +1002,7 @@ async function main() { set region_id = $3, avatar_preset = 'male', last_check_in_date = null, - stats = stats - 'checkInStreak' - 'lastCheckInPoints', + stats = stats - 'checkInStreak' - 'lastCheckInPoints' - 'lastCheckInRewardMode' - 'checkInRewardBreakdown', updated_at = now() where tenant_id = $1 and user_id in ($2::uuid, $4::uuid)