forked from wangziqi/gongxue-base
feat: add platform tenant detail audit console
This commit is contained in:
@@ -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、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 第一版;继续补销售团队看板、客资跟进效果、结算导出、真实打款、凭证和财务复核。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|
||||||
当前平台后台已经具备第一批写操作台:创建租户、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户详情页、编辑租户基础资料、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限和审计。下一批继续补租户基础资料编辑增强、平台审计报表导出/告警、自动计费、账单批量操作和更细平台权限点。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` 第一版已有,平台后台工作台/租户详情页可查看最近审计;缺更完整筛选报表、导出和告警 |
|
||||||
|
|
||||||
## 旧功能中应重新设计的点
|
## 旧功能中应重新设计的点
|
||||||
|
|
||||||
|
|||||||
@@ -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 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||||
|
|||||||
@@ -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 择校推荐接入
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user