From 78a26d1df2945d6adf212774705f61fce6f29b62 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 07:04:53 +0800 Subject: [PATCH] feat: manage tenant badges --- README.md | 12 +- apps/api/src/features/profile/index.ts | 2 + apps/api/src/features/profile/routes.ts | 58 ++++ apps/api/src/features/tenant-admin/auth.ts | 5 +- apps/api/src/features/tenant-admin/index.ts | 8 + apps/api/src/features/tenant-admin/routes.ts | 281 ++++++++++++++++++ docs/refactor/api-structure.md | 2 +- docs/refactor/backend-capability-status.md | 2 + docs/refactor/backend-handoff-roadmap.md | 12 +- docs/refactor/backend-progress.md | 15 +- docs/refactor/blueprint-coverage.md | 2 +- docs/refactor/frontend-handoff-index.md | 5 +- docs/refactor/implementation-status.md | 25 +- docs/refactor/legacy-feature-gap-matrix.md | 7 +- docs/refactor/next-development-todo.md | 10 +- docs/refactor/project-structure.md | 4 +- docs/refactor/taro-frontend-integration.md | 31 +- scripts/api-integration-test.js | 178 +++++++++++ ...202606290013_badge_management_metadata.sql | 29 ++ 19 files changed, 650 insertions(+), 38 deletions(-) create mode 100644 supabase/migrations/202606290013_badge_management_metadata.sql diff --git a/README.md b/README.md index 9621d504..6c4d0c20 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 +- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON 批量导入。 -- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 +- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 - `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源。 @@ -29,7 +29,7 @@ - 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;微信网页登录、QQ 登录、手机号换绑、完整资金流水对账和真实生产账号联调还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览和资源复检 worker 已完成,CDN 防盗链、杀毒扫描和视频动态水印还没完成。 - Excel/CSV 导入和大批量异步导入 worker 还没完成;分数线/视频 JSON 批量导入已可联调。 -- 分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。 +- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。 - Taro 跨端前端还没开始 scaffold。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 @@ -155,19 +155,19 @@ npm run test:worker:assets ```text apps/api/src/features/ - auth/ 短信登录、迁移期 session、OAuth 占位 + auth/ 短信登录、迁移期 session、微信小程序登录、OAuth 占位 catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口 commerce/ 订单、支付确认、激活码、优惠券、权益 health/ 健康检查 learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜 platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量 - profile/ 学生个人中心 + profile/ 学生个人中心、勋章 referral/ 销售/代理客资追踪、CRM 队列 referral/commission.ts 分佣设置、汇总、来源明细、结算单、审核/打款状态 scoreline/ 分数线 tenant/ 租户解析 - tenant-admin/ 租户后台配置、成员权限、班级学生、活动和审计 + tenant-admin/ 租户后台配置、成员权限、班级学生、活动、勋章和审计 tenant-content/ 租户内容导航、题库维护、资源管理、批量导入 video/ 题目视频讲解 ``` diff --git a/apps/api/src/features/profile/index.ts b/apps/api/src/features/profile/index.ts index e3236d6a..e8f920f9 100644 --- a/apps/api/src/features/profile/index.ts +++ b/apps/api/src/features/profile/index.ts @@ -3,6 +3,7 @@ import { checkInRoute, examCountdownRoute, feedbacksRoute, + profileBadgesRoute, profileMeRoute, scoreEventsRoute, submitFeedbackRoute, @@ -14,6 +15,7 @@ export const profileRoutes: RouteDefinition[] = [ ['PATCH', '/api/profile/me', updateProfileMeRoute], ['POST', '/api/profile/check-in', checkInRoute], ['GET', '/api/profile/score-events', scoreEventsRoute], + ['GET', '/api/profile/badges', profileBadgesRoute], ['GET', '/api/profile/feedbacks', feedbacksRoute], ['POST', '/api/profile/feedbacks', submitFeedbackRoute], ['GET', '/api/profile/exam-countdowns', examCountdownRoute], diff --git a/apps/api/src/features/profile/routes.ts b/apps/api/src/features/profile/routes.ts index e311a6cf..24036d92 100644 --- a/apps/api/src/features/profile/routes.ts +++ b/apps/api/src/features/profile/routes.ts @@ -72,6 +72,7 @@ function daysBetween(dateValue: unknown, today: Date) { } const FEEDBACK_TYPES = ['question_error', 'content_error', 'video_error', 'asset_error', 'system_bug', 'suggestion', 'other']; +const BADGE_CATEGORIES = ['learning', 'practice', 'vocabulary', 'mock_exam', 'activity', 'feedback', 'sales', 'system', 'custom']; export async function profileMeRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); @@ -430,6 +431,63 @@ export async function scoreEventsRoute(ctx: RequestContext) { return { items }; } +export async function profileBadgesRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const limit = intParam(ctx, 'limit', 100, 300); + const includeLocked = ctx.url.searchParams.get('includeLocked') === 'true'; + const category = stringParam(ctx, 'category'); + const params: unknown[] = [tenantId, userId]; + const filters = ['b.tenant_id = $1', 'b.is_active = true']; + if (category) { + if (!BADGE_CATEGORIES.includes(category)) { + throw new HttpError(400, `Invalid badge category: ${category}`, 'INVALID_BADGE_CATEGORY'); + } + params.push(category); + filters.push(`b.category = $${params.length}`); + } + if (!includeLocked) { + filters.push('ub.id is not null'); + } + params.push(limit); + + const items = await query<{ unlocked: boolean } & Record>( + ` + select b.id as "badgeId", b.legacy_id as "legacyId", b.name, + b.description, b.category, b.icon_url as "iconUrl", b.level, + b.unlock_type as "unlockType", b.condition_field as "conditionField", + b.condition_operator as "conditionOperator", b.condition_value as "conditionValue", + b.condition_extra as "conditionExtra", b.metadata, + b.sort_order as "order", + ub.id as "grantId", ub.note, ub.metadata as "grantMetadata", + ub.granted_at as "grantedAt", + (ub.id is not null) as unlocked + from public.badges b + left join public.user_badges ub + on ub.tenant_id = b.tenant_id + and ub.badge_id = b.id + and ub.user_id = $2 + where ${filters.join(' and ')} + order by (ub.id is not null) desc, + coalesce(ub.granted_at, ub.created_at) desc nulls last, + b.sort_order asc, + b.level asc nulls last, + b.created_at desc + limit $${params.length} + `, + params, + ); + + return { + items, + summary: { + total: items.length, + unlocked: items.filter(item => item.unlocked).length, + includeLocked, + }, + }; +} + export async function feedbacksRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 84c249c4..989d62a0 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -13,7 +13,7 @@ const TENANT_ADMIN_ROLES = new Set([ const ROLE_PERMISSION_DEFAULTS: Record = { tenant_owner: ['*'], tenant_admin: ['*'], - tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'], + tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'badges:*', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'], teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'], sales: ['codes:*', 'coupons:*', 'referral:*', 'commission:self'], agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'], @@ -97,6 +97,9 @@ export function tenantPermissionCatalog() { { key: 'tenant:secrets:write', label: '密钥轮换' }, { key: 'marketing:read', label: '活动内容查看' }, { key: 'marketing:write', label: '活动内容管理' }, + { key: 'badges:read', label: '勋章查看' }, + { key: 'badges:write', label: '勋章管理' }, + { key: 'badges:grant', label: '勋章发放' }, { key: 'codes:read', label: '激活码查看' }, { key: 'codes:write', label: '激活码管理' }, { key: 'coupons:read', label: '优惠券查看' }, diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index d79b5fe6..f69ee8af 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -29,6 +29,8 @@ import { activationCodesRoute, announcementsAdminRoute, auditLogsRoute, + badgeGrantsRoute, + badgesRoute, authProvidersRoute, bannersAdminRoute, codeBatchesRoute, @@ -37,6 +39,7 @@ import { disableTenantRoleTemplateRoute, disableTenantMemberRoute, faqsAdminRoute, + grantBadgeRoute, generateActivationCodesRoute, paymentAccountsRoute, tenantDomainsRoute, @@ -50,6 +53,7 @@ import { upsertActivationCodeRoute, upsertAnnouncementRoute, upsertAuthProviderRoute, + upsertBadgeRoute, upsertBannerRoute, upsertCodeBatchRoute, upsertCouponRoute, @@ -99,6 +103,10 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['PUT', '/api/tenant-admin/faqs', upsertFaqRoute], ['GET', '/api/tenant-admin/announcements', announcementsAdminRoute], ['PUT', '/api/tenant-admin/announcements', upsertAnnouncementRoute], + ['GET', '/api/tenant-admin/badges', badgesRoute], + ['PUT', '/api/tenant-admin/badges', upsertBadgeRoute], + ['GET', '/api/tenant-admin/badge-grants', badgeGrantsRoute], + ['POST', '/api/tenant-admin/badge-grants', grantBadgeRoute], ['GET', '/api/tenant-admin/exam-dates', tenantExamDatesRoute], ['PUT', '/api/tenant-admin/exam-dates', upsertTenantExamDateRoute], ['GET', '/api/tenant-admin/feedbacks', tenantFeedbacksRoute], diff --git a/apps/api/src/features/tenant-admin/routes.ts b/apps/api/src/features/tenant-admin/routes.ts index f7495b1f..9c0c93cb 100644 --- a/apps/api/src/features/tenant-admin/routes.ts +++ b/apps/api/src/features/tenant-admin/routes.ts @@ -21,6 +21,9 @@ const DISCOUNT_TYPES = ['percent', 'fixed']; const TENANT_MEMBER_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student']; const TENANT_MEMBER_STATUSES = ['active', 'invited', 'disabled']; const ROLE_TEMPLATE_STATUSES = ['active', 'disabled', 'archived']; +const BADGE_CATEGORIES = ['learning', 'practice', 'vocabulary', 'mock_exam', 'activity', 'feedback', 'sales', 'system', 'custom']; +const BADGE_UNLOCK_TYPES = ['manual', 'auto', 'score', 'check_in', 'practice_count', 'vocabulary_mastered', 'mock_exam_score', 'feedback_resolved', 'custom']; +const BADGE_OPERATORS = ['gte', 'lte', 'eq', 'gt', 'lt']; function jsonBodyValue(value: unknown) { return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {}); @@ -34,6 +37,25 @@ function nullableString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : null; } +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function optionalUuidString(value: unknown, key: string) { + const candidate = nullableString(value); + if (!candidate) return null; + if (!UUID_PATTERN.test(candidate)) { + throw new HttpError(400, `${key} must be a UUID`, 'INVALID_UUID'); + } + return candidate; +} + +function requiredUuidString(body: JsonBody, key: string) { + const candidate = optionalUuidString(body[key], key); + if (!candidate) { + throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD'); + } + return candidate; +} + function boolValue(value: unknown, fallback: boolean) { return typeof value === 'boolean' ? value : fallback; } @@ -68,6 +90,15 @@ function objectValue(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } +function optionalNumberValue(value: unknown) { + if (value === undefined || value === null || value === '') return null; + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new HttpError(400, 'Numeric field is invalid', 'INVALID_NUMBER'); + } + return parsed; +} + function assertPublicConfigHasNoSecrets(value: unknown, path = 'configPublic') { if (!value || typeof value !== 'object') return; @@ -1148,6 +1179,256 @@ export async function upsertAnnouncementRoute(ctx: RequestContext) { return { item }; } +export async function badgesRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'badges:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const category = stringParam(ctx, 'category'); + const includeInactive = ctx.url.searchParams.get('includeInactive') === 'true'; + const params: unknown[] = [auth.tenantId]; + const filters = ['tenant_id = $1']; + if (category) { + if (!BADGE_CATEGORIES.includes(category)) { + throw new HttpError(400, `Invalid badge category: ${category}`, 'INVALID_BADGE_CATEGORY'); + } + params.push(category); + filters.push(`category = $${params.length}`); + } + if (!includeInactive) { + filters.push('is_active = true'); + } + params.push(limit); + + const items = await query( + ` + select id, legacy_id as "legacyId", name, description, category, + icon_url as "iconUrl", level, unlock_type as "unlockType", + condition_field as "conditionField", condition_operator as "conditionOperator", + condition_value as "conditionValue", condition_extra as "conditionExtra", + metadata, sort_order as "order", is_active as "isActive", + created_at as "createdAt", updated_at as "updatedAt" + from public.badges + where ${filters.join(' and ')} + order by sort_order asc, level asc nulls last, created_at desc + limit $${params.length} + `, + params, + ); + + return { items }; +} + +export async function upsertBadgeRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'badges:write'); + const body = await readJsonBody(ctx); + const category = optionalChoice(body.category, BADGE_CATEGORIES, 'custom'); + const unlockType = optionalChoice(body.unlockType, BADGE_UNLOCK_TYPES, 'manual'); + const conditionOperator = body.conditionOperator === undefined || body.conditionOperator === null || body.conditionOperator === '' + ? null + : optionalChoice(body.conditionOperator, BADGE_OPERATORS, 'gte'); + const item = await transaction(async client => { + const requestedId = optionalUuidString(body.id, 'id'); + const legacyId = nullableString(body.legacyId); + const existing = await client.query<{ id: string }>( + ` + select id + from public.badges + where tenant_id = $1 + and ( + ($2::uuid is not null and id = $2::uuid) + or ($3::text is not null and legacy_id = $3) + ) + order by case when $2::uuid is not null and id = $2::uuid then 0 else 1 end + limit 2 + `, + [auth.tenantId, requestedId, legacyId], + ); + if ((existing.rowCount || 0) > 1) { + throw new HttpError(409, 'Badge id and legacyId point to different records', 'BADGE_ID_CONFLICT'); + } + if (requestedId && existing.rows[0]?.id && existing.rows[0].id !== requestedId) { + throw new HttpError(409, 'Badge legacyId already belongs to another record', 'BADGE_ID_CONFLICT'); + } + + const result = await client.query( + ` + insert into public.badges ( + id, tenant_id, legacy_id, name, description, category, icon_url, + level, unlock_type, condition_field, condition_operator, + condition_value, condition_extra, metadata, sort_order, is_active + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13::jsonb, $14::jsonb, $15, $16 + ) + on conflict (id) + do update set legacy_id = coalesce(excluded.legacy_id, public.badges.legacy_id), + name = excluded.name, + description = excluded.description, + category = excluded.category, + icon_url = excluded.icon_url, + level = excluded.level, + unlock_type = excluded.unlock_type, + condition_field = excluded.condition_field, + condition_operator = excluded.condition_operator, + condition_value = excluded.condition_value, + condition_extra = excluded.condition_extra, + metadata = excluded.metadata, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = now() + where public.badges.tenant_id = excluded.tenant_id + returning id, legacy_id as "legacyId", name, description, category, + icon_url as "iconUrl", level, unlock_type as "unlockType", + condition_field as "conditionField", condition_operator as "conditionOperator", + condition_value as "conditionValue", condition_extra as "conditionExtra", + metadata, sort_order as "order", is_active as "isActive", + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + existing.rows[0]?.id || requestedId, + legacyId, + requiredString(body, 'name'), + nullableString(body.description), + category, + nullableString(body.iconUrl), + body.level === undefined ? null : intValue(body.level, 0), + unlockType, + nullableString(body.conditionField), + conditionOperator, + optionalNumberValue(body.conditionValue), + jsonBodyValue(body.conditionExtra), + jsonBodyValue(body.metadata), + intValue(body.order, 0), + boolValue(body.isActive, true), + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Badge not found for this tenant', 'BADGE_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.badge.upserted', 'badges', result.rows[0].id, { + name: result.rows[0].name, + category, + unlockType, + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function badgeGrantsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'badges:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const userId = optionalUuidString(stringParam(ctx, 'userId'), 'userId'); + const badgeId = optionalUuidString(stringParam(ctx, 'badgeId'), 'badgeId'); + const params: unknown[] = [auth.tenantId]; + const filters = ['ub.tenant_id = $1']; + if (userId) { + params.push(userId); + filters.push(`ub.user_id = $${params.length}::uuid`); + } + if (badgeId) { + params.push(badgeId); + filters.push(`ub.badge_id = $${params.length}::uuid`); + } + params.push(limit); + + const items = await query( + ` + select ub.id, ub.legacy_id as "legacyId", ub.user_id as "userId", + u.name as "userName", u.phone as "userPhone", u.avatar_url as "userAvatarUrl", + ub.badge_id as "badgeId", b.name as "badgeName", b.category, + b.icon_url as "iconUrl", b.level, ub.granted_by as "grantedBy", + gu.name as "grantedByName", ub.note, ub.metadata, + ub.granted_at as "grantedAt", ub.created_at as "createdAt", + ub.updated_at as "updatedAt" + from public.user_badges ub + join public.badges b on b.tenant_id = ub.tenant_id and b.id = ub.badge_id + join public.platform_users u on u.id = ub.user_id + left join public.platform_users gu on gu.id = ub.granted_by + where ${filters.join(' and ')} + order by ub.granted_at desc nulls last, ub.created_at desc + limit $${params.length} + `, + params, + ); + + return { items }; +} + +export async function grantBadgeRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'badges:grant'); + const body = await readJsonBody(ctx); + const badgeId = requiredUuidString(body, 'badgeId'); + const userId = requiredUuidString(body, 'userId'); + + const item = await transaction(async client => { + const badge = await client.query<{ id: string; is_active: boolean; name: string }>( + 'select id, is_active, name from public.badges where tenant_id = $1 and id = $2 limit 1', + [auth.tenantId, badgeId], + ); + if (!badge.rows[0]) throw new HttpError(404, 'Badge not found for this tenant', 'BADGE_NOT_FOUND'); + if (!badge.rows[0].is_active) throw new HttpError(409, 'Cannot grant inactive badge', 'BADGE_INACTIVE'); + + const member = await client.query<{ id: string }>( + ` + select tm.id + from public.tenant_memberships tm + where tm.tenant_id = $1 + and tm.user_id = $2 + and tm.status = 'active' + limit 1 + `, + [auth.tenantId, userId], + ); + if (!member.rows[0]) throw new HttpError(400, 'Badge target user is not an active member of this tenant', 'BADGE_TARGET_NOT_IN_TENANT'); + + const legacyId = nullableString(body.legacyId) || `badge:${badgeId}:user:${userId}`; + const result = await client.query( + ` + insert into public.user_badges ( + tenant_id, user_id, badge_id, granted_by, legacy_id, + note, metadata, granted_at + ) + values ($1, $2, $3, $4, $5, $6, $7::jsonb, coalesce($8::timestamptz, now())) + on conflict (tenant_id, user_id, badge_id) + do update set note = coalesce(excluded.note, public.user_badges.note), + metadata = public.user_badges.metadata || excluded.metadata, + granted_by = coalesce(public.user_badges.granted_by, excluded.granted_by), + granted_at = coalesce(public.user_badges.granted_at, excluded.granted_at), + updated_at = now() + returning id, legacy_id as "legacyId", user_id as "userId", + badge_id as "badgeId", granted_by as "grantedBy", + note, metadata, granted_at as "grantedAt", + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + userId, + badgeId, + auth.userId, + legacyId, + nullableString(body.note), + JSON.stringify({ source: 'tenant_admin', ...objectValue(body.metadata) }), + nullableString(body.grantedAt), + ], + ); + + await recordAudit(client, auth, 'tenant.badge.granted', 'user_badges', result.rows[0].id, { + userId, + badgeId, + badgeName: badge.rows[0].name, + }); + + return result.rows[0]; + }); + + return { item }; +} + export async function codeBatchesRoute(ctx: RequestContext) { const auth = await requireTenantAdmin(ctx); requireTenantPermission(auth, 'codes:read'); diff --git a/docs/refactor/api-structure.md b/docs/refactor/api-structure.md index 25d696e6..b17b3a9b 100644 --- a/docs/refactor/api-structure.md +++ b/docs/refactor/api-structure.md @@ -13,7 +13,7 @@ apps/api/src/ http.ts CORS、JSON 响应、统一错误 router.ts 汇总注册各业务域路由 features/ - auth/ 短信验证码、迁移期 session、OAuth provider 预留 + auth/ 短信验证码、迁移期 session、微信小程序登录、OAuth provider 预留 health/ 健康检查 tenant/ 租户解析、品牌配置、域名识别 catalog/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 7be00c15..6838bd45 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -67,6 +67,7 @@ | 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates`、`GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` | | 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转 | | 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分 | +| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态 | ## 背单词、知识手册、分数线、视频 @@ -124,6 +125,7 @@ | 登录 provider 配置 | 可联调 | `/api/tenant-admin/auth-providers` | | 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption | | 活动、Banner、FAQ、公告 | 可联调 | `/api/tenant-admin/banners`、`faqs`、`announcements` | +| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges`、`/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant` | | 考试日期维护 | 可联调 | `/api/tenant-admin/exam-dates`,支持地区维度维护和公开倒计时展示 | | 题目反馈处理 | 可联调 | `/api/tenant-admin/feedbacks`、`feedbacks/status`、`feedbacks/events`;支持状态流转、处理备注、审计事件和幂等奖励积分 | | 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches`、`activation-codes` | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index fd236045..95635d13 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -10,11 +10,11 @@ - 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。 - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册和资料资源。 -- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。 +- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。 - 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 -因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实鉴权、对象存储、支付、短信、微信/QQ 登录、异步 worker、真实数据 dry-run 迁移仍需要继续补齐。 +因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信生产账号、微信网页登录/QQ 登录、真实数据 dry-run 迁移和大批量异步导入仍需要继续补齐或联调。 ## 后端模块进度 @@ -22,7 +22,7 @@ | --- | --- | --- | --- | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 | | 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权 | 自动计费、平台审计、公共题库版本同步 | -| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、成员权限、角色模板、菜单/模块/字段权限配置 API | 前端权限 UI、班级/教师/学生范围权限 | +| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限 | 前端权限 UI、更细的数据范围组合 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照 | 专项策略、公共题库版本同步、Excel 导入、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON 导入、排行榜 | Excel 导入、更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON 导入 | 富文本资源、版本管理、附件/PDF 关联 | @@ -30,10 +30,10 @@ | 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 | | 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架 | PDF 渲染、CDN 防盗链、杀毒扫描、视频水印 | | 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 | -| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth | +| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、OAuth 配置表 | 微信网页登录、QQ 登录、手机号换绑、真实生产账号联调 | | 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker | | 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON preview/import、issue、job、审计、幂等 | Excel/CSV 解析、大批量异步 worker、导入后复检 | -| 数据看板 | 数据基础具备 | 日统计/收入统计表和部分旧脚本 | 新 API 聚合、24h 活跃、运营动态、题型/科目/套餐看板 | +| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 | | Taro 前端 | 未开始 | 旧 Web 已有新 API 适配雏形 | `apps/taro`、跨端 API client、H5/小程序页面和端到端测试 | @@ -65,7 +65,7 @@ 5. 个人中心 - 调 `GET /api/profile/me`。 - - 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计和排行榜。 + - 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计、排行榜和勋章。 6. 资料、视频和支付 - 资料下载、PDF 预览、视频播放必须先请求后端签名或权限检查。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 6dd8a32b..9eb9a4de 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -5,23 +5,23 @@ - Docker Desktop + Supabase local 已可用。 - API Docker 镜像 `tiku-saas-dev-api:latest` 已可构建,并可从容器连接宿主 Supabase PostgreSQL。 - API 已按 `core/features` 分层: - - `auth`:短信验证码登录、迁移期 session、OAuth provider 预留。 + - `auth`:短信验证码登录、迁移期 session、微信小程序登录、OAuth provider 预留。 - `catalog`:公开题库、地区、内容入口、分类树、题目集合、练习蓝图、考试日期、手册、商品、SVIP 套餐、资料资源只读/下载接口。 - `learning`:顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计、排行榜。 - - `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈。 + - `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章。 - `scoreline`:分数线字段、院校、专业、记录、趋势、年份。 - `video`:题目视频讲解、批量预加载、通用视频搜索。 - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、权益查询。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。 - - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 + - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant`:域名/租户解析。 - 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 - 租户自定义角色模板已落库:`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` 支持每日签到积分流水和反馈奖励幂等。 +- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转;`user_score_events` 支持每日签到积分流水和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示。 - 旧题库排行榜主链路已补齐:`GET /api/learning/leaderboard` 支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 @@ -82,6 +82,7 @@ GET /api/profile/me PATCH /api/profile/me POST /api/profile/check-in GET /api/profile/score-events +GET /api/profile/badges GET /api/profile/feedbacks POST /api/profile/feedbacks GET /api/profile/exam-countdowns @@ -205,6 +206,10 @@ GET /api/tenant-admin/faqs PUT /api/tenant-admin/faqs GET /api/tenant-admin/announcements PUT /api/tenant-admin/announcements +GET /api/tenant-admin/badges +PUT /api/tenant-admin/badges +GET /api/tenant-admin/badge-grants +POST /api/tenant-admin/badge-grants GET /api/tenant-admin/exam-dates PUT /api/tenant-admin/exam-dates GET /api/tenant-admin/feedbacks @@ -238,7 +243,7 @@ GET /api/tenant-admin/audit-logs - `/api/commerce/refunds`、`/api/commerce/refunds/status` 和 `/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook,全额退款成功会撤销订单来源权益;`apps/worker --job commerce` 会定时补偿查询支付和退款状态,完整资金流水对账后续接入。 - 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。 - `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。 -- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。 +- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容、活动和勋章,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。 - 班级学生 API 会按 `tenant_memberships.role_template_id -> tenant_role_templates.data_scope`、成员显式权限和 `tenant_class_members` 共同确定可见范围;非全局权限教师只能查看自己负责班级的学生。 - 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。 - 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 06046f44..ded7faf9 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 复检 | CDN 防盗链、杀毒扫描、资料前端管理页 | -| 营销中心 | 基础完成 | 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 6b69d78a..017f141e 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -26,7 +26,7 @@ ## 当前可进入的前端工作 - 可以开始搭建 `apps/taro`。 -- 可以复刻旧题库学生端主要视觉和交互:登录、选地区、首页、刷题、背单词、知识手册、分数线、资料、商城、个人中心。 +- 可以复刻旧题库学生端主要视觉和交互:登录、选地区、首页、刷题、背单词、知识手册、分数线、资料、商城、个人中心、勋章。 - 可以按新后端主模型接入内容导航: - `content_entries` - `content_nodes` @@ -37,6 +37,7 @@ - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。 - 可以接入租户品牌、主题、功能开关和域名/小程序参数解析。 - 租户后台可以接入角色模板 API:`/api/tenant-admin/role-templates`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性。 +- 租户后台可以接入勋章管理和手动发放:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`;学生端用 `GET /api/profile/badges` 展示成就。 ## 不能误认为已商用完成的部分 @@ -45,7 +46,7 @@ - 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。 - 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker,但 CDN 防盗链、视频水印和杀毒扫描还要补。 - 分数线/视频 JSON 导入已可联调;大批量 Excel/CSV 解析和异步导入 worker 还未完成。 -- 数据看板和分佣结算基础 API 已可联调;分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。 +- 数据看板、分佣结算和勋章手动发放基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。 ## 前后端协作建议 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 8a53fbe2..a332cfe4 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -12,12 +12,12 @@ | 范围 | 路径 | 状态 | | --- | --- | --- | -| 旧 PocketBase/React 项目 | `F:\project\src`、`F:\project\pb_hooks`、`F:\project\pb_migrations` | 保留作为功能参照和迁移来源 | +| 旧 PocketBase/React 项目 | `F:\project\参考\旧题库项目` | 保留作为功能参照和迁移来源 | | 新 Node API | `F:\project\apps\api` | 已按 `core/features` 分层重构 | | Supabase/PostgreSQL 迁移 | `F:\project\supabase\migrations` | 已建立多租户和业务域表 | | PocketBase 数据导入 | `F:\project\scripts\import-pocketbase` | 已支持多类旧数据归一化导入与校验 | | 共享包 | `F:\project\packages\config`、`F:\project\packages\db`、`F:\project\packages\domain` | 已建立基础共享层 | -| 旧 Web 到新 API 适配 | `F:\project\src\services\supabaseApi.ts` | 已开始抽象,后续应迁到 Taro 共享 API 包 | +| 旧 Web 到新 API 适配 | `F:\project\参考\旧题库项目\src\services\supabaseApi.ts` | 仅作参考;后续应在 `apps/taro` 或共享包中建立新的跨端 API client | ## 功能完成度 @@ -33,8 +33,8 @@ | 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 | | 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放和导入断言 | 播放、权益、后台绑定和批量 JSON 导入链路已实现,深度防盗链、动态水印和播放统计待补 | | 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载,assets worker 测试 | 资料资源基础闭环可跑,CDN 防盗链、杀毒扫描和资料下载前端待补 | -| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录 | 已支持部分用户资料导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合 API 已实现 | 核心 API 烟测 | 学生端基础个人中心已实现,签到/任务/更细统计待补 | -| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣已实现 | 核心 API 集成测试 | 基础运营后台可用,复杂活动规则、营销自动化、核销报表待补 | +| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,账号绑定/换绑、学习报告可视化和更细任务系统待补 | +| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel/CSV 导入和大批量异步导入待补 | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 | @@ -102,6 +102,12 @@ learning: profile: GET /api/profile/me PATCH /api/profile/me + POST /api/profile/check-in + GET /api/profile/score-events + GET /api/profile/badges + GET /api/profile/feedbacks + POST /api/profile/feedbacks + GET /api/profile/exam-countdowns scoreline: GET /api/scoreline/fields @@ -217,6 +223,15 @@ tenant-admin: PUT /api/tenant-admin/faqs GET /api/tenant-admin/announcements PUT /api/tenant-admin/announcements + GET /api/tenant-admin/badges + PUT /api/tenant-admin/badges + GET /api/tenant-admin/badge-grants + POST /api/tenant-admin/badge-grants + GET /api/tenant-admin/exam-dates + PUT /api/tenant-admin/exam-dates + GET /api/tenant-admin/feedbacks + POST /api/tenant-admin/feedbacks/status + GET /api/tenant-admin/feedbacks/events GET /api/tenant-admin/code-batches PUT /api/tenant-admin/code-batches GET /api/tenant-admin/activation-codes @@ -252,7 +267,7 @@ platform-admin: 1. 正式鉴权:API 已支持 Supabase Auth JWT;生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id`、`x-platform-admin-key` 兼容入口。 2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;微信网页登录、QQ 登录、真实生产账号联调、对账和支付补偿仍需实现。 -3. 核心缺口 API:学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API;下一步重点是后台维护、权限、统计和真实业务验收。 +3. 核心缺口 API:学生端个人中心、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API;下一步重点是账号绑定、学习报告可视化、后台统计和真实业务验收。 4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel/CSV 导入、大批量异步导入和前端操作台待补。 5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。 6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 1c79ea4a..a060d218 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -31,7 +31,7 @@ | 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 | | 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker 和全额退款权益撤销已有;缺完整资金流水对账、异常订单运营台和前端收银台/售后体验 | -| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 | +| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺账号绑定/换绑、学习报告可视化 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 | | AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 | | 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 | @@ -57,7 +57,7 @@ | SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI | | 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券幂等、下单抵扣、全额优惠自动开通权益已有;复杂活动规则和核销报表待补 | | 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 | -| 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges;缺后台和学生端 API | +| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 | | 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON 导入、集合/蓝图已有;Excel/CSV 和异步大批量导入待补 | | 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定服务端导出、导出水印和权限审计 | | 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 | @@ -103,6 +103,7 @@ 6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。 7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 +9. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。 ### P0:前端联调到云端前 @@ -110,7 +111,7 @@ 2. 生产配置 fail-fast:默认密钥、`CORS=*`、mock SMS、平台默认 key 必须禁止。 3. JSON body size limit:导入接口可配置更大限制,但必须有上限。 4. 真实租户隔离测试:跨租户读写、角色越权、资源下载越权。 -5. 真实短信/微信小程序登录最小闭环。 +5. 真实短信/微信小程序登录生产账号联调。 ### P1:商用主链路 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 1cd36a98..e26932af 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -8,8 +8,8 @@ - Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。 - Node.js API 分层:`core/features`。 -- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈。 -- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 +- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章。 +- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 @@ -20,6 +20,7 @@ - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 +- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 - 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session。 - 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。 @@ -100,7 +101,8 @@ 8. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 - - 继续补积分兑换、活动任务、连续签到奖励配置、处理通知和反馈聚合统计。 + - 已完成勋章后台维护、手动发放和学生端展示。 + - 继续补积分兑换、活动任务、连续签到奖励配置、勋章自动发放、处理通知和反馈聚合统计。 9. 数据看板 - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 @@ -189,7 +191,7 @@ 8. 个人中心 - 会员权益、订单、激活码兑换 - - 错题本、收藏夹、学习统计、排行榜 + - 错题本、收藏夹、学习统计、排行榜、勋章 ### 前端接入原则 diff --git a/docs/refactor/project-structure.md b/docs/refactor/project-structure.md index 5d570e12..d1e4f4d9 100644 --- a/docs/refactor/project-structure.md +++ b/docs/refactor/project-structure.md @@ -20,13 +20,13 @@ F:\project src/ core/ 配置、HTTP、路由、数据库访问 features/ 业务模块 - auth/ 迁移期登录、短信 mock、OAuth 占位 + auth/ 迁移期登录、短信、微信小程序登录、OAuth 占位 catalog/ 学生端目录、题库、资料、商品只读接口 commerce/ 订单、支付确认、激活码、权益 health/ 健康检查 learning/ 练习、答题、错题、收藏、单词进度 platform-admin/ 平台租户、套餐、账单、用量 - profile/ 学生个人中心 + profile/ 学生个人中心、勋章 referral/ 销售/代理/CRM 增长链路 scoreline/ 分数线 storage/ 对象存储签名 provider diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 02bdf0f0..a9e08135 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -172,7 +172,7 @@ tenant::theme | 商城 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` | | 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` | | 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` | -| 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns` | +| 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns`、`GET /api/profile/badges` | | 销售分享 | `/api/referral/resolve`、`track-event`、`bind` | | 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d®ionId=...` | | 租户班级 | `GET/PUT /api/tenant-admin/classes`、`POST /api/tenant-admin/classes/disable` | @@ -183,6 +183,7 @@ tenant::theme | 租户教师 | `GET /api/tenant-admin/teachers` | | 租户考试日期 | `GET/PUT /api/tenant-admin/exam-dates` | | 租户反馈处理 | `GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` | +| 租户勋章 | `GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` | | 公共题库采纳 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt` | ## 练习访问控制契约 @@ -230,6 +231,31 @@ tenant::theme - `PRACTICE_SESSION_QUESTION_FORBIDDEN`:说明提交答案的题目不在本次 session 快照内,应清理本地异常进度并重新开始。 - 提交答案必须传 `practiceSessionId`;后端会拒绝不属于本人有效 session 的题目。 +## 勋章 + +学生个人中心或学习成就页调用: + +```text +GET /api/profile/badges?includeLocked=true&category=practice +``` + +说明: + +- `includeLocked=true` 时返回已解锁和未解锁勋章;不传时只返回已解锁。 +- `category` 可选:`learning`、`practice`、`vocabulary`、`mock_exam`、`activity`、`feedback`、`sales`、`system`、`custom`。 +- 前端只展示后端返回的 `unlocked/grantId/grantedAt`,不要在本地自行认定用户已经获得勋章。 + +租户后台勋章管理: + +```text +GET /api/tenant-admin/badges?category=practice&includeInactive=true +PUT /api/tenant-admin/badges +GET /api/tenant-admin/badge-grants?userId=...&badgeId=... +POST /api/tenant-admin/badge-grants +``` + +`PUT /api/tenant-admin/badges` 支持同租户内 `legacyId` 幂等更新;如果 `id` 与 `legacyId` 指向不同记录会返回 `BADGE_ID_CONFLICT`。`POST /api/tenant-admin/badge-grants` 对同一用户同一勋章幂等,不会重复生成多条发放记录。当前后端支持手动发放,自动发放规则后续由 worker/事件流补齐。 + ### 模考交卷与报告 全真模拟、试卷模式、顺序练习的最终报告都走后端交卷接口。前端不得传分数、正确数或题目范围;后端只信任 `practice_sessions.question_ids` 快照和 `answer_records` 最新答题记录。 @@ -1040,7 +1066,7 @@ ACTIVATION_CODE_REGION_MISMATCH 9. `pages/scoreline/index` - 动态字段筛选和趋势。 10. `pages/profile/index` - - 会员、订单、激活码、学习数据。 + - 会员、订单、激活码、学习数据、勋章。 ## 租户后台前端建议 @@ -1057,6 +1083,7 @@ ACTIVATION_CODE_REGION_MISMATCH - 题目/单词/知识手册/分数线/视频维护 - 题目/单词/知识手册/分数线/视频 JSON 导入 preview/import/issues - Banner/FAQ/公告/激活码/优惠券 +- 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` - 考试日期:`GET/PUT /api/tenant-admin/exam-dates` - 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` - 销售/代理/CRM 队列 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index b2910c51..d15d6ada 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -49,6 +49,7 @@ const ids = { tenantClassOther: '00000000-0000-0000-0000-000000000852', examDate: '00000000-0000-0000-0000-000000000861', tenantExamDate: '00000000-0000-0000-0000-000000000862', + tenantBadge: '00000000-0000-0000-0000-000000000871', questionBank: '00000000-0000-0000-0000-000000000400', publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906', }; @@ -3562,6 +3563,181 @@ async function testTenantAdminOps() { }); assert.ok(publicExamDates.items?.some(item => item.id === ids.tenantExamDate), 'public catalog should expose tenant admin exam date'); + const studentBadgeAdminDenied = await request('/api/tenant-admin/badges', { + expectStatus: 403, + }); + assert.equal(studentBadgeAdminDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access badge admin'); + + const badge = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantBadge, + legacyId: 'integration-badge-first-practice', + name: '集成测试首练勋章', + description: '完成首次练习后展示', + category: 'practice', + iconUrl: 'https://example.test/badges/first-practice.png', + level: 1, + unlockType: 'manual', + conditionField: 'practice.completedCount', + conditionOperator: 'gte', + conditionValue: 1, + conditionExtra: { mode: 'any' }, + metadata: { source: 'integration-test' }, + order: 3, + isActive: true, + }, + }); + assert.equal(badge.item?.id, ids.tenantBadge, 'tenant admin should create badge with stable id'); + assert.equal(badge.item?.category, 'practice', 'badge should persist category'); + + const badgeByLegacy = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + legacyId: 'integration-badge-first-practice', + name: '集成测试首练勋章升级', + description: '同 legacyId 应幂等更新原勋章', + category: 'practice', + iconUrl: 'https://example.test/badges/first-practice-v2.png', + level: 2, + unlockType: 'manual', + conditionField: 'practice.completedCount', + conditionOperator: 'gte', + conditionValue: 1, + conditionExtra: { mode: 'any', version: 2 }, + metadata: { source: 'integration-test', updated: true }, + order: 2, + isActive: true, + }, + }); + assert.equal(badgeByLegacy.item?.id, ids.tenantBadge, 'badge legacyId should update existing badge idempotently'); + assert.equal(badgeByLegacy.item?.level, 2, 'badge update should persist new level'); + + const badgeConflict = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: '00000000-0000-0000-0000-000000000872', + legacyId: 'integration-badge-first-practice', + name: '冲突勋章', + }, + expectStatus: 409, + }); + assert.equal(badgeConflict.code, 'BADGE_ID_CONFLICT', 'badge id and legacyId conflict should be explicit'); + + const invalidBadgeCategory = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + name: '非法分类勋章', + category: 'unsafe-category', + }, + expectStatus: 400, + }); + assert.equal(invalidBadgeCategory.code, 'INVALID_FIELD_VALUE', 'badge admin should reject invalid category'); + + const invalidBadgeNumber = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + name: '非法数值勋章', + category: 'practice', + conditionValue: 'not-a-number', + }, + expectStatus: 400, + }); + assert.equal(invalidBadgeNumber.code, 'INVALID_NUMBER', 'badge admin should reject invalid numeric condition'); + + const invalidBadgeId = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: 'not-a-uuid', + name: '非法 ID 勋章', + }, + expectStatus: 400, + }); + assert.equal(invalidBadgeId.code, 'INVALID_UUID', 'badge admin should reject invalid badge id before database cast'); + + const badges = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + query: { category: 'practice', includeInactive: 'true', limit: 20 }, + }); + assert.ok(badges.items?.some(item => item.id === ids.tenantBadge), 'tenant admin should list badge by category'); + + const grant = await request('/api/tenant-admin/badge-grants', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + badgeId: ids.tenantBadge, + userId: USER_ID, + note: '集成测试手动授予', + metadata: { source: 'integration-test', firstGrant: true }, + }, + }); + assert.equal(grant.item?.badgeId, ids.tenantBadge, 'tenant admin should grant badge'); + assert.equal(grant.item?.userId, USER_ID, 'badge grant should target student'); + + const repeatedGrant = await request('/api/tenant-admin/badge-grants', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + badgeId: ids.tenantBadge, + userId: USER_ID, + note: '重复授予不应产生重复记录', + metadata: { source: 'integration-test-repeat', repeated: true }, + }, + }); + assert.equal(repeatedGrant.item?.id, grant.item?.id, 'repeated badge grant should be idempotent'); + + const badgeGrants = await request('/api/tenant-admin/badge-grants', { + userId: TENANT_ADMIN_USER_ID, + query: { userId: USER_ID, badgeId: ids.tenantBadge, limit: 20 }, + }); + assert.equal( + badgeGrants.items?.filter(item => item.userId === USER_ID && item.badgeId === ids.tenantBadge).length, + 1, + 'badge grants should not duplicate per user and badge', + ); + + const profileBadges = await request('/api/profile/badges', { + query: { includeLocked: 'true', category: 'practice', limit: 20 }, + }); + const profileBadge = profileBadges.items?.find(item => item.badgeId === ids.tenantBadge); + assert.equal(profileBadge?.unlocked, true, 'profile badges should expose granted badge as unlocked'); + assert.equal(profileBadges.summary?.unlocked >= 1, true, 'profile badges summary should count unlocked badges'); + + const profileBadgesInvalid = await request('/api/profile/badges', { + query: { category: 'unsafe-category' }, + expectStatus: 400, + }); + assert.equal(profileBadgesInvalid.code, 'INVALID_BADGE_CATEGORY', 'profile badges should reject invalid category'); + + const badgeGrantInvalidId = await request('/api/tenant-admin/badge-grants', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + badgeId: 'not-a-uuid', + userId: USER_ID, + }, + expectStatus: 400, + }); + assert.equal(badgeGrantInvalidId.code, 'INVALID_UUID', 'badge grant should reject invalid UUID input'); + + const badgeCrossTenantDenied = await request('/api/tenant-admin/badge-grants', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + badgeId: ids.tenantBadge, + userId: USER_ID, + }, + expectStatus: 403, + }); + assert.equal(badgeCrossTenantDenied.code, 'TENANT_ADMIN_REQUIRED', 'badge grant must be tenant isolated'); + const feedbacksBefore = await request('/api/tenant-admin/feedbacks', { userId: TENANT_ADMIN_USER_ID, query: { status: 'pending', limit: 20 }, @@ -3705,9 +3881,11 @@ async function testTenantMemberPermissionsAndAudit() { assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission'); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission'); assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission'); + assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission'); assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups'); assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('dashboard:read'), 'tenant operator defaults should include dashboard read'); assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults'); + assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('badges:*'), 'tenant operator defaults should include badge management'); const roleTemplate = await request('/api/tenant-admin/role-templates', { userId: TENANT_ADMIN_USER_ID, diff --git a/supabase/migrations/202606290013_badge_management_metadata.sql b/supabase/migrations/202606290013_badge_management_metadata.sql new file mode 100644 index 00000000..46fdd37a --- /dev/null +++ b/supabase/migrations/202606290013_badge_management_metadata.sql @@ -0,0 +1,29 @@ +alter table public.badges + add column if not exists metadata jsonb not null default '{}'::jsonb; + +alter table public.user_badges + add column if not exists metadata jsonb not null default '{}'::jsonb; + +with ranked_badges as ( + select id, + row_number() over ( + partition by tenant_id, user_id, badge_id + order by granted_at asc nulls last, created_at asc, id asc + ) as rn + from public.user_badges + where user_id is not null + and badge_id is not null +) +delete from public.user_badges ub +using ranked_badges rb +where ub.id = rb.id + and rb.rn > 1; + +create unique index if not exists idx_user_badges_user_badge_unique + on public.user_badges(tenant_id, user_id, badge_id); + +create index if not exists idx_badges_tenant_category_active + on public.badges(tenant_id, category, is_active, sort_order); + +create index if not exists idx_user_badges_tenant_user_granted + on public.user_badges(tenant_id, user_id, granted_at desc nulls last, created_at desc);