feat: manage tenant badges

This commit is contained in:
Codex
2026-06-29 07:04:53 +08:00
parent 4954ad3ca9
commit 78a26d1df2
19 changed files with 650 additions and 38 deletions

View File

@@ -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/ 题目视频讲解
```

View File

@@ -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],

View File

@@ -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<string, unknown>>(
`
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);

View File

@@ -13,7 +13,7 @@ const TENANT_ADMIN_ROLES = new Set([
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
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: '优惠券查看' },

View File

@@ -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],

View File

@@ -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<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
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');

View File

@@ -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/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口

View File

@@ -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` |

View File

@@ -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 预览、视频播放必须先请求后端签名或权限检查。

View File

@@ -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/VaultAPI 只返回 `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` 的租户成员可手动强制补绑。

View File

@@ -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 监控和销售转化看板 |

View File

@@ -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 择校、主题模板市场等仍是后续商用增强项。
## 前后端协作建议

View File

@@ -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 队列、成员权限、审计查询已补 APIExcel/CSV 导入、大批量异步导入和前端操作台待补。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。

View File

@@ -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商用主链路

View File

@@ -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. 个人中心
- 会员权益、订单、激活码兑换
- 错题本、收藏夹、学习统计、排行榜
- 错题本、收藏夹、学习统计、排行榜、勋章
### 前端接入原则

View File

@@ -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

View File

@@ -172,7 +172,7 @@ tenant:<tenantId>: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&regionId=...` |
| 租户班级 | `GET/PUT /api/tenant-admin/classes``POST /api/tenant-admin/classes/disable` |
@@ -183,6 +183,7 @@ tenant:<tenantId>: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:<tenantId>: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 队列

View File

@@ -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,

View File

@@ -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);