feat: add tenant role templates

This commit is contained in:
Codex
2026-06-29 00:29:31 +08:00
parent 9553836ac7
commit b262e87af9
14 changed files with 504 additions and 42 deletions

View File

@@ -12,7 +12,7 @@
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
- `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、审计日志。
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、自定义角色模板、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。
- 平台后台能力租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。
@@ -174,7 +174,7 @@ npm run check:refactor
优先继续补:
1. 真实云端 Auth/JWKS 回归、RLS 深测、自定义角色模板和菜单/模块/字段级权限。
1. 真实云端 Auth/JWKS 回归、RLS 深测、班级/教师/学生范围权限。
2. Taro 前端 scaffold让 H5 和小程序共用同一套 API。
3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。

View File

@@ -24,7 +24,15 @@ export interface TenantAdminAuth {
tenantId: string;
userId: string;
role: string;
roleTemplateId: string | null;
roleTemplateCode: string | null;
roleTemplateName: string | null;
permissions: Record<string, unknown>;
templatePermissions: Record<string, unknown>;
menuPermissions: Record<string, unknown>;
modulePermissions: Record<string, unknown>;
fieldPermissions: Record<string, unknown>;
dataScope: Record<string, unknown>;
}
function permissionKeys(permission: string) {
@@ -49,6 +57,9 @@ export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
const explicit = explicitPermission(auth.permissions, permission);
if (explicit !== null) return explicit;
const templateExplicit = explicitPermission(auth.templatePermissions, permission);
if (templateExplicit !== null) return templateExplicit;
const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || [];
return defaults.some(defaultPermission => {
if (defaultPermission === '*') return true;
@@ -93,9 +104,31 @@ export function tenantPermissionCatalog() {
{ key: 'crm:write', label: 'CRM 入队和重试' },
{ key: 'members:read', label: '成员查看' },
{ key: 'members:write', label: '成员管理' },
{ key: 'roles:read', label: '角色模板查看' },
{ key: 'roles:write', label: '角色模板管理' },
{ key: 'audit:read', label: '审计日志查看' },
{ key: 'content:*', label: '内容维护' },
],
menuGroups: [
{ key: 'dashboard', label: '数据看板' },
{ key: 'content', label: '题库内容' },
{ key: 'students', label: '学生管理' },
{ key: 'teachers', label: '教师/班级' },
{ key: 'marketing', label: '营销中心' },
{ key: 'sales', label: '销售/代理' },
{ key: 'crm', label: 'CRM' },
{ key: 'commerce', label: '订单/权益' },
{ key: 'settings', label: '租户设置' },
{ key: 'audit', label: '审计日志' },
],
fieldGroups: [
{ key: 'student.phone', label: '学生手机号' },
{ key: 'student.wechat', label: '学生微信' },
{ key: 'student.exam_intent', label: '考试意向' },
{ key: 'order.amount', label: '订单金额' },
{ key: 'referral.owner', label: '客资归属' },
{ key: 'payment.secret_mask', label: '商户密钥掩码' },
],
roleDefaults: ROLE_PERMISSION_DEFAULTS,
};
}
@@ -104,14 +137,37 @@ export async function requireTenantAdmin(ctx: RequestContext): Promise<TenantAdm
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
const membership = await queryOne<{
role: string;
roleTemplateId: string | null;
roleTemplateCode: string | null;
roleTemplateName: string | null;
permissions: Record<string, unknown>;
templatePermissions: Record<string, unknown>;
menuPermissions: Record<string, unknown>;
modulePermissions: Record<string, unknown>;
fieldPermissions: Record<string, unknown>;
dataScope: Record<string, unknown>;
}>(
`
select role, permissions
from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and status = 'active'
and role = any($3::text[])
select tm.role, tm.permissions,
tm.role_template_id as "roleTemplateId",
rt.code as "roleTemplateCode",
rt.name as "roleTemplateName",
coalesce(rt.permissions, '{}'::jsonb) as "templatePermissions",
coalesce(rt.menu_permissions, '{}'::jsonb) as "menuPermissions",
coalesce(rt.module_permissions, '{}'::jsonb) as "modulePermissions",
coalesce(rt.field_permissions, '{}'::jsonb) as "fieldPermissions",
coalesce(rt.data_scope, '{}'::jsonb) as "dataScope"
from public.tenant_memberships tm
left join public.tenant_role_templates rt
on rt.id = tm.role_template_id
and rt.tenant_id = tm.tenant_id
and rt.status = 'active'
where tm.tenant_id = $1
and tm.user_id = $2
and tm.status = 'active'
and tm.role = any($3::text[])
order by case role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
@@ -126,5 +182,18 @@ export async function requireTenantAdmin(ctx: RequestContext): Promise<TenantAdm
throw new HttpError(403, 'Tenant admin access is required', 'TENANT_ADMIN_REQUIRED');
}
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
return {
tenantId,
userId,
role: membership.role,
roleTemplateId: membership.roleTemplateId,
roleTemplateCode: membership.roleTemplateCode,
roleTemplateName: membership.roleTemplateName,
permissions: membership.permissions || {},
templatePermissions: membership.templatePermissions || {},
menuPermissions: membership.menuPermissions || {},
modulePermissions: membership.modulePermissions || {},
fieldPermissions: membership.fieldPermissions || {},
dataScope: membership.dataScope || {},
};
}

View File

@@ -8,6 +8,7 @@ import {
codeBatchesRoute,
couponsRoute,
createTenantDomainRoute,
disableTenantRoleTemplateRoute,
disableTenantMemberRoute,
faqsAdminRoute,
generateActivationCodesRoute,
@@ -16,8 +17,10 @@ import {
tenantMembersRoute,
tenantOverviewRoute,
tenantPermissionsRoute,
tenantRoleTemplatesRoute,
tenantSecretsRoute,
upsertTenantMemberRoute,
upsertTenantRoleTemplateRoute,
upsertActivationCodeRoute,
upsertAnnouncementRoute,
upsertAuthProviderRoute,
@@ -33,6 +36,9 @@ import {
export const tenantAdminRoutes: RouteDefinition[] = [
['GET', '/api/tenant-admin/permissions', tenantPermissionsRoute],
['GET', '/api/tenant-admin/role-templates', tenantRoleTemplatesRoute],
['PUT', '/api/tenant-admin/role-templates', upsertTenantRoleTemplateRoute],
['POST', '/api/tenant-admin/role-templates/disable', disableTenantRoleTemplateRoute],
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],

View File

@@ -20,6 +20,7 @@ const AUTH_STATUSES = ['active', 'disabled', 'testing'];
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'];
function jsonBodyValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
@@ -230,6 +231,41 @@ function permissionValue(value: unknown) {
return permissions;
}
function safeCodeValue(value: unknown, fallback = '') {
const raw = nullableString(value) || fallback;
const code = raw.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
if (!code || !/^[a-z][a-z0-9_-]{1,63}$/.test(code)) {
throw new HttpError(400, 'Invalid role template code', 'INVALID_ROLE_TEMPLATE_CODE');
}
return code;
}
function accessControlMapValue(value: unknown, name: string) {
const source = objectValue(value);
const result: Record<string, boolean> = {};
for (const [key, raw] of Object.entries(source)) {
if (typeof raw !== 'boolean') {
throw new HttpError(400, `${name}.${key} must be boolean`, 'INVALID_ACCESS_CONTROL_VALUE');
}
if (!/^[a-z][a-z0-9_.:-]*$/i.test(key)) {
throw new HttpError(400, `Invalid ${name} key: ${key}`, 'INVALID_ACCESS_CONTROL_KEY');
}
result[key] = raw;
}
return result;
}
function dataScopeValue(value: unknown) {
const scope = objectValue(value);
const allowed = new Set(['mode', 'regionIds', 'contentNodeIds', 'classIds', 'ownLeadsOnly', 'teamScope', 'metadata']);
for (const key of Object.keys(scope)) {
if (!allowed.has(key)) {
throw new HttpError(400, `Invalid dataScope key: ${key}`, 'INVALID_DATA_SCOPE_KEY');
}
}
return scope;
}
function requiredMemberRole(value: unknown) {
return optionalChoice(value, TENANT_MEMBER_ROLES, 'student');
}
@@ -241,6 +277,30 @@ function ensureCanGrantRole(auth: TenantAdminAuth, role: string, permissions: Re
}
}
async function resolveRoleTemplateForMember(client: pg.PoolClient, auth: TenantAdminAuth, roleTemplateId: string | null) {
if (!roleTemplateId) return null;
const template = await client.query<{
id: string;
baseRole: string;
permissions: Record<string, boolean>;
status: string;
}>(
`
select id, base_role as "baseRole", permissions, status
from public.tenant_role_templates
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, roleTemplateId],
);
const item = template.rows[0];
if (!item || item.status !== 'active') {
throw new HttpError(404, 'Role template not found or inactive', 'ROLE_TEMPLATE_NOT_FOUND');
}
ensureCanGrantRole(auth, item.baseRole, item.permissions || {});
return item;
}
async function ensureOwnerRemains(
client: pg.PoolClient,
tenantId: string,
@@ -346,12 +406,172 @@ export async function tenantPermissionsRoute(ctx: RequestContext) {
current: {
userId: auth.userId,
role: auth.role,
roleTemplateId: auth.roleTemplateId,
roleTemplateCode: auth.roleTemplateCode,
roleTemplateName: auth.roleTemplateName,
permissions: auth.permissions,
templatePermissions: auth.templatePermissions,
effectivePermissions: {
...auth.templatePermissions,
...auth.permissions,
},
menuPermissions: auth.menuPermissions,
modulePermissions: auth.modulePermissions,
fieldPermissions: auth.fieldPermissions,
dataScope: auth.dataScope,
},
...tenantPermissionCatalog(),
};
}
export async function tenantRoleTemplatesRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'roles:read');
const limit = intParam(ctx, 'limit', 100, 500);
const status = stringParam(ctx, 'status');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (status) {
if (!ROLE_TEMPLATE_STATUSES.includes(status)) {
throw new HttpError(400, `Invalid role template status: ${status}`, 'INVALID_ROLE_TEMPLATE_STATUS');
}
params.push(status);
filters.push(`status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, code, name, description, base_role as "baseRole", status, permissions,
menu_permissions as "menuPermissions", module_permissions as "modulePermissions",
field_permissions as "fieldPermissions", data_scope as "dataScope",
is_system as "isSystem", sort_order as "sortOrder",
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
from public.tenant_role_templates
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
limit $${params.length}
`,
params,
);
return { items };
}
export async function upsertTenantRoleTemplateRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'roles:write');
const body = await readJsonBody(ctx);
const templateId = nullableString(body.id);
const baseRole = requiredMemberRole(body.baseRole || body.role);
const permissions = permissionValue(body.permissions);
ensureCanGrantRole(auth, baseRole, permissions);
const item = await transaction(async client => {
const result = await client.query(
`
insert into public.tenant_role_templates (
id, tenant_id, code, name, description, base_role, status, permissions,
menu_permissions, module_permissions, field_permissions, data_scope,
sort_order, created_by, updated_by
)
values (
coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5, $6, $7, $8::jsonb,
$9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb, $13, $14, $14
)
on conflict (tenant_id, code)
do update set name = excluded.name,
description = excluded.description,
base_role = excluded.base_role,
status = excluded.status,
permissions = excluded.permissions,
menu_permissions = excluded.menu_permissions,
module_permissions = excluded.module_permissions,
field_permissions = excluded.field_permissions,
data_scope = excluded.data_scope,
sort_order = excluded.sort_order,
updated_by = excluded.updated_by,
updated_at = now()
returning id, code, name, description, base_role as "baseRole", status, permissions,
menu_permissions as "menuPermissions", module_permissions as "modulePermissions",
field_permissions as "fieldPermissions", data_scope as "dataScope",
is_system as "isSystem", sort_order as "sortOrder",
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
templateId,
safeCodeValue(body.code, nullableString(body.name) || ''),
requiredString(body, 'name'),
nullableString(body.description),
baseRole,
optionalChoice(body.status, ROLE_TEMPLATE_STATUSES, 'active'),
JSON.stringify(permissions),
JSON.stringify(accessControlMapValue(body.menuPermissions, 'menuPermissions')),
JSON.stringify(accessControlMapValue(body.modulePermissions, 'modulePermissions')),
JSON.stringify(accessControlMapValue(body.fieldPermissions, 'fieldPermissions')),
JSON.stringify(dataScopeValue(body.dataScope)),
intValue(body.sortOrder, 100),
auth.userId,
],
);
await recordAudit(client, auth, 'tenant.role_template.upserted', 'tenant_role_templates', result.rows[0].id, {
code: result.rows[0].code,
baseRole,
permissionKeys: Object.keys(permissions),
});
return result.rows[0];
});
return { item };
}
export async function disableTenantRoleTemplateRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'roles:write');
const body = await readJsonBody(ctx);
const roleTemplateId = requiredString(body, 'roleTemplateId');
const item = await transaction(async client => {
const current = await client.query<{ id: string; permissions: Record<string, boolean>; baseRole: string; isSystem: boolean }>(
`
select id, permissions, base_role as "baseRole", is_system as "isSystem"
from public.tenant_role_templates
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, roleTemplateId],
);
if (!current.rows[0]) throw new HttpError(404, 'Role template not found', 'ROLE_TEMPLATE_NOT_FOUND');
if (current.rows[0].isSystem) throw new HttpError(400, 'System role template cannot be disabled', 'SYSTEM_ROLE_TEMPLATE_LOCKED');
ensureCanGrantRole(auth, current.rows[0].baseRole, current.rows[0].permissions || {});
const result = await client.query(
`
update public.tenant_role_templates
set status = 'disabled',
updated_by = $3,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, code, name, description, base_role as "baseRole", status, permissions,
menu_permissions as "menuPermissions", module_permissions as "modulePermissions",
field_permissions as "fieldPermissions", data_scope as "dataScope",
is_system as "isSystem", sort_order as "sortOrder",
created_at as "createdAt", updated_at as "updatedAt"
`,
[auth.tenantId, roleTemplateId, auth.userId],
);
await recordAudit(client, auth, 'tenant.role_template.disabled', 'tenant_role_templates', roleTemplateId, {
code: result.rows[0].code,
});
return result.rows[0];
});
return { item };
}
export async function tenantOverviewRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'tenant:overview:read');
@@ -1289,11 +1509,19 @@ export async function tenantMembersRoute(ctx: RequestContext) {
const items = await query(
`
select tm.id, tm.user_id as "userId", tm.role, tm.status, tm.permissions,
tm.role_template_id as "roleTemplateId",
rt.code as "roleTemplateCode",
rt.name as "roleTemplateName",
rt.menu_permissions as "menuPermissions",
rt.module_permissions as "modulePermissions",
rt.field_permissions as "fieldPermissions",
rt.data_scope as "dataScope",
tm.legacy_role as "legacyRole", tm.created_at as "createdAt", tm.updated_at as "updatedAt",
u.username, u.email::text as email, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.last_seen_at as "lastSeenAt"
from public.tenant_memberships tm
join public.platform_users u on u.id = tm.user_id
left join public.tenant_role_templates rt on rt.id = tm.role_template_id and rt.tenant_id = tm.tenant_id
where ${filters.join(' and ')}
order by case tm.role
when 'tenant_owner' then 1
@@ -1317,12 +1545,16 @@ export async function upsertTenantMemberRoute(ctx: RequestContext) {
requireTenantPermission(auth, 'members:write');
const body = await readJsonBody(ctx);
const membershipId = nullableString(body.membershipId) || nullableString(body.id);
const role = requiredMemberRole(body.role);
const roleTemplateId = nullableString(body.roleTemplateId);
let role = requiredMemberRole(body.role);
const status = optionalChoice(body.status, TENANT_MEMBER_STATUSES, 'active');
const permissions = permissionValue(body.permissions);
ensureCanGrantRole(auth, role, permissions);
const item = await transaction(async client => {
const template = await resolveRoleTemplateForMember(client, auth, roleTemplateId);
if (template) role = template.baseRole;
ensureCanGrantRole(auth, role, permissions);
const userId = await resolveOrCreateMemberUser(client, body);
if (userId === auth.userId && status === 'disabled') {
throw new HttpError(400, 'Cannot disable your own tenant membership', 'CANNOT_DISABLE_SELF');
@@ -1338,26 +1570,30 @@ export async function upsertTenantMemberRoute(ctx: RequestContext) {
role = $4,
status = $5,
permissions = $6::jsonb,
role_template_id = $7::uuid,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, user_id as "userId", role, status, permissions,
role_template_id as "roleTemplateId",
legacy_role as "legacyRole", created_at as "createdAt", updated_at as "updatedAt"
`,
[auth.tenantId, membershipId, userId, role, status, JSON.stringify(permissions)],
[auth.tenantId, membershipId, userId, role, status, JSON.stringify(permissions), roleTemplateId],
);
} else {
result = await client.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
values ($1, $2, $3, $4, $5::jsonb)
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions, role_template_id)
values ($1, $2, $3, $4, $5::jsonb, $6::uuid)
on conflict (tenant_id, user_id, role)
do update set status = excluded.status,
permissions = excluded.permissions,
role_template_id = excluded.role_template_id,
updated_at = now()
returning id, user_id as "userId", role, status, permissions,
role_template_id as "roleTemplateId",
legacy_role as "legacyRole", created_at as "createdAt", updated_at as "updatedAt"
`,
[auth.tenantId, userId, role, status, JSON.stringify(permissions)],
[auth.tenantId, userId, role, status, JSON.stringify(permissions), roleTemplateId],
);
}
@@ -1367,6 +1603,7 @@ export async function upsertTenantMemberRoute(ctx: RequestContext) {
userId,
role,
status,
roleTemplateId,
permissionKeys: Object.keys(permissions),
});

View File

@@ -9,21 +9,37 @@ export interface TenantContentAuth {
userId: string;
role: string;
permissions: Record<string, unknown>;
templatePermissions: Record<string, unknown>;
}
function hasContentPermission(permissions: Record<string, unknown>) {
return permissions['*'] === true || permissions['content:*'] === true;
}
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
const membership = await queryOne<{ role: string; permissions: Record<string, unknown>; templatePermissions: Record<string, unknown> }>(
`
select role, permissions
from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and status = 'active'
and role = any($3::text[])
order by case role
select tm.role, tm.permissions,
coalesce(rt.permissions, '{}'::jsonb) as "templatePermissions"
from public.tenant_memberships tm
left join public.tenant_role_templates rt
on rt.id = tm.role_template_id
and rt.tenant_id = tm.tenant_id
and rt.status = 'active'
where tm.tenant_id = $1
and tm.user_id = $2
and tm.status = 'active'
and (
tm.role = any($3::text[])
or coalesce(rt.permissions, '{}'::jsonb) ? 'content:*'
or coalesce(rt.permissions, '{}'::jsonb) ? '*'
or tm.permissions ? 'content:*'
or tm.permissions ? '*'
)
order by case tm.role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
when 'tenant_operator' then 3
@@ -38,6 +54,9 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise<T
if (!membership) {
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
}
if (!CONTENT_ROLES.has(membership.role) && !hasContentPermission(membership.permissions || {}) && !hasContentPermission(membership.templatePermissions || {})) {
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
}
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {}, templatePermissions: membership.templatePermissions || {} };
}

View File

@@ -41,8 +41,8 @@
| 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 |
| 微信网页/QQ OAuth | 待补齐 | 目前仍是 placeholder需要 code 换 token、回调域名、账号合并和审计 |
| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 |
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions`,接口有权限点校验 |
| 自定义角色模板 | 待补齐 | 当前有权限 JSON 覆盖,缺角色模板、菜单/模块/字段级权限配置 UI/API |
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 |
| 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单模块字段、数据范围配置;前端 UI 和班级/学生范围继续补 |
## 学生端题库主链路
@@ -113,7 +113,7 @@
| 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption |
| 活动、Banner、FAQ、公告 | 可联调 | `/api/tenant-admin/banners``faqs``announcements` |
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches``activation-codes` |
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members``permissions``audit-logs` |
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members``permissions``role-templates``audit-logs` |
| 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` |
| 数据看板聚合接口 | 待补齐 | 表基础已有,缺完整 dashboard API |

View File

@@ -22,7 +22,7 @@
| --- | --- | --- | --- |
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量 | 自动计费、平台审计、公共题库披露策略 |
| 租户后台 | 基础完成 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、成员权限 | 自定义角色模板、菜单/模块可见性 UI、字段级权限 |
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、成员权限角色模板、菜单/模块/字段权限配置 API | 前端权限 UI、班级/教师/学生范围权限 |
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏 | 完整模考交卷报告、专项策略、公题库采纳/授权、Excel 导入 |
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、JSON 导入 | 复习算法、每日计划、排行榜、Excel 导入 |
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON 导入 | 富文本资源、版本管理、附件/PDF 关联 |
@@ -93,7 +93,7 @@
### P2企业级体验和增长闭环
- 租户自定义角色、菜单可见、模块可见、字段级权限和权限审计
- 租户自定义角色模板基础 API 已完成;继续补权限配置 UI、班级/教师/学生范围权限和平台级审计报表
- 三套默认主题、租户主题预览、Logo/图标/分享图配置。
- CRM worker钉钉、飞书、企微机器人轮询/定向分配,失败重试。
- 销售/代理分佣结算、销售团队看板、客资跟进效果。

View File

@@ -14,10 +14,11 @@
- `commerce`:订单、支付确认、激活码兑换、权益查询。
- `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` 绑定模板。
- `learning` 已接入商用访问控制免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。

View File

@@ -36,10 +36,11 @@
- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api`
- 可以接入租户品牌、主题、功能开关和域名/小程序参数解析。
- 租户后台可以接入角色模板 API`/api/tenant-admin/role-templates`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性。
## 不能误认为已商用完成的部分
- 生产鉴权已具备 Supabase JWT API 入口,仍要做真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化前端不要继续使用 `x-user-id`
- 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化前端不要继续使用 `x-user-id`
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
- 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。
- 对象存储已完成签名 provider但 PDF 预览、防盗链、视频水印、上传后校验还要补。

View File

@@ -149,11 +149,17 @@ provider event id 幂等
| agent | 兑换码/优惠券只读、本人的客资 |
| student | 无后台权限 |
已支持租户自定义角色模板:
- `tenant_role_templates` 保存租户内模板,成员通过 `tenant_memberships.role_template_id` 绑定。
- 权限判断顺序:成员 `permissions` 显式覆盖 > 角色模板 `permissions` > 系统角色默认权限。
- 模板可保存 `menuPermissions``modulePermissions``fieldPermissions``dataScope`,供 Taro/管理台做菜单、模块、字段可见性和数据范围 UI。
- 模板含 `*``tenant_owner``tenant_admin` 等管理员级能力时,只有租户 owner 可创建或授予;普通租户管理员不能自造全权限模板。
- 角色模板创建、更新、禁用都会写入 `audit_logs`
后续要补:
- 租户自定义角色模板
- 菜单级、模块级、字段级权限。
- 权限变更审计。
- 前端角色模板配置 UI
- 班级/教师/学生范围权限。
## 上线前安全验收清单

View File

@@ -30,7 +30,7 @@
1. 生产鉴权
- 已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份。
- 已覆盖学生、租户管理员、平台管理员、错租户、坏签名、禁用 legacy header 的 API 集成测试。
- 继续补真实云端 Auth/JWKS 回归、RLS 深测、自定义角色模板和菜单/模块/字段级权限。
- 已补自定义角色模板、菜单/模块/字段级配置 API继续补真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限。
- 前端联调时禁止继续使用 `x-user-id``x-tenant-id` 只作为租户上下文,不能作为身份依据。
2. 对象存储
@@ -97,9 +97,9 @@
### P2 运营体验和企业交付
1. 自定义角色
- 租户内角色模板。
- 菜单可见、模块可见、字段级权限
- 权限变更审计
- 已完成租户内角色模板、菜单可见、模块可见、字段级权限和权限变更审计基础 API
- 继续补租户后台可视化配置 UI
- 继续补班级/教师/学生范围权限。
2. 主题系统
- 平台默认三套主题。

View File

@@ -437,7 +437,7 @@ content_entries
- 支付渠道从后端返回或租户配置读取,不在页面硬编码。
- 小程序分享路径必须带 tenantCode 和 referral code。
- 用户首绑归属由后端保护,前端不要提供“换绑销售”入口。
- 管理后台菜单按 `GET /api/tenant-admin/permissions` 和用户权限渲染
- 管理后台菜单按 `GET /api/tenant-admin/permissions` 返回的 `current.permissions``current.templatePermissions``current.menuPermissions``current.modulePermissions` 渲染;接口权限仍以后端校验为准
- H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。
## 登录对接
@@ -602,13 +602,14 @@ GET /api/commerce/entitlements
- 品牌/主题/域名/公开设置
- 支付账户/登录 provider/密钥引用
- 用户与成员权限
- 角色模板:`GET/PUT /api/tenant-admin/role-templates``POST /api/tenant-admin/role-templates/disable`
- 内容入口/分类树/题目集合/练习蓝图
- 题目/单词/知识手册/分数线/视频维护
- JSON 导入 preview/import/issues
- Banner/FAQ/公告/激活码/优惠券
- 销售/代理/CRM 队列
租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。
租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。
## 联调顺序

View File

@@ -2348,8 +2348,63 @@ async function testTenantMemberPermissionsAndAudit() {
userId: TENANT_ADMIN_USER_ID,
});
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission');
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission');
assert.ok(permissionMatrix.menuGroups?.some(item => item.key === 'sales'), 'permission matrix should expose menu groups');
assert.ok(permissionMatrix.roleDefaults?.tenant_operator?.includes('marketing:*'), 'permission matrix should include role defaults');
const roleTemplate = await request('/api/tenant-admin/role-templates', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: 'ops-marketing',
name: '运营活动模板',
description: '允许运营维护活动内容,同时可配置前端菜单和字段可见性',
baseRole: 'tenant_operator',
permissions: {
'marketing:*': true,
'tenant:payment:write': true,
},
menuPermissions: {
dashboard: true,
marketing: true,
settings: false,
},
modulePermissions: {
banners: true,
coupons: true,
},
fieldPermissions: {
'student.phone': false,
'order.amount': true,
},
dataScope: {
mode: 'tenant',
ownLeadsOnly: false,
},
sortOrder: 10,
},
});
assert.equal(roleTemplate.item?.code, 'ops-marketing', 'tenant admin should upsert custom role template');
assert.equal(roleTemplate.item?.menuPermissions?.marketing, true, 'role template should persist menu permissions');
const roleTemplates = await request('/api/tenant-admin/role-templates', {
userId: TENANT_ADMIN_USER_ID,
});
assert.ok(roleTemplates.items?.some(item => item.id === roleTemplate.item.id), 'role template list should include custom template');
const wildcardTemplateDenied = await request('/api/tenant-admin/role-templates', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
code: 'super-admin-template',
name: '危险全权限模板',
baseRole: 'tenant_admin',
permissions: { '*': true },
},
expectStatus: 403,
});
assert.equal(wildcardTemplateDenied.code, 'TENANT_OWNER_REQUIRED', 'tenant admin should not create owner/admin level role template');
const operator = await request('/api/tenant-admin/members', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
@@ -2359,6 +2414,7 @@ async function testTenantMemberPermissionsAndAudit() {
phone: '13800000003',
name: 'Integration Operator',
role: 'tenant_operator',
roleTemplateId: roleTemplate.item.id,
status: 'active',
permissions: {
'marketing:*': true,
@@ -2367,12 +2423,20 @@ async function testTenantMemberPermissionsAndAudit() {
},
});
assert.equal(operator.item?.role, 'tenant_operator', 'tenant admin should create operator membership');
assert.equal(operator.item?.roleTemplateId, roleTemplate.item.id, 'member should bind custom role template');
const members = await request('/api/tenant-admin/members', {
userId: TENANT_ADMIN_USER_ID,
query: { keyword: 'Integration Operator' },
});
assert.ok(members.items?.some(item => item.userId === TENANT_OPERATOR_USER_ID), 'member list should find operator');
assert.ok(members.items?.some(item => item.userId === TENANT_OPERATOR_USER_ID && item.roleTemplateCode === 'ops-marketing'), 'member list should expose role template binding');
const operatorPermissionMatrix = await request('/api/tenant-admin/permissions', {
userId: TENANT_OPERATOR_USER_ID,
});
assert.equal(operatorPermissionMatrix.current?.roleTemplateCode, 'ops-marketing', 'current permission matrix should include role template');
assert.equal(operatorPermissionMatrix.current?.menuPermissions?.marketing, true, 'current permission matrix should expose menu permissions');
const operatorBanner = await request('/api/tenant-admin/banners', {
userId: TENANT_OPERATOR_USER_ID,
@@ -2468,12 +2532,21 @@ async function testTenantMemberPermissionsAndAudit() {
});
assert.equal(disabledSalesDenied.code, 'TENANT_ADMIN_REQUIRED', 'disabled sales membership should lose tenant admin access');
const disabledTemplate = await request('/api/tenant-admin/role-templates/disable', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { roleTemplateId: roleTemplate.item.id },
});
assert.equal(disabledTemplate.item?.status, 'disabled', 'tenant admin should disable custom role template');
const auditLogs = await request('/api/tenant-admin/audit-logs', {
userId: TENANT_ADMIN_USER_ID,
query: { action: 'tenant.member', limit: 20 },
query: { action: 'tenant.', limit: 50 },
});
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.member.upserted'), 'audit logs should include member upsert');
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.member.disabled'), 'audit logs should include member disable');
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.role_template.upserted'), 'audit logs should include role template upsert');
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.role_template.disabled'), 'audit logs should include role template disable');
const partnerAuditDenied = await request('/api/tenant-admin/audit-logs', {
tenantId: PARTNER_TENANT_ID,

View File

@@ -0,0 +1,49 @@
create table if not exists public.tenant_role_templates (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
code text not null,
name text not null,
description text,
base_role text not null default 'tenant_operator'
check (base_role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student')),
status text not null default 'active' check (status in ('active', 'disabled', 'archived')),
permissions jsonb not null default '{}'::jsonb,
menu_permissions jsonb not null default '{}'::jsonb,
module_permissions jsonb not null default '{}'::jsonb,
field_permissions jsonb not null default '{}'::jsonb,
data_scope jsonb not null default '{}'::jsonb,
is_system boolean not null default false,
sort_order integer not null default 100,
created_by uuid references public.platform_users(id) on delete set null,
updated_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, code)
);
comment on table public.tenant_role_templates is
'租户自定义角色模板。成员仍保留系统 base role模板提供权限、菜单、模块、字段和数据范围配置。';
alter table public.tenant_memberships
add column if not exists role_template_id uuid references public.tenant_role_templates(id) on delete restrict;
create index if not exists idx_role_templates_tenant_status
on public.tenant_role_templates(tenant_id, status, sort_order);
create index if not exists idx_memberships_role_template
on public.tenant_memberships(role_template_id)
where role_template_id is not null;
alter table public.tenant_role_templates enable row level security;
drop policy if exists tenant_isolation on public.tenant_role_templates;
create policy tenant_isolation on public.tenant_role_templates
for all
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
drop trigger if exists set_updated_at on public.tenant_role_templates;
create trigger set_updated_at
before update on public.tenant_role_templates
for each row
execute function app.touch_updated_at();