forked from wangziqi/gongxue-base
feat: manage tenant badges
This commit is contained in:
12
README.md
12
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/ 题目视频讲解
|
||||
```
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '优惠券查看' },
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 预览、视频播放必须先请求后端签名或权限检查。
|
||||
|
||||
@@ -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` 的租户成员可手动强制补绑。
|
||||
|
||||
@@ -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 监控和销售转化看板 |
|
||||
|
||||
@@ -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 择校、主题模板市场等仍是后续商用增强项。
|
||||
|
||||
## 前后端协作建议
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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:商用主链路
|
||||
|
||||
|
||||
@@ -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. 个人中心
|
||||
- 会员权益、订单、激活码兑换
|
||||
- 错题本、收藏夹、学习统计、排行榜
|
||||
- 错题本、收藏夹、学习统计、排行榜、勋章
|
||||
|
||||
### 前端接入原则
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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®ionId=...` |
|
||||
| 租户班级 | `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 队列
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user