From d5d32e84b317ef93d527f0f711085e7187e26c29 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 04:38:36 +0800 Subject: [PATCH] feat: add platform tenant detail audit console --- README.md | 8 +- apps/api/src/features/platform-admin/index.ts | 2 + .../api/src/features/platform-admin/routes.ts | 263 ++++++++++++++---- .../pages/platform-admin/tenants/index.tsx | 250 +++++++++++++++-- .../pages/platform-admin/workbench/index.tsx | 19 +- apps/taro/src/services/platformAdmin.ts | 98 +++++++ docs/refactor/backend-capability-status.md | 4 +- docs/refactor/backend-handoff-roadmap.md | 10 +- docs/refactor/backend-progress.md | 5 +- docs/refactor/blueprint-coverage.md | 2 +- docs/refactor/frontend-handoff-index.md | 10 +- docs/refactor/implementation-status.md | 5 +- docs/refactor/legacy-feature-gap-matrix.md | 6 +- docs/refactor/next-development-todo.md | 8 +- docs/refactor/taro-frontend-integration.md | 6 +- scripts/api-integration-test.js | 103 +++++++ 16 files changed, 688 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index ae342b7a..4fe837a4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## 当前状态 -更新时间:2026-06-29 +更新时间:2026-06-30 目前已经完成并在本地验证通过的内容: @@ -15,7 +15,7 @@ - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 -- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 +- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 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 配置、跟进分配策略和队列。 @@ -35,7 +35,7 @@ - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 -- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 +- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计、订阅、账单、收款、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 更完整的进度看这些文档: @@ -430,7 +430,7 @@ git diff --check 优先继续补: 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 -2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。 +2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台自动计费、批量账单、审计报表增强和更细平台权限点,小程序兼容验证。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/platform-admin/index.ts b/apps/api/src/features/platform-admin/index.ts index b1003aa3..e622cce6 100644 --- a/apps/api/src/features/platform-admin/index.ts +++ b/apps/api/src/features/platform-admin/index.ts @@ -5,6 +5,7 @@ import { createSubscriptionRoute, createTenantInvoiceFromSubscriptionRoute, createTenantRoute, + platformAuditLogsRoute, platformOverviewRoute, platformPlansRoute, platformQuestionBanksRoute, @@ -30,6 +31,7 @@ export const platformAdminRoutes: RouteDefinition[] = [ ['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute], ['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute], ['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute], + ['GET', '/api/platform-admin/audit-logs', platformAuditLogsRoute], ['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute], ['GET', '/api/platform-admin/invoices', tenantInvoicesRoute], ['POST', '/api/platform-admin/invoices', createInvoiceRoute], diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts index 29f73ca1..cc3d37da 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -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 { intParam, optionalStringArray, @@ -37,6 +39,54 @@ function optionalUuidArray(body: Record, key: string) { 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 = {}, + 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) { const scope = value || 'plans'; 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.contact_name as "contactName", bp.contact_phone as "contactPhone", 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 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 @@ -454,7 +505,7 @@ export async function tenantDetailRoute(ctx: RequestContext) { ), 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", balance_cents as "balanceCents", due_date as "dueDate", issued_at as "issuedAt", paid_at as "paidAt", created_at as "createdAt" @@ -467,7 +518,7 @@ export async function tenantDetailRoute(ctx: RequestContext) { ), 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, created_at as "createdAt" from public.tenant_usage_records @@ -482,6 +533,63 @@ export async function tenantDetailRoute(ctx: RequestContext) { 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) { await requirePlatformAdmin(ctx); @@ -497,6 +605,7 @@ export async function createTenantRoute(ctx: RequestContext) { const legalName = optionalString(body, 'legalName') || null; const primaryHost = optionalString(body, 'primaryHost'); const billing = body.billing && typeof body.billing === 'object' ? (body.billing as Record) : {}; + 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 item = await transaction(async client => { @@ -561,7 +670,7 @@ export async function createTenantRoute(ctx: RequestContext) { typeof billing.contactEmail === 'string' ? billing.contactEmail : null, typeof billing.billingAddress === 'string' ? billing.billingAddress : null, typeof billing.invoiceTitle === 'string' ? billing.invoiceTitle : legalName || name, - typeof billing.invoiceType === 'string' ? billing.invoiceType : 'none', + billingInvoiceType, 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; }); @@ -620,21 +738,30 @@ export async function updateTenantStatusRoute(ctx: RequestContext) { const billingStatus = optionalString(body, 'billingStatus'); if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD'); - const item = await queryOne( - ` - update public.tenants - set status = coalesce(nullif($2, ''), status), - billing_status = coalesce(nullif($3, ''), billing_status), - metadata = metadata || $4::jsonb, - updated_at = now() - where id = $1 - returning id, slug::text, name, status, billing_status as "billingStatus", - metadata, updated_at as "updatedAt" - `, - [tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })], - ); + const item = await transaction(async client => { + const result = await client.query( + ` + update public.tenants + set status = coalesce(nullif($2, ''), status), + billing_status = coalesce(nullif($3, ''), billing_status), + metadata = metadata || $4::jsonb, + updated_at = now() + where id = $1 + returning id, slug::text, name, status, billing_status as "billingStatus", + metadata, updated_at as "updatedAt" + `, + [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 }; } @@ -643,49 +770,63 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) { const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); + const invoiceType = billingInvoiceTypeFrom(optionalString(body, 'invoiceType')); - const item = await queryOne( - ` - insert into public.tenant_billing_profiles ( - 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) - do update set billing_name = excluded.billing_name, - tax_id = excluded.tax_id, - contact_name = excluded.contact_name, - contact_phone = excluded.contact_phone, - contact_email = excluded.contact_email, - billing_address = excluded.billing_address, - invoice_title = excluded.invoice_title, - invoice_type = excluded.invoice_type, - bank_name = excluded.bank_name, - bank_account_masked = excluded.bank_account_masked, - metadata = excluded.metadata, - updated_at = now() - returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId", - contact_name as "contactName", contact_phone as "contactPhone", - contact_email as "contactEmail", billing_address as "billingAddress", - invoice_title as "invoiceTitle", invoice_type as "invoiceType", - bank_name as "bankName", bank_account_masked as "bankAccountMasked", - metadata, updated_at as "updatedAt" - `, - [ - tenantId, - optionalString(body, 'billingName') || null, - optionalString(body, 'taxId') || null, - optionalString(body, 'contactName') || null, - optionalString(body, 'contactPhone') || null, - optionalString(body, 'contactEmail') || null, - optionalString(body, 'billingAddress') || null, - optionalString(body, 'invoiceTitle') || null, - optionalString(body, 'invoiceType') || 'none', - optionalString(body, 'bankName') || null, - optionalString(body, 'bankAccountMasked') || null, - jsonBodyValue(body.metadata), - ], - ); + 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, + 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) + do update set billing_name = excluded.billing_name, + tax_id = excluded.tax_id, + contact_name = excluded.contact_name, + contact_phone = excluded.contact_phone, + contact_email = excluded.contact_email, + billing_address = excluded.billing_address, + invoice_title = excluded.invoice_title, + invoice_type = excluded.invoice_type, + bank_name = excluded.bank_name, + bank_account_masked = excluded.bank_account_masked, + metadata = excluded.metadata, + updated_at = now() + returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId", + contact_name as "contactName", contact_phone as "contactPhone", + contact_email as "contactEmail", billing_address as "billingAddress", + invoice_title as "invoiceTitle", invoice_type as "invoiceType", + bank_name as "bankName", bank_account_masked as "bankAccountMasked", + metadata, updated_at as "updatedAt" + `, + [ + tenantId, + optionalString(body, 'billingName') || null, + optionalString(body, 'taxId') || null, + optionalString(body, 'contactName') || null, + optionalString(body, 'contactPhone') || null, + optionalString(body, 'contactEmail') || null, + optionalString(body, 'billingAddress') || null, + optionalString(body, 'invoiceTitle') || null, + invoiceType, + optionalString(body, 'bankName') || null, + 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 }; } diff --git a/apps/taro/src/pages/platform-admin/tenants/index.tsx b/apps/taro/src/pages/platform-admin/tenants/index.tsx index c83a8f63..1c962174 100644 --- a/apps/taro/src/pages/platform-admin/tenants/index.tsx +++ b/apps/taro/src/pages/platform-admin/tenants/index.tsx @@ -3,10 +3,15 @@ import Taro from '@tarojs/taro'; import { Button, Input, Text, View } from '@tarojs/components'; import { createPlatformTenant, + loadPlatformAuditLogs, loadPlatformPlans, + loadPlatformTenantDetail, loadPlatformTenants, updatePlatformTenantStatus, + upsertPlatformTenantBillingProfile, + type PlatformAuditLogItem, type PlatformSaasPlan, + type PlatformTenantDetail, type PlatformTenantItem, } from '@/services/platformAdmin'; import '../platform.css'; @@ -21,11 +26,41 @@ function centsFromYuan(value: string) { 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() { const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState(''); const [plans, setPlans] = useState([]); const [tenants, setTenants] = useState([]); + const [selectedTenantId, setSelectedTenantId] = useState(''); + const [detail, setDetail] = useState(null); + const [auditLogs, setAuditLogs] = useState([]); const [tenantForm, setTenantForm] = useState({ slug: '', name: '', @@ -42,6 +77,7 @@ export default function PlatformTenantsPage() { billingStatus: '', reason: '', }); + const [billingForm, setBillingForm] = useState(emptyBillingForm); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); @@ -51,6 +87,43 @@ export default function PlatformTenantsPage() { .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(() => { reload('', ''); loadPlatformPlans().then(payload => { @@ -73,6 +146,22 @@ export default function PlatformTenantsPage() { 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) { const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' }); return result.confirm; @@ -90,7 +179,7 @@ export default function PlatformTenantsPage() { if (!ok) return; setBusy('create'); try { - await createPlatformTenant({ + const payload = await createPlatformTenant({ slug, name, legalName: tenantForm.legalName.trim() || undefined, @@ -112,6 +201,10 @@ export default function PlatformTenantsPage() { amountYuan: '', })); reload(status, keyword); + if (payload.item?.id) { + setSelectedTenantId(payload.item.id); + await loadDetail(payload.item.id); + } } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '创建租户失败'); } finally { @@ -141,6 +234,7 @@ export default function PlatformTenantsPage() { }); Taro.showToast({ title: '已更新', icon: 'success' }); reload(status, keyword); + await loadDetail(statusForm.tenantId); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '状态更新失败'); } 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 ( Tenants 租户管理 - 查看 SaaS 租户的品牌、订阅、状态和欠费情况;创建和状态变更表单后续接入同一组平台 API。 + 创建合作商租户、维护 SaaS 状态和账务资料,并追踪租户级平台审计证据。 @@ -176,7 +304,7 @@ export default function PlatformTenantsPage() { 当前列表{String(tenants.length)} 欠费租户{String(tenants.filter(item => Number(item.openBalanceCents || 0) > 0).length)} 试用租户{String(tenants.filter(item => item.billingStatus === 'trial').length)} - 暂停租户{String(tenants.filter(item => item.status === 'suspended').length)} + 已选租户{selectedTenant ? selectedTenant.slug : '-'} @@ -196,19 +324,6 @@ export default function PlatformTenantsPage() { - - 状态变更 - - 租户 ID updateStatusForm('tenantId', String(event.detail.value || ''))} /> - 租户状态 updateStatusForm('status', String(event.detail.value || ''))} /> - 账务状态 updateStatusForm('billingStatus', String(event.detail.value || ''))} /> - 原因 updateStatusForm('reason', String(event.detail.value || ''))} /> - - - - - - 租户列表 @@ -216,17 +331,114 @@ export default function PlatformTenantsPage() { {item.brandName || item.name} {item.slug} · {item.legalName || '未填公司'} · {item.status || '-'} · {item.billingStatus || '-'} - 套餐 {item.planCode || '未订阅'} · 订阅 {item.subscriptionStatus || '-'} · 到期 {item.subscriptionExpiresAt ? String(item.subscriptionExpiresAt).slice(0, 10) : '-'} + 套餐 {item.planCode || '未订阅'} · 订阅 {item.subscriptionStatus || '-'} · 到期 {dateText(item.subscriptionExpiresAt)} 未收余额 {money(item.openBalanceCents)} - - + + ))} {!tenants.length ? 暂无租户,或当前平台管理员权限未通过。 : null} + + {selectedTenant ? ( + + 租户详情 + + 品牌{selectedTenant.brandName || selectedTenant.name} + 租户状态{selectedTenant.status || '-'} + 账务状态{selectedTenant.billingStatus || '-'} + 主体{selectedTenant.legalName || '-'} + + + + 状态变更 + + 租户 ID updateStatusForm('tenantId', String(event.detail.value || ''))} /> + 租户状态 updateStatusForm('status', String(event.detail.value || ''))} /> + 账务状态 updateStatusForm('billingStatus', String(event.detail.value || ''))} /> + 原因 updateStatusForm('reason', String(event.detail.value || ''))} /> + + + + + + + + 账务资料 + + 租户 ID updateBillingForm('tenantId', String(event.detail.value || ''))} /> + 开票名称 updateBillingForm('billingName', String(event.detail.value || ''))} /> + 税号 updateBillingForm('taxId', String(event.detail.value || ''))} /> + 联系人 updateBillingForm('contactName', String(event.detail.value || ''))} /> + 联系电话 updateBillingForm('contactPhone', String(event.detail.value || ''))} /> + 联系邮箱 updateBillingForm('contactEmail', String(event.detail.value || ''))} /> + 发票类型 updateBillingForm('invoiceType', String(event.detail.value || ''))} /> + 发票抬头 updateBillingForm('invoiceTitle', String(event.detail.value || ''))} /> + 账单地址 updateBillingForm('billingAddress', String(event.detail.value || ''))} /> + 开户行 updateBillingForm('bankName', String(event.detail.value || ''))} /> + 银行账号掩码 updateBillingForm('bankAccountMasked', String(event.detail.value || ''))} /> + + + + + + + + + 域名 / 订阅 / 账单 / 用量 + + {(detail?.domains || []).map(item => ( + + {item.host || item.id} + {item.domainType || 'domain'} · {item.status || '-'} · {item.isPrimary ? 'primary' : 'secondary'} · 验证 {dateText(item.verifiedAt)} + + ))} + {(detail?.subscriptions || []).slice(0, 3).map(item => ( + + {item.planCode || item.id} + {item.status || '-'} · {item.billingCycle || '-'} · {money(item.amountCents)} · {dateText(item.startsAt)} 至 {dateText(item.expiresAt)} + + ))} + {(detail?.invoices || []).slice(0, 4).map(item => ( + + {item.invoiceNo || item.id} + {item.status || '-'} · 总额 {money(item.totalCents)} · 已收 {money(item.paidCents)} · 余额 {money(item.balanceCents)} · 到期 {dateText(item.dueDate)} + + ))} + {(detail?.usage || []).slice(0, 6).map(item => ( + + {item.metricKey || 'metric'} + {String(item.metricValue || 0)} · {dateText(item.periodStart)} 至 {dateText(item.periodEnd)} + + ))} + + + + + 最近平台审计 + + {auditLogs.map(item => ( + + {item.action || '-'} + {dateText(item.createdAt)} · {item.actorName || item.actorUsername || item.actorPhone || 'system'} · {item.targetType || '-'} · {item.targetId || '-'} + {auditDetailsText(item)} + + ))} + + {!auditLogs.length ? 暂无平台审计记录。 : null} + + + ) : ( + 从租户列表点击“详情”后,可以查看域名、订阅、账单、用量、账务资料和平台审计。 + )} + {error ? {error} : null} diff --git a/apps/taro/src/pages/platform-admin/workbench/index.tsx b/apps/taro/src/pages/platform-admin/workbench/index.tsx index beb94ef2..9a5fa7a2 100644 --- a/apps/taro/src/pages/platform-admin/workbench/index.tsx +++ b/apps/taro/src/pages/platform-admin/workbench/index.tsx @@ -2,11 +2,13 @@ import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Text, View } from '@tarojs/components'; import { + loadPlatformAuditLogs, loadPlatformInvoices, loadPlatformOverview, loadPlatformQuestionBankGrants, loadPlatformQuestionBanks, loadPlatformTenants, + type PlatformAuditLogItem, type PlatformInvoiceItem, type PlatformOverview, type PlatformQuestionBankGrant, @@ -23,6 +25,7 @@ export default function PlatformWorkbenchPage() { const [overview, setOverview] = useState(null); const [tenants, setTenants] = useState([]); const [invoices, setInvoices] = useState([]); + const [auditLogs, setAuditLogs] = useState([]); const [banks, setBanks] = useState([]); const [grants, setGrants] = useState([]); const [error, setError] = useState(''); @@ -34,12 +37,14 @@ export default function PlatformWorkbenchPage() { loadPlatformInvoices({ limit: 6 }).catch(() => ({ items: [] })), loadPlatformQuestionBanks({ 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); setTenants(tenantPayload.items || []); setInvoices(invoicePayload.items || []); setBanks(bankPayload.items || []); setGrants(grantPayload.items || []); + setAuditLogs(auditPayload.items || []); }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败')); }, []); @@ -105,6 +110,18 @@ export default function PlatformWorkbenchPage() { 逾期账单{String(overview?.billing?.overdueInvoices || 0)} + + 最近平台审计 + + {auditLogs.map(item => ( + + {item.action || '-'} + {item.tenantName || item.tenantSlug || '平台'} · {item.actorName || item.actorUsername || 'system'} · {item.targetType || '-'} · {String(item.createdAt || '').slice(0, 19).replace('T', ' ')} + + ))} + + {!auditLogs.length ? 暂无审计记录。 : null} + {error ? {error} : null} diff --git a/apps/taro/src/services/platformAdmin.ts b/apps/taro/src/services/platformAdmin.ts index 99b243a4..e787b6b3 100644 --- a/apps/taro/src/services/platformAdmin.ts +++ b/apps/taro/src/services/platformAdmin.ts @@ -54,6 +54,60 @@ export interface PlatformTenantItem { 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 | null; +} + +export interface PlatformTenantDetailTenant extends PlatformTenantItem, PlatformTenantBillingProfile { + shortName?: string | null; + serviceWechat?: string | null; + ownerUserId?: string | null; + metadata?: Record | 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 | null; + createdAt?: string | null; +} + +export interface PlatformTenantDetail { + tenant?: PlatformTenantDetailTenant | null; + domains?: PlatformTenantDomain[]; + subscriptions?: PlatformTenantSubscription[]; + invoices?: PlatformInvoiceItem[]; + usage?: PlatformUsageItem[]; +} + export interface PlatformInvoiceItem { id: string; tenantId: string; @@ -113,6 +167,24 @@ export interface PlatformQuestionBankGrant { 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 | null; + ipAddress?: string | null; + userAgent?: string | null; + createdAt?: string | null; +} + export interface CreatePlatformTenantInput { slug: string; name: string; @@ -133,6 +205,10 @@ export interface UpdatePlatformTenantStatusInput { reason?: string; } +export interface UpsertPlatformTenantBillingProfileInput extends PlatformTenantBillingProfile { + tenantId: string; +} + export interface CreatePlatformSubscriptionInput { tenantId: 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 } = {}) { return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', { 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) { return apiRequest<{ item?: Record }>('/api/platform-admin/subscriptions', { method: 'POST', diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 53ae1c57..b5f90eb0 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -1,6 +1,6 @@ # 后端当前能力盘点 -更新时间:2026-06-29 +更新时间:2026-06-30 当前后端已经完成商用 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/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 | | 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 | -| 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` | +| 平台租户/详情/账务资料/审计/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、收款和用量;创建租户、状态变更、账务资料维护会写入审计 | | 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 平台公共题库授权 | 可联调 | `/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 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 325ac0a5..c50f4ffc 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -1,6 +1,6 @@ # 后端进度同步与前端接入路线图 -更新时间:2026-06-29 +更新时间:2026-06-30 这份文档用于在进入 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 深测继续执行 | -| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 | +| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台审计日志、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、批量账单、平台审计报表增强、更完整运营消息 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、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 执行验收、抽样校验和导入性能压测 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | 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 级水印。 - 真实数据 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 容器部署说明。 -- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台后台审计增强和小程序兼容验证。 +- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑和最近平台审计第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台后台自动计费/批量账单/审计报表增强和小程序兼容验证。 ### P1:商用收费和运营能力 @@ -93,7 +93,7 @@ ### P2:企业级体验和增长闭环 -- 租户自定义角色模板基础 API、Taro 权限配置 UI、成员绑定模板和工作台权限驱动入口第一版已完成;继续补班级/教师/学生组合范围 UI、成员批量运营和平台级审计报表。 +- 租户自定义角色模板基础 API、Taro 权限配置 UI、成员绑定模板和工作台权限驱动入口第一版已完成;继续补班级/教师/学生组合范围 UI、成员批量运营和更完整平台级审计报表。 - 三套默认主题、租户主题预览、Logo/图标/分享图配置。 - CRM worker:钉钉、飞书、企微机器人发送、签名、失败重试已落地;Taro 营销中心已能配置 CRM 和查看队列;继续补轮询/定向分配、富卡片、失败告警和死信运营台。 - 销售/代理分佣基础闭环已接 Taro 第一版;继续补销售团队看板、客资跟进效果、结算导出、真实打款、凭证和财务复核。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 0c6207d0..84d29513 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -2,6 +2,7 @@ ## 已完成 +- 更新时间:2026-06-30。 - Docker Desktop + Supabase local 已可用。 - API Docker 镜像 `tiku-saas-dev-api:latest` 已可构建,并可从容器连接宿主 Supabase PostgreSQL。 - API 已按 `core/features` 分层: @@ -13,11 +14,12 @@ - `video`:题目视频讲解、批量预加载、通用视频搜索。 - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - - `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。 + - `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、使用量。 - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant`:域名/租户解析。 - 鉴权上下文已支持 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_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。 @@ -54,6 +56,7 @@ POST /api/platform-admin/tenants GET /api/platform-admin/tenants/detail PATCH /api/platform-admin/tenants/status PUT /api/platform-admin/tenants/billing-profile +GET /api/platform-admin/audit-logs POST /api/platform-admin/subscriptions GET /api/platform-admin/invoices POST /api/platform-admin/invoices diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index f163cce2..31110123 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -15,7 +15,7 @@ | 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 | | --- | --- | --- | --- | -| 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 | +| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、自动计费、批量账单和平台审计报表增强 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | | 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 | diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 6db176ca..a7b59f40 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -1,6 +1,6 @@ # 前端交接索引 -更新时间:2026-06-29 +更新时间:2026-06-30 这份文件是给 Taro/H5/小程序前端同事的入口。当前仓库的前端重构建议从 `apps/taro` 新建工程开始,不再把旧 React/Vite 前端搬回根目录继续开发。 @@ -31,7 +31,7 @@ - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。 - 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 -- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。 +- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要。 - 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以按新后端主模型接入内容导航: - `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/tenants/index.tsx` | `platform-admin/tenants`、`POST tenants`、`PATCH tenants/status` | +| 工作台 | `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`、`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/question-banks/index.tsx` | `platform-admin/question-banks`、`question-bank-grants`、`PUT question-bank-grants` | -当前平台后台已经具备第一批写操作台:创建租户、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户详情页、编辑租户基础资料、平台审计报表、自动计费、账单批量操作和更细平台权限点。 +当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费、账单批量操作和更细平台权限点。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 6ed150ce..71fe1a24 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -1,6 +1,6 @@ # Supabase 重构功能进度矩阵 -更新时间:2026-06-29 +更新时间:2026-06-30 ## 当前结论 @@ -37,7 +37,7 @@ | 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 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 生产账号和回调域名联调待补 | | 数据导入 | 已建立 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 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | @@ -280,6 +280,7 @@ platform-admin: GET /api/platform-admin/tenants/detail PATCH /api/platform-admin/tenants/status PUT /api/platform-admin/tenants/billing-profile + GET /api/platform-admin/audit-logs POST /api/platform-admin/subscriptions GET /api/platform-admin/invoices POST /api/platform-admin/invoices diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 00b715bf..6876c0e0 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -1,6 +1,6 @@ # 旧题库功能差距矩阵 -更新时间:2026-06-29 +更新时间:2026-06-30 旧项目位于 `F:\project\参考\旧题库项目`,只作为功能、样式、交互和迁移参考。新项目不再复刻 PocketBase/SQLite 的数据结构,而是以新多租户 SaaS 模型为准。 @@ -73,13 +73,13 @@ | 功能 | 新后端状态 | 待补齐 | | --- | --- | --- | -| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户和状态变更已接真实 API;编辑租户基础资料、详情页和平台审计报表待补 | +| 创建/管理租户 | 已覆盖 | 平台后台租户列表、创建租户、租户详情、状态变更、账务资料维护和最近平台审计已接真实 API;后续补自动计费、批量账单、租户基础资料编辑增强和更完整平台审计报表 | | SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | | 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、同步通知、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺生产定时调度、失败告警和更完整运营消息 | | 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 | -| 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 | +| 租户安全审计 | 部分覆盖 | 租户侧 audit logs 和平台侧 `/api/platform-admin/audit-logs` 第一版已有,平台后台工作台/租户详情页可查看最近审计;缺更完整筛选报表、导出和告警 | ## 旧功能中应重新设计的点 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index b9b95609..b0ddd9c7 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -1,6 +1,6 @@ # 后续开发 TODO -更新时间:2026-06-29 +更新时间:2026-06-30 ## 当前后端基线 @@ -11,7 +11,7 @@ - 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。 - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。 -- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 +- 平台后台 API:租户、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、账单、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 @@ -185,7 +185,7 @@ - H5 和小程序共用同一套业务 API client。 - 租户通过域名、小程序配置或启动参数解析。 - 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/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 和发票。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。 -3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 +3. 补平台后台增强:租户基础资料编辑增强、平台审计报表导出/告警、自动计费、账单批量操作和更细平台权限点。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。 6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 35ab2870..194f945a 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -1,6 +1,6 @@ # Taro 前端对接指南 -更新时间:2026-06-29 +更新时间:2026-06-30 目标:用一套 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/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录 src/services/tenantFinance.ts 租户财务运营:退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证 -src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权 +src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计、套餐账单、用量、公共题库授权 ``` 验证命令: @@ -2537,7 +2537,7 @@ npm run build:taro:h5:platform - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。 - 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。 -- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。 +- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费和批量账单操作。 - 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。 ## AI 择校推荐接入 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 728e3798..81822eba 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -1138,6 +1138,108 @@ async function testLegacyAuthHeadersDisabled() { 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() { if (serverProcess && !serverProcess.killed) { serverProcess.kill(); @@ -8234,6 +8336,7 @@ async function main() { await check('Supabase JWT identity', testSupabaseJwtIdentity); await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity); await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); + await check('platform tenant operations and audit', testPlatformTenantOperationsAndAudit); await check('catalog and learning', testCatalogAndLearning); await check('composite practice questions', testCompositePracticeQuestions); await check('profile', testProfile);