feat: add platform tenant detail audit console

This commit is contained in:
Codex
2026-06-30 04:38:36 +08:00
parent e62653025c
commit d5d32e84b3
16 changed files with 688 additions and 111 deletions

View File

@@ -6,7 +6,7 @@
## 当前状态 ## 当前状态
更新时间2026-06-29 更新时间2026-06-30
目前已经完成并在本地验证通过的内容: 目前已经完成并在本地验证通过的内容:
@@ -15,7 +15,7 @@
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
- 平台后台能力租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 - 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets``daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets``daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。
@@ -35,7 +35,7 @@
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 - 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、账单、收款、用量和题库授权第一版写操作。
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
更完整的进度看这些文档: 更完整的进度看这些文档:
@@ -430,7 +430,7 @@ git diff --check
优先继续补: 优先继续补:
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。 2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台自动计费、批量账单、审计报表增强和更细平台权限点,小程序兼容验证。
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。 5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。

View File

@@ -5,6 +5,7 @@ import {
createSubscriptionRoute, createSubscriptionRoute,
createTenantInvoiceFromSubscriptionRoute, createTenantInvoiceFromSubscriptionRoute,
createTenantRoute, createTenantRoute,
platformAuditLogsRoute,
platformOverviewRoute, platformOverviewRoute,
platformPlansRoute, platformPlansRoute,
platformQuestionBanksRoute, platformQuestionBanksRoute,
@@ -30,6 +31,7 @@ export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute], ['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute],
['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute], ['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute],
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute], ['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
['GET', '/api/platform-admin/audit-logs', platformAuditLogsRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute], ['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute], ['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute], ['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -1,4 +1,6 @@
import { HttpError, type RequestContext } from '../../core/http.js'; import type pg from 'pg';
import { currentSessionFromContext } from '../../core/auth-context.js';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import { import {
intParam, intParam,
optionalStringArray, optionalStringArray,
@@ -37,6 +39,54 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
return optionalStringArray(body, key).filter(Boolean); return optionalStringArray(body, key).filter(Boolean);
} }
function billingInvoiceTypeFrom(value: string) {
const invoiceType = value || 'none';
if (!['none', 'normal_vat', 'special_vat'].includes(invoiceType)) {
throw new HttpError(400, 'invoiceType is invalid', 'INVALID_INVOICE_TYPE');
}
return invoiceType;
}
function platformAuditDetails(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
function requestIp(ctx: RequestContext) {
const forwarded = getHeader(ctx.req, 'x-forwarded-for').split(',')[0]?.trim();
return forwarded || ctx.req.socket.remoteAddress || null;
}
async function recordPlatformAudit(
client: pg.PoolClient,
ctx: RequestContext,
action: string,
targetType: string,
targetId: string | null,
details: Record<string, unknown> = {},
tenantId: string | null = null,
) {
const session = currentSessionFromContext(ctx);
await client.query(
`
insert into public.audit_logs (
tenant_id, actor_user_id, action, target_type, target_id,
details, ip_address, user_agent
)
values ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb, $7, $8)
`,
[
tenantId || null,
session?.id || null,
action,
targetType,
targetId,
platformAuditDetails(details),
requestIp(ctx),
getHeader(ctx.req, 'user-agent') || null,
],
);
}
function grantScopeFrom(value: string) { function grantScopeFrom(value: string) {
const scope = value || 'plans'; const scope = value || 'plans';
if (!['all_active_tenants', 'plans', 'tenants', 'mixed'].includes(scope)) { if (!['all_active_tenants', 'plans', 'tenants', 'mixed'].includes(scope)) {
@@ -417,7 +467,8 @@ export async function tenantDetailRoute(ctx: RequestContext) {
bp.billing_name as "billingName", bp.tax_id as "taxId", bp.billing_name as "billingName", bp.tax_id as "taxId",
bp.contact_name as "contactName", bp.contact_phone as "contactPhone", bp.contact_name as "contactName", bp.contact_phone as "contactPhone",
bp.contact_email as "contactEmail", bp.invoice_title as "invoiceTitle", bp.contact_email as "contactEmail", bp.invoice_title as "invoiceTitle",
bp.invoice_type as "invoiceType" bp.invoice_type as "invoiceType", bp.billing_address as "billingAddress",
bp.bank_name as "bankName", bp.bank_account_masked as "bankAccountMasked"
from public.tenants t from public.tenants t
left join public.tenant_branding b on b.tenant_id = t.id left join public.tenant_branding b on b.tenant_id = t.id
left join public.tenant_billing_profiles bp on bp.tenant_id = t.id left join public.tenant_billing_profiles bp on bp.tenant_id = t.id
@@ -454,7 +505,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
), ),
query( query(
` `
select id, invoice_no as "invoiceNo", invoice_type as "invoiceType", status, select id, tenant_id as "tenantId", invoice_no as "invoiceNo", invoice_type as "invoiceType", status,
total_cents as "totalCents", paid_cents as "paidCents", total_cents as "totalCents", paid_cents as "paidCents",
balance_cents as "balanceCents", due_date as "dueDate", balance_cents as "balanceCents", due_date as "dueDate",
issued_at as "issuedAt", paid_at as "paidAt", created_at as "createdAt" issued_at as "issuedAt", paid_at as "paidAt", created_at as "createdAt"
@@ -467,7 +518,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
), ),
query( query(
` `
select metric_key as "metricKey", metric_value as "metricValue", select id, tenant_id as "tenantId", metric_key as "metricKey", metric_value as "metricValue",
period_start as "periodStart", period_end as "periodEnd", metadata, period_start as "periodStart", period_end as "periodEnd", metadata,
created_at as "createdAt" created_at as "createdAt"
from public.tenant_usage_records from public.tenant_usage_records
@@ -482,6 +533,63 @@ export async function tenantDetailRoute(ctx: RequestContext) {
return { item: { tenant, domains, subscriptions, invoices, usage } }; return { item: { tenant, domains, subscriptions, invoices, usage } };
} }
export async function platformAuditLogsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const tenantId = listQuery(ctx, 'tenantId');
const action = listQuery(ctx, 'action');
const targetType = listQuery(ctx, 'targetType');
const actorUserId = listQuery(ctx, 'actorUserId');
const q = listQuery(ctx, 'q');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [];
const filters: string[] = [];
if (tenantId) {
params.push(tenantId);
filters.push(`al.tenant_id = $${params.length}::uuid`);
}
if (action) {
params.push(`${action}%`);
filters.push(`al.action ilike $${params.length}`);
}
if (targetType) {
params.push(targetType);
filters.push(`al.target_type = $${params.length}`);
}
if (actorUserId) {
params.push(actorUserId);
filters.push(`al.actor_user_id = $${params.length}::uuid`);
}
if (q) {
params.push(`%${q}%`);
filters.push(`(al.action ilike $${params.length} or al.target_type ilike $${params.length} or al.target_id ilike $${params.length})`);
}
params.push(limit);
const items = await query(
`
select al.id, al.tenant_id as "tenantId", t.slug::text as "tenantSlug",
t.name as "tenantName", al.actor_user_id as "actorUserId",
u.username as "actorUsername", u.name as "actorName",
u.phone as "actorPhone", al.action,
al.target_type as "targetType", al.target_id as "targetId",
al.details, al.ip_address as "ipAddress", al.user_agent as "userAgent",
al.created_at as "createdAt"
from public.audit_logs al
left join public.tenants t on t.id = al.tenant_id
left join public.platform_users u on u.id = al.actor_user_id
${filters.length ? `where ${filters.join(' and ')}` : ''}
order by al.created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function createTenantRoute(ctx: RequestContext) { export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx); await requirePlatformAdmin(ctx);
@@ -497,6 +605,7 @@ export async function createTenantRoute(ctx: RequestContext) {
const legalName = optionalString(body, 'legalName') || null; const legalName = optionalString(body, 'legalName') || null;
const primaryHost = optionalString(body, 'primaryHost'); const primaryHost = optionalString(body, 'primaryHost');
const billing = body.billing && typeof body.billing === 'object' ? (body.billing as Record<string, unknown>) : {}; const billing = body.billing && typeof body.billing === 'object' ? (body.billing as Record<string, unknown>) : {};
const billingInvoiceType = billingInvoiceTypeFrom(typeof billing.invoiceType === 'string' ? billing.invoiceType.trim() : '');
const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}; const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
const item = await transaction(async client => { const item = await transaction(async client => {
@@ -561,7 +670,7 @@ export async function createTenantRoute(ctx: RequestContext) {
typeof billing.contactEmail === 'string' ? billing.contactEmail : null, typeof billing.contactEmail === 'string' ? billing.contactEmail : null,
typeof billing.billingAddress === 'string' ? billing.billingAddress : null, typeof billing.billingAddress === 'string' ? billing.billingAddress : null,
typeof billing.invoiceTitle === 'string' ? billing.invoiceTitle : legalName || name, typeof billing.invoiceTitle === 'string' ? billing.invoiceTitle : legalName || name,
typeof billing.invoiceType === 'string' ? billing.invoiceType : 'none', billingInvoiceType,
jsonBodyValue(billing.metadata), jsonBodyValue(billing.metadata),
], ],
); );
@@ -605,6 +714,15 @@ export async function createTenantRoute(ctx: RequestContext) {
], ],
); );
await recordPlatformAudit(client, ctx, 'platform.tenant.created', 'tenant', tenant.id, {
slug,
name,
legalName,
planCode: plan.code,
billingStatus,
primaryHost: primaryHost ? normalizeHost(primaryHost) : null,
}, tenant.id);
return tenant; return tenant;
}); });
@@ -620,21 +738,30 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
const billingStatus = optionalString(body, 'billingStatus'); const billingStatus = optionalString(body, 'billingStatus');
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD'); if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
const item = await queryOne( const item = await transaction(async client => {
` const result = await client.query(
update public.tenants `
set status = coalesce(nullif($2, ''), status), update public.tenants
billing_status = coalesce(nullif($3, ''), billing_status), set status = coalesce(nullif($2, ''), status),
metadata = metadata || $4::jsonb, billing_status = coalesce(nullif($3, ''), billing_status),
updated_at = now() metadata = metadata || $4::jsonb,
where id = $1 updated_at = now()
returning id, slug::text, name, status, billing_status as "billingStatus", where id = $1
metadata, updated_at as "updatedAt" returning id, slug::text, name, status, billing_status as "billingStatus",
`, metadata, updated_at as "updatedAt"
[tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })], `,
); [tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })],
);
if (!result.rows[0]) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
await recordPlatformAudit(client, ctx, 'platform.tenant.status_updated', 'tenant', tenantId, {
status: status || null,
billingStatus: billingStatus || null,
reason: optionalString(body, 'reason') || null,
}, tenantId);
return result.rows[0];
});
if (!item) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
return { item }; return { item };
} }
@@ -643,49 +770,63 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx); const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId'); const tenantId = requiredString(body, 'tenantId');
const invoiceType = billingInvoiceTypeFrom(optionalString(body, 'invoiceType'));
const item = await queryOne( const item = await transaction(async client => {
` const result = await client.query(
insert into public.tenant_billing_profiles ( `
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email, insert into public.tenant_billing_profiles (
billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
) billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb) )
on conflict (tenant_id) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
do update set billing_name = excluded.billing_name, on conflict (tenant_id)
tax_id = excluded.tax_id, do update set billing_name = excluded.billing_name,
contact_name = excluded.contact_name, tax_id = excluded.tax_id,
contact_phone = excluded.contact_phone, contact_name = excluded.contact_name,
contact_email = excluded.contact_email, contact_phone = excluded.contact_phone,
billing_address = excluded.billing_address, contact_email = excluded.contact_email,
invoice_title = excluded.invoice_title, billing_address = excluded.billing_address,
invoice_type = excluded.invoice_type, invoice_title = excluded.invoice_title,
bank_name = excluded.bank_name, invoice_type = excluded.invoice_type,
bank_account_masked = excluded.bank_account_masked, bank_name = excluded.bank_name,
metadata = excluded.metadata, bank_account_masked = excluded.bank_account_masked,
updated_at = now() metadata = excluded.metadata,
returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId", updated_at = now()
contact_name as "contactName", contact_phone as "contactPhone", returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId",
contact_email as "contactEmail", billing_address as "billingAddress", contact_name as "contactName", contact_phone as "contactPhone",
invoice_title as "invoiceTitle", invoice_type as "invoiceType", contact_email as "contactEmail", billing_address as "billingAddress",
bank_name as "bankName", bank_account_masked as "bankAccountMasked", invoice_title as "invoiceTitle", invoice_type as "invoiceType",
metadata, updated_at as "updatedAt" bank_name as "bankName", bank_account_masked as "bankAccountMasked",
`, metadata, updated_at as "updatedAt"
[ `,
tenantId, [
optionalString(body, 'billingName') || null, tenantId,
optionalString(body, 'taxId') || null, optionalString(body, 'billingName') || null,
optionalString(body, 'contactName') || null, optionalString(body, 'taxId') || null,
optionalString(body, 'contactPhone') || null, optionalString(body, 'contactName') || null,
optionalString(body, 'contactEmail') || null, optionalString(body, 'contactPhone') || null,
optionalString(body, 'billingAddress') || null, optionalString(body, 'contactEmail') || null,
optionalString(body, 'invoiceTitle') || null, optionalString(body, 'billingAddress') || null,
optionalString(body, 'invoiceType') || 'none', optionalString(body, 'invoiceTitle') || null,
optionalString(body, 'bankName') || null, invoiceType,
optionalString(body, 'bankAccountMasked') || null, optionalString(body, 'bankName') || null,
jsonBodyValue(body.metadata), optionalString(body, 'bankAccountMasked') || null,
], jsonBodyValue(body.metadata),
); ],
);
await recordPlatformAudit(client, ctx, 'platform.tenant.billing_profile_upserted', 'tenant_billing_profile', tenantId, {
billingName: optionalString(body, 'billingName') || null,
contactName: optionalString(body, 'contactName') || null,
contactPhoneSet: Boolean(optionalString(body, 'contactPhone')),
contactEmailSet: Boolean(optionalString(body, 'contactEmail')),
invoiceType,
bankAccountMaskedSet: Boolean(optionalString(body, 'bankAccountMasked')),
}, tenantId);
return result.rows[0];
});
return { item }; return { item };
} }

View File

@@ -3,10 +3,15 @@ import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components'; import { Button, Input, Text, View } from '@tarojs/components';
import { import {
createPlatformTenant, createPlatformTenant,
loadPlatformAuditLogs,
loadPlatformPlans, loadPlatformPlans,
loadPlatformTenantDetail,
loadPlatformTenants, loadPlatformTenants,
updatePlatformTenantStatus, updatePlatformTenantStatus,
upsertPlatformTenantBillingProfile,
type PlatformAuditLogItem,
type PlatformSaasPlan, type PlatformSaasPlan,
type PlatformTenantDetail,
type PlatformTenantItem, type PlatformTenantItem,
} from '@/services/platformAdmin'; } from '@/services/platformAdmin';
import '../platform.css'; import '../platform.css';
@@ -21,11 +26,41 @@ function centsFromYuan(value: string) {
return Math.round(amount * 100); return Math.round(amount * 100);
} }
function dateText(value?: string | null) {
return value ? String(value).slice(0, 10) : '-';
}
function auditDetailsText(item: PlatformAuditLogItem) {
try {
const text = JSON.stringify(item.details || {});
return text.length > 160 ? `${text.slice(0, 160)}...` : text;
} catch {
return '{}';
}
}
const emptyBillingForm = {
tenantId: '',
billingName: '',
taxId: '',
contactName: '',
contactPhone: '',
contactEmail: '',
billingAddress: '',
invoiceTitle: '',
invoiceType: 'none',
bankName: '',
bankAccountMasked: '',
};
export default function PlatformTenantsPage() { export default function PlatformTenantsPage() {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState(''); const [status, setStatus] = useState('');
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]); const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]); const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [detail, setDetail] = useState<PlatformTenantDetail | null>(null);
const [auditLogs, setAuditLogs] = useState<PlatformAuditLogItem[]>([]);
const [tenantForm, setTenantForm] = useState({ const [tenantForm, setTenantForm] = useState({
slug: '', slug: '',
name: '', name: '',
@@ -42,6 +77,7 @@ export default function PlatformTenantsPage() {
billingStatus: '', billingStatus: '',
reason: '', reason: '',
}); });
const [billingForm, setBillingForm] = useState(emptyBillingForm);
const [busy, setBusy] = useState(''); const [busy, setBusy] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -51,6 +87,43 @@ export default function PlatformTenantsPage() {
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '租户列表加载失败')); .catch(nextError => setError(nextError instanceof Error ? nextError.message : '租户列表加载失败'));
} }
function fillBillingForm(nextDetail: PlatformTenantDetail | null, fallbackTenantId = '') {
const tenant = nextDetail?.tenant;
setBillingForm({
tenantId: tenant?.id || fallbackTenantId,
billingName: tenant?.billingName || '',
taxId: tenant?.taxId || '',
contactName: tenant?.contactName || '',
contactPhone: tenant?.contactPhone || '',
contactEmail: tenant?.contactEmail || '',
billingAddress: tenant?.billingAddress || '',
invoiceTitle: tenant?.invoiceTitle || '',
invoiceType: tenant?.invoiceType || 'none',
bankName: tenant?.bankName || '',
bankAccountMasked: tenant?.bankAccountMasked || '',
});
}
async function loadDetail(tenantId: string) {
if (!tenantId) return;
setError('');
setBusy('detail');
try {
const [detailPayload, auditPayload] = await Promise.all([
loadPlatformTenantDetail(tenantId),
loadPlatformAuditLogs({ tenantId, limit: 30 }).catch(() => ({ items: [] })),
]);
const nextDetail = detailPayload.item || null;
setDetail(nextDetail);
fillBillingForm(nextDetail, tenantId);
setAuditLogs(auditPayload.items || []);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '租户详情加载失败');
} finally {
setBusy('');
}
}
useEffect(() => { useEffect(() => {
reload('', ''); reload('', '');
loadPlatformPlans().then(payload => { loadPlatformPlans().then(payload => {
@@ -73,6 +146,22 @@ export default function PlatformTenantsPage() {
setStatusForm(current => ({ ...current, [key]: value })); setStatusForm(current => ({ ...current, [key]: value }));
} }
function updateBillingForm(key: keyof typeof billingForm, value: string) {
setBillingForm(current => ({ ...current, [key]: value }));
}
function chooseTenant(item: PlatformTenantItem) {
setSelectedTenantId(item.id);
setStatusForm(current => ({
...current,
tenantId: item.id,
status: item.status || 'active',
billingStatus: item.billingStatus || '',
}));
fillBillingForm(null, item.id);
loadDetail(item.id);
}
async function confirm(title: string, content: string) { async function confirm(title: string, content: string) {
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' }); const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
return result.confirm; return result.confirm;
@@ -90,7 +179,7 @@ export default function PlatformTenantsPage() {
if (!ok) return; if (!ok) return;
setBusy('create'); setBusy('create');
try { try {
await createPlatformTenant({ const payload = await createPlatformTenant({
slug, slug,
name, name,
legalName: tenantForm.legalName.trim() || undefined, legalName: tenantForm.legalName.trim() || undefined,
@@ -112,6 +201,10 @@ export default function PlatformTenantsPage() {
amountYuan: '', amountYuan: '',
})); }));
reload(status, keyword); reload(status, keyword);
if (payload.item?.id) {
setSelectedTenantId(payload.item.id);
await loadDetail(payload.item.id);
}
} catch (nextError) { } catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '创建租户失败'); setError(nextError instanceof Error ? nextError.message : '创建租户失败');
} finally { } finally {
@@ -141,6 +234,7 @@ export default function PlatformTenantsPage() {
}); });
Taro.showToast({ title: '已更新', icon: 'success' }); Taro.showToast({ title: '已更新', icon: 'success' });
reload(status, keyword); reload(status, keyword);
await loadDetail(statusForm.tenantId);
} catch (nextError) { } catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '状态更新失败'); setError(nextError instanceof Error ? nextError.message : '状态更新失败');
} finally { } finally {
@@ -148,13 +242,47 @@ export default function PlatformTenantsPage() {
} }
} }
async function submitBillingProfile() {
setError('');
if (!billingForm.tenantId) {
setError('请先选择租户,再维护账务资料。');
return;
}
const ok = await confirm('保存账务资料', '确认更新该租户的开票和收款联系资料?');
if (!ok) return;
setBusy('billing');
try {
await upsertPlatformTenantBillingProfile({
tenantId: billingForm.tenantId,
billingName: billingForm.billingName.trim() || undefined,
taxId: billingForm.taxId.trim() || undefined,
contactName: billingForm.contactName.trim() || undefined,
contactPhone: billingForm.contactPhone.trim() || undefined,
contactEmail: billingForm.contactEmail.trim() || undefined,
billingAddress: billingForm.billingAddress.trim() || undefined,
invoiceTitle: billingForm.invoiceTitle.trim() || undefined,
invoiceType: billingForm.invoiceType.trim() || 'none',
bankName: billingForm.bankName.trim() || undefined,
bankAccountMasked: billingForm.bankAccountMasked.trim() || undefined,
});
Taro.showToast({ title: '已保存', icon: 'success' });
await loadDetail(billingForm.tenantId);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '账务资料保存失败');
} finally {
setBusy('');
}
}
const selectedTenant = detail?.tenant;
return ( return (
<View className='platform-page'> <View className='platform-page'>
<View className='platform-shell'> <View className='platform-shell'>
<View className='platform-header'> <View className='platform-header'>
<Text className='platform-kicker'>Tenants</Text> <Text className='platform-kicker'>Tenants</Text>
<Text className='platform-title'></Text> <Text className='platform-title'></Text>
<Text className='platform-subtitle'> SaaS API</Text> <Text className='platform-subtitle'> SaaS </Text>
</View> </View>
<View className='platform-actions'> <View className='platform-actions'>
@@ -176,7 +304,7 @@ export default function PlatformTenantsPage() {
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.length)}</Text></View> <View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => Number(item.openBalanceCents || 0) > 0).length)}</Text></View> <View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => Number(item.openBalanceCents || 0) > 0).length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => item.billingStatus === 'trial').length)}</Text></View> <View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => item.billingStatus === 'trial').length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => item.status === 'suspended').length)}</Text></View> <View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant ? selectedTenant.slug : '-'}</Text></View>
</View> </View>
<View className='platform-section'> <View className='platform-section'>
@@ -196,19 +324,6 @@ export default function PlatformTenantsPage() {
</View> </View>
</View> </View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='从列表选择或粘贴 tenantId' value={statusForm.tenantId} onInput={event => updateStatusForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='active / suspended' value={statusForm.status} onInput={event => updateStatusForm('status', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='trial / active / past_due' value={statusForm.billingStatus} onInput={event => updateStatusForm('billingStatus', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='内部审计备注' value={statusForm.reason} onInput={event => updateStatusForm('reason', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'status'} onClick={submitStatusChange}></Button>
</View>
</View>
<View className='platform-section'> <View className='platform-section'>
<Text className='platform-section-title'></Text> <Text className='platform-section-title'></Text>
<View className='platform-list'> <View className='platform-list'>
@@ -216,17 +331,114 @@ export default function PlatformTenantsPage() {
<View className='platform-row' key={item.id}> <View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.brandName || item.name}</Text> <Text className='platform-row-main'>{item.brandName || item.name}</Text>
<Text className='platform-row-meta'>{item.slug} · {item.legalName || '未填公司'} · {item.status || '-'} · {item.billingStatus || '-'}</Text> <Text className='platform-row-meta'>{item.slug} · {item.legalName || '未填公司'} · {item.status || '-'} · {item.billingStatus || '-'}</Text>
<Text className='platform-row-meta'> {item.planCode || '未订阅'} · {item.subscriptionStatus || '-'} · {item.subscriptionExpiresAt ? String(item.subscriptionExpiresAt).slice(0, 10) : '-'}</Text> <Text className='platform-row-meta'> {item.planCode || '未订阅'} · {item.subscriptionStatus || '-'} · {dateText(item.subscriptionExpiresAt)}</Text>
<Text className='platform-row-meta'> {money(item.openBalanceCents)}</Text> <Text className='platform-row-meta'> {money(item.openBalanceCents)}</Text>
<View className='platform-row-actions'> <View className='platform-row-actions'>
<Button className='platform-mini-button' onClick={() => setStatusForm(current => ({ ...current, tenantId: item.id, status: item.status || 'active', billingStatus: item.billingStatus || '' }))}></Button> <Button className='platform-mini-button' loading={busy === 'detail' && selectedTenantId === item.id} onClick={() => chooseTenant(item)}></Button>
<Button className='platform-mini-button danger' onClick={() => setStatusForm({ tenantId: item.id, status: 'suspended', billingStatus: item.billingStatus || '', reason: 'platform manual suspend' })}></Button> <Button className='platform-mini-button danger' onClick={() => {
setSelectedTenantId(item.id);
setStatusForm({ tenantId: item.id, status: 'suspended', billingStatus: item.billingStatus || '', reason: 'platform manual suspend' });
fillBillingForm(null, item.id);
}}></Button>
</View> </View>
</View> </View>
))} ))}
</View> </View>
{!tenants.length ? <View className='platform-empty'></View> : null} {!tenants.length ? <View className='platform-empty'></View> : null}
</View> </View>
{selectedTenant ? (
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant.brandName || selectedTenant.name}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant.status || '-'}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant.billingStatus || '-'}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant.legalName || '-'}</Text></View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='tenantId' value={statusForm.tenantId} onInput={event => updateStatusForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='active / suspended' value={statusForm.status} onInput={event => updateStatusForm('status', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='trial / active / past_due' value={statusForm.billingStatus} onInput={event => updateStatusForm('billingStatus', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='内部审计备注' value={statusForm.reason} onInput={event => updateStatusForm('reason', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'status'} onClick={submitStatusChange}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' value={billingForm.tenantId} onInput={event => updateBillingForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='公司或个人抬头' value={billingForm.billingName} onInput={event => updateBillingForm('billingName', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='可选' value={billingForm.taxId} onInput={event => updateBillingForm('taxId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.contactName} onInput={event => updateBillingForm('contactName', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.contactPhone} onInput={event => updateBillingForm('contactPhone', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.contactEmail} onInput={event => updateBillingForm('contactEmail', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='none / normal_vat / special_vat' value={billingForm.invoiceType} onInput={event => updateBillingForm('invoiceType', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.invoiceTitle} onInput={event => updateBillingForm('invoiceTitle', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.billingAddress} onInput={event => updateBillingForm('billingAddress', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.bankName} onInput={event => updateBillingForm('bankName', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='仅保存掩码' value={billingForm.bankAccountMasked} onInput={event => updateBillingForm('bankAccountMasked', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'billing'} onClick={submitBillingProfile}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/billing/index' })}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'> / / / </Text>
<View className='platform-list'>
{(detail?.domains || []).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.host || item.id}</Text>
<Text className='platform-row-meta'>{item.domainType || 'domain'} · {item.status || '-'} · {item.isPrimary ? 'primary' : 'secondary'} · {dateText(item.verifiedAt)}</Text>
</View>
))}
{(detail?.subscriptions || []).slice(0, 3).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.planCode || item.id}</Text>
<Text className='platform-row-meta'>{item.status || '-'} · {item.billingCycle || '-'} · {money(item.amountCents)} · {dateText(item.startsAt)} {dateText(item.expiresAt)}</Text>
</View>
))}
{(detail?.invoices || []).slice(0, 4).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.invoiceNo || item.id}</Text>
<Text className='platform-row-meta'>{item.status || '-'} · {money(item.totalCents)} · {money(item.paidCents)} · {money(item.balanceCents)} · {dateText(item.dueDate)}</Text>
</View>
))}
{(detail?.usage || []).slice(0, 6).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.metricKey || 'metric'}</Text>
<Text className='platform-row-meta'>{String(item.metricValue || 0)} · {dateText(item.periodStart)} {dateText(item.periodEnd)}</Text>
</View>
))}
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{auditLogs.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.action || '-'}</Text>
<Text className='platform-row-meta'>{dateText(item.createdAt)} · {item.actorName || item.actorUsername || item.actorPhone || 'system'} · {item.targetType || '-'} · {item.targetId || '-'}</Text>
<Text className='platform-row-meta'>{auditDetailsText(item)}</Text>
</View>
))}
</View>
{!auditLogs.length ? <View className='platform-empty'></View> : null}
</View>
</View>
) : (
<View className='platform-empty'></View>
)}
{error ? <Text className='platform-error'>{error}</Text> : null} {error ? <Text className='platform-error'>{error}</Text> : null}
</View> </View>
</View> </View>

View File

@@ -2,11 +2,13 @@ import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components'; import { Button, Text, View } from '@tarojs/components';
import { import {
loadPlatformAuditLogs,
loadPlatformInvoices, loadPlatformInvoices,
loadPlatformOverview, loadPlatformOverview,
loadPlatformQuestionBankGrants, loadPlatformQuestionBankGrants,
loadPlatformQuestionBanks, loadPlatformQuestionBanks,
loadPlatformTenants, loadPlatformTenants,
type PlatformAuditLogItem,
type PlatformInvoiceItem, type PlatformInvoiceItem,
type PlatformOverview, type PlatformOverview,
type PlatformQuestionBankGrant, type PlatformQuestionBankGrant,
@@ -23,6 +25,7 @@ export default function PlatformWorkbenchPage() {
const [overview, setOverview] = useState<PlatformOverview | null>(null); const [overview, setOverview] = useState<PlatformOverview | null>(null);
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]); const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]); const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [auditLogs, setAuditLogs] = useState<PlatformAuditLogItem[]>([]);
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]); const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]); const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -34,12 +37,14 @@ export default function PlatformWorkbenchPage() {
loadPlatformInvoices({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformInvoices({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformQuestionBanks({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformQuestionBanks({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformQuestionBankGrants({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformQuestionBankGrants({ limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload]) => { loadPlatformAuditLogs({ limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload]) => {
setOverview(overviewPayload.item || null); setOverview(overviewPayload.item || null);
setTenants(tenantPayload.items || []); setTenants(tenantPayload.items || []);
setInvoices(invoicePayload.items || []); setInvoices(invoicePayload.items || []);
setBanks(bankPayload.items || []); setBanks(bankPayload.items || []);
setGrants(grantPayload.items || []); setGrants(grantPayload.items || []);
setAuditLogs(auditPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败')); }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败'));
}, []); }, []);
@@ -105,6 +110,18 @@ export default function PlatformWorkbenchPage() {
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(overview?.billing?.overdueInvoices || 0)}</Text></View> <View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(overview?.billing?.overdueInvoices || 0)}</Text></View>
</View> </View>
</View> </View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{auditLogs.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.action || '-'}</Text>
<Text className='platform-row-meta'>{item.tenantName || item.tenantSlug || '平台'} · {item.actorName || item.actorUsername || 'system'} · {item.targetType || '-'} · {String(item.createdAt || '').slice(0, 19).replace('T', ' ')}</Text>
</View>
))}
</View>
{!auditLogs.length ? <View className='platform-empty'></View> : null}
</View>
{error ? <Text className='platform-error'>{error}</Text> : null} {error ? <Text className='platform-error'>{error}</Text> : null}
</View> </View>
</View> </View>

View File

@@ -54,6 +54,60 @@ export interface PlatformTenantItem {
openBalanceCents?: number | string | null; openBalanceCents?: number | string | null;
} }
export interface PlatformTenantBillingProfile {
tenantId?: string | null;
billingName?: string | null;
taxId?: string | null;
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
billingAddress?: string | null;
invoiceTitle?: string | null;
invoiceType?: string | null;
bankName?: string | null;
bankAccountMasked?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface PlatformTenantDetailTenant extends PlatformTenantItem, PlatformTenantBillingProfile {
shortName?: string | null;
serviceWechat?: string | null;
ownerUserId?: string | null;
metadata?: Record<string, unknown> | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PlatformTenantDomain {
id: string;
host?: string | null;
domainType?: string | null;
status?: string | null;
isPrimary?: boolean | null;
verifiedAt?: string | null;
createdAt?: string | null;
}
export interface PlatformTenantSubscription {
id: string;
planCode?: string | null;
status?: string | null;
startsAt?: string | null;
expiresAt?: string | null;
billingCycle?: string | null;
amountCents?: number | string | null;
metadata?: Record<string, unknown> | null;
createdAt?: string | null;
}
export interface PlatformTenantDetail {
tenant?: PlatformTenantDetailTenant | null;
domains?: PlatformTenantDomain[];
subscriptions?: PlatformTenantSubscription[];
invoices?: PlatformInvoiceItem[];
usage?: PlatformUsageItem[];
}
export interface PlatformInvoiceItem { export interface PlatformInvoiceItem {
id: string; id: string;
tenantId: string; tenantId: string;
@@ -113,6 +167,24 @@ export interface PlatformQuestionBankGrant {
expiresAt?: string | null; expiresAt?: string | null;
} }
export interface PlatformAuditLogItem {
id: string;
tenantId?: string | null;
tenantSlug?: string | null;
tenantName?: string | null;
actorUserId?: string | null;
actorUsername?: string | null;
actorName?: string | null;
actorPhone?: string | null;
action?: string | null;
targetType?: string | null;
targetId?: string | null;
details?: Record<string, unknown> | null;
ipAddress?: string | null;
userAgent?: string | null;
createdAt?: string | null;
}
export interface CreatePlatformTenantInput { export interface CreatePlatformTenantInput {
slug: string; slug: string;
name: string; name: string;
@@ -133,6 +205,10 @@ export interface UpdatePlatformTenantStatusInput {
reason?: string; reason?: string;
} }
export interface UpsertPlatformTenantBillingProfileInput extends PlatformTenantBillingProfile {
tenantId: string;
}
export interface CreatePlatformSubscriptionInput { export interface CreatePlatformSubscriptionInput {
tenantId: string; tenantId: string;
planCode: string; planCode: string;
@@ -198,6 +274,20 @@ export async function loadPlatformTenants(query: { q?: string; status?: string;
}); });
} }
export async function loadPlatformTenantDetail(tenantId: string) {
return apiRequest<{ item?: PlatformTenantDetail }>('/api/platform-admin/tenants/detail', {
query: { tenantId },
tenantId: null,
});
}
export async function loadPlatformAuditLogs(query: { tenantId?: string; action?: string; targetType?: string; actorUserId?: string; q?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformAuditLogItem[] }>('/api/platform-admin/audit-logs', {
query: { ...query, limit: query.limit || 100 },
tenantId: null,
});
}
export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) { export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', { return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', {
query: { ...query, limit: query.limit || 80 }, query: { ...query, limit: query.limit || 80 },
@@ -242,6 +332,14 @@ export async function updatePlatformTenantStatus(input: UpdatePlatformTenantStat
}); });
} }
export async function upsertPlatformTenantBillingProfile(input: UpsertPlatformTenantBillingProfileInput) {
return apiRequest<{ item?: PlatformTenantBillingProfile }>('/api/platform-admin/tenants/billing-profile', {
method: 'PUT',
body: input,
tenantId: null,
});
}
export async function createPlatformSubscription(input: CreatePlatformSubscriptionInput) { export async function createPlatformSubscription(input: CreatePlatformSubscriptionInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/subscriptions', { return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/subscriptions', {
method: 'POST', method: 'POST',

View File

@@ -1,6 +1,6 @@
# 后端当前能力盘点 # 后端当前能力盘点
更新时间2026-06-29 更新时间2026-06-30
当前后端已经完成商用 SaaS 题库系统的主干骨架PostgreSQL 多租户 schema、Node.js 业务 API、PocketBase 数据导入工具、本地 seed、API 集成测试和对象存储签名 provider。 当前后端已经完成商用 SaaS 题库系统的主干骨架PostgreSQL 多租户 schema、Node.js 业务 API、PocketBase 数据导入工具、本地 seed、API 集成测试和对象存储签名 provider。
@@ -145,7 +145,7 @@
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 | | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 |
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 | | 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 | | 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
| 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` | | 平台租户/详情/账务资料/审计/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、收款和用量;创建租户、状态变更、账务资料维护会写入审计 |
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks``question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 | | 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks``question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks``public-question-banks/adopt``public-question-banks/sync``public-question-banks/conflicts``public-question-banks/conflicts/resolve``public-question-banks/conflicts/resolve-batch``tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved | | 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks``public-question-banks/adopt``public-question-banks/sync``public-question-banks/conflicts``public-question-banks/conflicts/resolve``public-question-banks/conflicts/resolve-batch``tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved |

View File

@@ -1,6 +1,6 @@
# 后端进度同步与前端接入路线图 # 后端进度同步与前端接入路线图
更新时间2026-06-29 更新时间2026-06-30
这份文档用于在进入 Taro 前端开发前,快速确认新 Supabase/PostgreSQL 后端已经做到哪里、还缺什么、前端应如何接入,以及后续继续开发的优先级。 这份文档用于在进入 Taro 前端开发前,快速确认新 Supabase/PostgreSQL 后端已经做到哪里、还缺什么、前端应如何接入,以及后续继续开发的优先级。
@@ -21,7 +21,7 @@
| 模块 | 当前状态 | 已经具备 | 上线前还要补 | | 模块 | 当前状态 | 已经具备 | 上线前还要补 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 | | 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、批量账单、平台审计报表增强、更完整运营消息 |
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
@@ -35,7 +35,7 @@
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 | | 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 | | AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 |
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台后台审计/详情增强、小程序兼容验证和端到端测试 | | Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑和平台审计第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台后台自动计费/批量账单/审计报表增强、小程序兼容验证和端到端测试 |
## 前端接入建议 ## 前端接入建议
@@ -79,7 +79,7 @@
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。 - 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
- 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。 - 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。
- 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 - 生产环境配置:`.env.example``npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
- Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台后台审计增强和小程序兼容验证。 - Taro scaffold`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑和最近平台审计第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台后台自动计费/批量账单/审计报表增强和小程序兼容验证。
### P1商用收费和运营能力 ### P1商用收费和运营能力
@@ -93,7 +93,7 @@
### P2企业级体验和增长闭环 ### P2企业级体验和增长闭环
- 租户自定义角色模板基础 API、Taro 权限配置 UI、成员绑定模板和工作台权限驱动入口第一版已完成继续补班级/教师/学生组合范围 UI、成员批量运营和平台级审计报表。 - 租户自定义角色模板基础 API、Taro 权限配置 UI、成员绑定模板和工作台权限驱动入口第一版已完成继续补班级/教师/学生组合范围 UI、成员批量运营和更完整平台级审计报表。
- 三套默认主题、租户主题预览、Logo/图标/分享图配置。 - 三套默认主题、租户主题预览、Logo/图标/分享图配置。
- CRM worker钉钉、飞书、企微机器人发送、签名、失败重试已落地Taro 营销中心已能配置 CRM 和查看队列;继续补轮询/定向分配、富卡片、失败告警和死信运营台。 - CRM worker钉钉、飞书、企微机器人发送、签名、失败重试已落地Taro 营销中心已能配置 CRM 和查看队列;继续补轮询/定向分配、富卡片、失败告警和死信运营台。
- 销售/代理分佣基础闭环已接 Taro 第一版;继续补销售团队看板、客资跟进效果、结算导出、真实打款、凭证和财务复核。 - 销售/代理分佣基础闭环已接 Taro 第一版;继续补销售团队看板、客资跟进效果、结算导出、真实打款、凭证和财务复核。

View File

@@ -2,6 +2,7 @@
## 已完成 ## 已完成
- 更新时间2026-06-30。
- Docker Desktop + Supabase local 已可用。 - Docker Desktop + Supabase local 已可用。
- API Docker 镜像 `tiku-saas-dev-api:latest` 已可构建,并可从容器连接宿主 Supabase PostgreSQL。 - API Docker 镜像 `tiku-saas-dev-api:latest` 已可构建,并可从容器连接宿主 Supabase PostgreSQL。
- API 已按 `core/features` 分层: - API 已按 `core/features` 分层:
@@ -13,11 +14,12 @@
- `video`:题目视频讲解、批量预加载、通用视频搜索。 - `video`:题目视频讲解、批量预加载、通用视频搜索。
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。 - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
- `platform-admin`平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。 - `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、使用量。
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
- `tenant`:域名/租户解析。 - `tenant`:域名/租户解析。
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 - 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者和关键词查询平台审计。创建租户、状态变更和账务资料维护会写入审计日志API 集成测试已覆盖平台管理员可操作、学生越权拒绝和审计记录存在。
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 - 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
- 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。 - 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
@@ -54,6 +56,7 @@ POST /api/platform-admin/tenants
GET /api/platform-admin/tenants/detail GET /api/platform-admin/tenants/detail
PATCH /api/platform-admin/tenants/status PATCH /api/platform-admin/tenants/status
PUT /api/platform-admin/tenants/billing-profile PUT /api/platform-admin/tenants/billing-profile
GET /api/platform-admin/audit-logs
POST /api/platform-admin/subscriptions POST /api/platform-admin/subscriptions
GET /api/platform-admin/invoices GET /api/platform-admin/invoices
POST /api/platform-admin/invoices POST /api/platform-admin/invoices

View File

@@ -15,7 +15,7 @@
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 | | 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略地区/全国套餐权限、平台侧主题模板库、平台审计 | | 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量记录公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、自动计费、批量账单和平台审计报表增强 |
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | | 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 | | 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |

View File

@@ -1,6 +1,6 @@
# 前端交接索引 # 前端交接索引
更新时间2026-06-29 更新时间2026-06-30
这份文件是给 Taro/H5/小程序前端同事的入口。当前仓库的前端重构建议从 `apps/taro` 新建工程开始,不再把旧 React/Vite 前端搬回根目录继续开发。 这份文件是给 Taro/H5/小程序前端同事的入口。当前仓库的前端重构建议从 `apps/taro` 新建工程开始,不再把旧 React/Vite 前端搬回根目录继续开发。
@@ -31,7 +31,7 @@
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。 - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 - 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。 - 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。
- 可以按新后端主模型接入内容导航: - 可以按新后端主模型接入内容导航:
- `content_entries` - `content_entries`
@@ -106,9 +106,9 @@
| 页面 | 文件 | 已接接口 | | 页面 | 文件 | 已接接口 |
| --- | --- | --- | | --- | --- | --- |
| 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/overview``tenants``invoices``question-banks``question-bank-grants` | | 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/overview``tenants``invoices``question-banks``question-bank-grants``audit-logs` |
| 租户管理 | `apps/taro/src/pages/platform-admin/tenants/index.tsx` | `platform-admin/tenants``POST tenants``PATCH tenants/status` | | 租户管理 | `apps/taro/src/pages/platform-admin/tenants/index.tsx` | `platform-admin/tenants``POST tenants``tenants/detail``PATCH tenants/status``PUT tenants/billing-profile``audit-logs` |
| 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans``invoices``usage``subscriptions``invoices/from-subscription``invoices/payments/manual-confirm``POST usage` | | 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans``invoices``usage``subscriptions``invoices/from-subscription``invoices/payments/manual-confirm``POST usage` |
| 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks``question-bank-grants``PUT question-bank-grants` | | 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks``question-bank-grants``PUT question-bank-grants` |
当前平台后台已经具备第一批写操作台:创建租户、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户详情页、编辑租户基础资料、平台审计报表、自动计费、账单批量操作和更细平台权限点。 当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费、账单批量操作和更细平台权限点。

View File

@@ -1,6 +1,6 @@
# Supabase 重构功能进度矩阵 # Supabase 重构功能进度矩阵
更新时间2026-06-29 更新时间2026-06-30
## 当前结论 ## 当前结论
@@ -37,7 +37,7 @@
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 | | 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志 | 不适用 | 租户管理、租户详情、账务资料维护、平台审计日志、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台租户创建、详情、账务资料更新、状态变更、审计查询和学生越权拒绝 | 平台收费和租户运营链路骨架可用,自动计费、批量账单和更完整平台审计报表待补 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 | | 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 |
| 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run``pb:import:validate``test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run、导入回归和抽样验收 | | 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run``pb:import:validate``test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run、导入回归和抽样验收 |
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | | 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
@@ -280,6 +280,7 @@ platform-admin:
GET /api/platform-admin/tenants/detail GET /api/platform-admin/tenants/detail
PATCH /api/platform-admin/tenants/status PATCH /api/platform-admin/tenants/status
PUT /api/platform-admin/tenants/billing-profile PUT /api/platform-admin/tenants/billing-profile
GET /api/platform-admin/audit-logs
POST /api/platform-admin/subscriptions POST /api/platform-admin/subscriptions
GET /api/platform-admin/invoices GET /api/platform-admin/invoices
POST /api/platform-admin/invoices POST /api/platform-admin/invoices

View File

@@ -1,6 +1,6 @@
# 旧题库功能差距矩阵 # 旧题库功能差距矩阵
更新时间2026-06-29 更新时间2026-06-30
旧项目位于 `F:\project\参考\旧题库项目`,只作为功能、样式、交互和迁移参考。新项目不再复刻 PocketBase/SQLite 的数据结构,而是以新多租户 SaaS 模型为准。 旧项目位于 `F:\project\参考\旧题库项目`,只作为功能、样式、交互和迁移参考。新项目不再复刻 PocketBase/SQLite 的数据结构,而是以新多租户 SaaS 模型为准。
@@ -73,13 +73,13 @@
| 功能 | 新后端状态 | 待补齐 | | 功能 | 新后端状态 | 待补齐 |
| --- | --- | --- | | --- | --- | --- |
| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户和状态变更已接真实 API编辑租户基础资料、详情页和平台审计报表待补 | | 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护和最近平台审计已接真实 API后续补自动计费、批量账单、租户基础资料编辑增强和更完整平台审计报表 |
| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 |
| 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 | | 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 |
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 |
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、同步通知、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺生产定时调度、失败告警和更完整运营消息 | | 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、同步通知、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺生产定时调度、失败告警和更完整运营消息 |
| 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 | | 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 |
| 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 | | 租户安全审计 | 部分覆盖 | 租户侧 audit logs 和平台侧 `/api/platform-admin/audit-logs` 第一版已有,平台后台工作台/租户详情页可查看最近审计;缺更完整筛选报表、导出和告警 |
## 旧功能中应重新设计的点 ## 旧功能中应重新设计的点

View File

@@ -1,6 +1,6 @@
# 后续开发 TODO # 后续开发 TODO
更新时间2026-06-29 更新时间2026-06-30
## 当前后端基线 ## 当前后端基线
@@ -11,7 +11,7 @@
- 学生端核心 API题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。 - 学生端核心 API题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。
- 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
- 租户主题系统平台默认经典蓝、专注绿、高对比三套模板租户可保存草稿、发布主题公开租户解析只返回已发布主题Taro 租户设置页已接第一版主题操作台。 - 租户主题系统平台默认经典蓝、专注绿、高对比三套模板租户可保存草稿、发布主题公开租户解析只返回已发布主题Taro 租户设置页已接第一版主题操作台。
- 平台后台 API租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 平台后台 API租户、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量。
- 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。 - 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
@@ -185,7 +185,7 @@
- H5 和小程序共用同一套业务 API client。 - H5 和小程序共用同一套业务 API client。
- 租户通过域名、小程序配置或启动参数解析。 - 租户通过域名、小程序配置或启动参数解析。
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。 - 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 - 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
### 第一批页面 ### 第一批页面
@@ -238,7 +238,7 @@
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。 1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 3. 补平台后台增强:租户基础资料编辑增强、平台审计报表导出/告警、自动计费、账单批量操作和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。 4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。 5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。
6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。

View File

@@ -1,6 +1,6 @@
# Taro 前端对接指南 # Taro 前端对接指南
更新时间2026-06-29 更新时间2026-06-30
目标:用一套 Taro 工程同时服务微信小程序和 H5 Web 题库并采用“Supabase Auth/JWT + `apps/api` 业务 API 优先”的混合架构,支持多租户、品牌主题、地区题库、会员权益、销售追踪和对象存储资源。 目标:用一套 Taro 工程同时服务微信小程序和 H5 Web 题库并采用“Supabase Auth/JWT + `apps/api` 业务 API 优先”的混合架构,支持多租户、品牌主题、地区题库、会员权益、销售追踪和对象存储资源。
@@ -2519,7 +2519,7 @@ src/services/ai.ts AI 择校推荐生成、报告列表、报告详情
src/services/pronunciation.ts H5/小程序单词发音适配 src/services/pronunciation.ts H5/小程序单词发音适配
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录 src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录
src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证 src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证
src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权 src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计、套餐账单、用量、公共题库授权
``` ```
验证命令: 验证命令:
@@ -2537,7 +2537,7 @@ npm run build:taro:h5:platform
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。 - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。 - 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。 - 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费和批量账单操作。
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。 - 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
## AI 择校推荐接入 ## AI 择校推荐接入

View File

@@ -1138,6 +1138,108 @@ async function testLegacyAuthHeadersDisabled() {
assert.equal(legacyAdminKey.code, 'PLATFORM_ADMIN_KEY_DISABLED', 'platform admin key should be disabled when configured off'); assert.equal(legacyAdminKey.code, 'PLATFORM_ADMIN_KEY_DISABLED', 'platform admin key should be disabled when configured off');
} }
async function testPlatformTenantOperationsAndAudit() {
const adminHeaders = { 'x-platform-admin-key': 'local-platform-admin-key' };
const slug = `integration-tenant-${Date.now().toString(36)}`;
const created = await request('/api/platform-admin/tenants', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'POST',
body: {
slug,
name: '集成测试合作商',
legalName: '集成测试合作商有限公司',
brandName: '集成测试题库',
primaryHost: `${slug}.example.test`,
planCode: 'starter_yearly',
billingStatus: 'trial',
amountCents: 120000,
billing: {
billingName: '集成测试合作商有限公司',
taxId: 'TESTTAXNO',
contactName: '平台测试联系人',
contactPhone: '13800001111',
invoiceTitle: '集成测试合作商有限公司',
invoiceType: 'normal_vat',
},
},
});
assert.ok(created.item?.id, 'platform admin should create a tenant');
const tenantId = created.item.id;
const detail = await request('/api/platform-admin/tenants/detail', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId },
});
assert.equal(detail.item?.tenant?.slug, slug, 'tenant detail should return created tenant');
assert.ok(detail.item?.domains?.some(item => item.host === `${slug}.example.test`), 'tenant detail should include primary host');
assert.ok(detail.item?.subscriptions?.some(item => item.planCode === 'starter_yearly'), 'tenant detail should include subscription');
const billing = await request('/api/platform-admin/tenants/billing-profile', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PUT',
body: {
tenantId,
billingName: '集成测试更新主体',
taxId: 'UPDATEDTAXNO',
contactName: '更新联系人',
contactPhone: '13800002222',
contactEmail: 'finance@example.test',
invoiceTitle: '集成测试更新主体',
invoiceType: 'special_vat',
bankName: '测试银行',
bankAccountMasked: '****2222',
},
});
assert.equal(billing.item?.billingName, '集成测试更新主体', 'platform admin should update tenant billing profile');
const status = await request('/api/platform-admin/tenants/status', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PATCH',
body: {
tenantId,
status: 'active',
billingStatus: 'active',
reason: 'integration audit coverage',
},
});
assert.equal(status.item?.billingStatus, 'active', 'platform admin should update tenant billing status');
const updatedDetail = await request('/api/platform-admin/tenants/detail', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId },
});
assert.equal(updatedDetail.item?.tenant?.billingName, '集成测试更新主体', 'tenant detail should include updated billing profile');
assert.equal(updatedDetail.item?.tenant?.bankAccountMasked, '****2222', 'tenant detail should only expose masked bank account');
const audit = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: false,
headers: adminHeaders,
query: { tenantId, limit: 20 },
});
const actions = new Set((audit.items || []).map(item => item.action));
assert.ok(actions.has('platform.tenant.created'), 'platform audit should include tenant creation');
assert.ok(actions.has('platform.tenant.billing_profile_upserted'), 'platform audit should include billing profile update');
assert.ok(actions.has('platform.tenant.status_updated'), 'platform audit should include status update');
const studentAuditDenied = await request('/api/platform-admin/audit-logs', {
tenantId: false,
userId: USER_ID,
expectStatus: 403,
});
assert.equal(studentAuditDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit logs');
}
function stopServer() { function stopServer() {
if (serverProcess && !serverProcess.killed) { if (serverProcess && !serverProcess.killed) {
serverProcess.kill(); serverProcess.kill();
@@ -8234,6 +8336,7 @@ async function main() {
await check('Supabase JWT identity', testSupabaseJwtIdentity); await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity); await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity);
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
await check('platform tenant operations and audit', testPlatformTenantOperationsAndAudit);
await check('catalog and learning', testCatalogAndLearning); await check('catalog and learning', testCatalogAndLearning);
await check('composite practice questions', testCompositePracticeQuestions); await check('composite practice questions', testCompositePracticeQuestions);
await check('profile', testProfile); await check('profile', testProfile);